• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

gameap / gameap / 29660672822

18 Jul 2026 08:56PM UTC coverage: 82.16% (-0.001%) from 82.161%
29660672822

push

github

web-flow
Fix file-manager copy-paste (#27)

63 of 68 new or added lines in 1 file covered. (92.65%)

11 existing lines in 4 files now uncovered.

47131 of 57365 relevant lines covered (82.16%)

34889.14 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.38
/internal/api/filemanager/paste/handler.go
1
package paste
2

3
import (
4
        "context"
5
        "encoding/json"
6
        "net/http"
7
        "path"
8
        "path/filepath"
9
        "strings"
10

11
        "github.com/gameap/gameap/internal/api/base"
12
        "github.com/gameap/gameap/internal/api/filemanager/filemanagerpath"
13
        serversbase "github.com/gameap/gameap/internal/api/servers/base"
14
        "github.com/gameap/gameap/internal/domain"
15
        "github.com/gameap/gameap/internal/filters"
16
        "github.com/gameap/gameap/internal/repositories"
17
        "github.com/gameap/gameap/pkg/api"
18
        "github.com/gameap/gameap/pkg/auth"
19
        "github.com/pkg/errors"
20
)
21

22
const (
23
        operationTypeCopy = "copy"
24
        operationTypeCut  = "cut"
25
)
26

27
type fileService interface {
28
        Copy(ctx context.Context, node *domain.Node, source, destination string) error
29
        Move(ctx context.Context, node *domain.Node, source, destination string) error
30
}
31

32
type Handler struct {
33
        serverFinder   *serversbase.ServerFinder
34
        abilityChecker *serversbase.AbilityChecker
35
        nodeRepo       repositories.NodeRepository
36
        daemonFiles    fileService
37
        responder      base.Responder
38
}
39

40
func NewHandler(
41
        serverRepo repositories.ServerRepository,
42
        nodeRepo repositories.NodeRepository,
43
        rbac base.RBAC,
44
        daemonFiles fileService,
45
        responder base.Responder,
46
) *Handler {
36✔
47
        return &Handler{
36✔
48
                serverFinder:   serversbase.NewServerFinder(serverRepo, rbac),
36✔
49
                abilityChecker: serversbase.NewAbilityChecker(rbac),
36✔
50
                nodeRepo:       nodeRepo,
36✔
51
                daemonFiles:    daemonFiles,
36✔
52
                responder:      responder,
36✔
53
        }
36✔
54
}
36✔
55

56
func (h *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
36✔
57
        ctx := r.Context()
36✔
58

36✔
59
        session := auth.SessionFromContext(ctx)
36✔
60
        if !session.IsAuthenticated() {
37✔
61
                h.responder.WriteError(ctx, rw, api.WrapHTTPError(
1✔
62
                        errors.New("user not authenticated"),
1✔
63
                        http.StatusUnauthorized,
1✔
64
                ))
1✔
65

1✔
66
                return
1✔
67
        }
1✔
68

69
        input := api.NewInputReader(r)
35✔
70

35✔
71
        serverID, err := input.ReadUint("server")
35✔
72
        if err != nil {
36✔
73
                h.responder.WriteError(ctx, rw, api.WrapHTTPError(
1✔
74
                        errors.WithMessage(err, "invalid server id"),
1✔
75
                        http.StatusBadRequest,
1✔
76
                ))
1✔
77

1✔
78
                return
1✔
79
        }
1✔
80

81
        server, err := h.serverFinder.FindUserServer(ctx, session.User, serverID)
34✔
82
        if err != nil {
36✔
83
                h.responder.WriteError(ctx, rw, err)
2✔
84

2✔
85
                return
2✔
86
        }
2✔
87

88
        err = h.abilityChecker.CheckOrError(
32✔
89
                ctx,
32✔
90
                session.User.ID,
32✔
91
                server.ID,
32✔
92
                []domain.AbilityName{domain.AbilityNameGameServerFiles},
32✔
93
        )
32✔
94
        if err != nil {
33✔
95
                h.responder.WriteError(ctx, rw, err)
1✔
96

1✔
97
                return
1✔
98
        }
1✔
99

100
        var req pasteRequest
31✔
101
        err = json.NewDecoder(r.Body).Decode(&req)
31✔
102
        if err != nil {
32✔
103
                h.responder.WriteError(ctx, rw, api.WrapHTTPError(
1✔
104
                        errors.WithMessage(err, "invalid request body"),
1✔
105
                        http.StatusBadRequest,
1✔
106
                ))
1✔
107

1✔
108
                return
1✔
109
        }
1✔
110

111
        if err = h.validateRequest(&req); err != nil {
34✔
112
                h.responder.WriteError(ctx, rw, api.WrapHTTPError(err, http.StatusBadRequest))
4✔
113

4✔
114
                return
4✔
115
        }
4✔
116

117
        node, err := h.getNode(ctx, server.DSID)
26✔
118
        if err != nil {
27✔
119
                h.responder.WriteError(ctx, rw, err)
1✔
120

1✔
121
                return
1✔
122
        }
1✔
123

124
        if err = h.processItems(ctx, node, server.Dir, &req); err != nil {
36✔
125
                h.responder.WriteError(ctx, rw, err)
11✔
126

11✔
127
                return
11✔
128
        }
11✔
129

130
        h.responder.Write(ctx, rw, newPasteResponse(req.Clipboard.Type))
14✔
131
}
132

133
func (h *Handler) validateRequest(req *pasteRequest) error {
30✔
134
        if req.Disk != "server" {
31✔
135
                return errors.Errorf("unsupported disk: %s, only 'server' disk is supported", req.Disk)
1✔
136
        }
1✔
137

138
        if req.Clipboard.Disk != "server" {
30✔
139
                return errors.Errorf(
1✔
140
                        "unsupported clipboard disk: %s, only same-disk operations are supported",
1✔
141
                        req.Clipboard.Disk,
1✔
142
                )
1✔
143
        }
1✔
144

145
        if req.Clipboard.Type != operationTypeCopy && req.Clipboard.Type != operationTypeCut {
29✔
146
                return errors.Errorf("unsupported clipboard type: %s, must be 'copy' or 'cut'", req.Clipboard.Type)
1✔
147
        }
1✔
148

149
        if len(req.Clipboard.Files) == 0 && len(req.Clipboard.Directories) == 0 {
28✔
150
                return errors.New("clipboard is empty: no files or directories to paste")
1✔
151
        }
1✔
152

153
        return nil
26✔
154
}
155

156
func (h *Handler) getNode(ctx context.Context, nodeID uint) (*domain.Node, error) {
26✔
157
        nodes, err := h.nodeRepo.Find(ctx, &filters.FindNode{
26✔
158
                IDs: []uint{nodeID},
26✔
159
        }, nil, &filters.Pagination{
26✔
160
                Limit: 1,
26✔
161
        })
26✔
162
        if err != nil {
26✔
163
                return nil, errors.WithMessage(err, "failed to find node")
×
164
        }
×
165

166
        if len(nodes) == 0 {
27✔
167
                return nil, api.NewNotFoundError("node not found")
1✔
168
        }
1✔
169

170
        return &nodes[0], nil
25✔
171
}
172

173
func (h *Handler) processItems(
174
        ctx context.Context,
175
        node *domain.Node,
176
        serverDir string,
177
        req *pasteRequest,
178
) error {
25✔
179
        destPath := strings.ReplaceAll(req.Path, "\\", "/")
25✔
180
        if err := filemanagerpath.ValidatePath(destPath); err != nil {
26✔
181
                return api.WrapHTTPError(err, http.StatusBadRequest)
1✔
182
        }
1✔
183

184
        sourceBase := filepath.Join(node.WorkPath, serverDir)
24✔
185
        destinationBase := filepath.Join(node.WorkPath, serverDir, destPath)
24✔
186

24✔
187
        operations := make([]pasteOperation, 0, len(req.Clipboard.Files)+len(req.Clipboard.Directories))
24✔
188

24✔
189
        for _, rawFilePath := range req.Clipboard.Files {
46✔
190
                operation, ok, err := planPasteItem(sourceBase, destinationBase, rawFilePath, false, req)
22✔
191
                if err != nil {
28✔
192
                        return err
6✔
193
                }
6✔
194

195
                if ok {
30✔
196
                        operations = append(operations, operation)
14✔
197
                }
14✔
198
        }
199

200
        for _, rawDirPath := range req.Clipboard.Directories {
26✔
201
                operation, ok, err := planPasteItem(sourceBase, destinationBase, rawDirPath, true, req)
8✔
202
                if err != nil {
12✔
203
                        return err
4✔
204
                }
4✔
205

206
                if ok {
7✔
207
                        operations = append(operations, operation)
3✔
208
                }
3✔
209
        }
210

211
        for _, operation := range operations {
30✔
212
                if err := h.pasteItem(ctx, node, operation.source, operation.destination, req.Clipboard.Type); err != nil {
16✔
NEW
213
                        itemKind := "file"
×
NEW
214
                        if operation.isDir {
×
NEW
215
                                itemKind = "directory"
×
NEW
216
                        }
×
217

NEW
218
                        return errors.WithMessagef(err, "failed to paste %s: %s", itemKind, operation.itemPath)
×
219
                }
220
        }
221

222
        return nil
14✔
223
}
224

225
func (h *Handler) pasteItem(
226
        ctx context.Context,
227
        node *domain.Node,
228
        source string,
229
        destination string,
230
        operationType string,
231
) error {
16✔
232
        switch operationType {
16✔
233
        case operationTypeCopy:
14✔
234
                return h.daemonFiles.Copy(ctx, node, source, destination)
14✔
235
        case operationTypeCut:
2✔
236
                return h.daemonFiles.Move(ctx, node, source, destination)
2✔
237
        default:
×
238
                return errors.Errorf("unknown operation type: %s", operationType)
×
239
        }
240
}
241

242
// pasteOperation is a validated copy/move planned for execution. The whole
243
// request is planned before the first daemon call so a validation error in
244
// any item rejects the batch without partially applying it.
245
type pasteOperation struct {
246
        source      string
247
        destination string
248
        itemPath    string
249
        isDir       bool
250
}
251

252
// planPasteItem validates one clipboard item and resolves its operation.
253
// ok is false for a same-path move, which is skipped as a no-op.
254
func planPasteItem(
255
        sourceBase string,
256
        destinationBase string,
257
        rawPath string,
258
        isDir bool,
259
        req *pasteRequest,
260
) (pasteOperation, bool, error) {
30✔
261
        itemPath := strings.ReplaceAll(rawPath, "\\", "/")
30✔
262
        if err := filemanagerpath.ValidatePath(itemPath); err != nil {
32✔
263
                return pasteOperation{}, false, api.WrapHTTPError(err, http.StatusBadRequest)
2✔
264
        }
2✔
265

266
        sourcePath := filepath.Join(sourceBase, itemPath)
28✔
267

28✔
268
        if isDir && pathIsInside(destinationBase, sourcePath) {
31✔
269
                return pasteOperation{}, false, api.WrapHTTPError(
3✔
270
                        errors.Errorf("cannot paste directory %s into itself", itemPath),
3✔
271
                        http.StatusBadRequest,
3✔
272
                )
3✔
273
        }
3✔
274

275
        name, err := destinationName(rawPath, itemPath, req.Names)
25✔
276
        if err != nil {
29✔
277
                return pasteOperation{}, false, err
4✔
278
        }
4✔
279

280
        destinationPath := filepath.Join(destinationBase, name)
21✔
281

21✔
282
        if sourcePath == destinationPath {
25✔
283
                if req.Clipboard.Type == operationTypeCut {
7✔
284
                        return pasteOperation{}, false, nil
3✔
285
                }
3✔
286

287
                return pasteOperation{}, false, api.WrapHTTPError(
1✔
288
                        errors.Errorf("copy source and destination are the same: %s", itemPath),
1✔
289
                        http.StatusBadRequest,
1✔
290
                )
1✔
291
        }
292

293
        operation := pasteOperation{
17✔
294
                source:      sourcePath,
17✔
295
                destination: destinationPath,
17✔
296
                itemPath:    itemPath,
17✔
297
                isDir:       isDir,
17✔
298
        }
17✔
299

17✔
300
        return operation, true, nil
17✔
301
}
302

303
// destinationName returns the basename an item is pasted under: the override
304
// from names (keyed by the raw clipboard entry) or the item's own basename.
305
func destinationName(rawPath, normalizedPath string, names map[string]string) (string, error) {
25✔
306
        name, ok := names[rawPath]
25✔
307
        if !ok {
42✔
308
                return path.Base(normalizedPath), nil
17✔
309
        }
17✔
310

311
        if err := filemanagerpath.ValidateFilename(name); err != nil {
12✔
312
                return "", api.WrapHTTPError(err, http.StatusBadRequest)
4✔
313
        }
4✔
314

315
        return name, nil
4✔
316
}
317

318
// pathIsInside reports whether child is parent itself or located inside it.
319
func pathIsInside(child, parent string) bool {
7✔
320
        return child == parent || strings.HasPrefix(child, parent+string(filepath.Separator))
7✔
321
}
7✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc