tenseleyflow/shithub / dd2218d

Browse files

Add web file commit service

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
dd2218df5d825a101d706a9460c2ce7840715541
Parents
9ecd8ed
Tree
9a137c9

4 changed files

StatusFile+-
A internal/migrationsfs/migrations/0052_push_events_web_protocol.sql 15 0
M internal/repos/protection/protection.go 12 5
A internal/repos/webedit/webedit.go 608 0
A internal/repos/webedit/webedit_test.go 86 0
internal/migrationsfs/migrations/0052_push_events_web_protocol.sqladded
@@ -0,0 +1,15 @@
1
+-- +goose Up
2
+-- +goose StatementBegin
3
+ALTER TABLE push_events DROP CONSTRAINT push_events_protocol;
4
+ALTER TABLE push_events
5
+    ADD CONSTRAINT push_events_protocol
6
+    CHECK (protocol IN ('http', 'ssh', 'web'));
7
+-- +goose StatementEnd
8
+
9
+-- +goose Down
10
+-- +goose StatementBegin
11
+ALTER TABLE push_events DROP CONSTRAINT push_events_protocol;
12
+ALTER TABLE push_events
13
+    ADD CONSTRAINT push_events_protocol
14
+    CHECK (protocol IN ('http', 'ssh'));
15
+-- +goose StatementEnd
internal/repos/protection/protection.gomodified
@@ -70,7 +70,14 @@ func Enforce(ctx context.Context, pool *pgxpool.Pool, gitDir string, repoID int6
70
 		return deny(rule, "deletion of this branch is blocked by protection rule"), nil
70
 		return deny(rule, "deletion of this branch is blocked by protection rule"), nil
71
 	}
71
 	}
72
 
72
 
73
-	// 2. Force-push gate. Only meaningful when this is an update of an
73
+	// 2. Pull-request-only gate. Direct web edits and git pushes both
74
+	// advance refs directly, so rules requiring PRs reject creates and
75
+	// updates here. Deletions remain governed by prevent_deletion above.
76
+	if !isDelete && rule.RequirePrForPush {
77
+		return deny(rule, "direct pushes to this branch must go through a pull request"), nil
78
+	}
79
+
80
+	// 3. Force-push gate. Only meaningful when this is an update of an
74
 	// existing branch (both sides non-zero). Skipping allows the create
81
 	// existing branch (both sides non-zero). Skipping allows the create
75
 	// case (oldSHA all-zero) and the delete case (handled above).
82
 	// case (oldSHA all-zero) and the delete case (handled above).
76
 	if !isCreate && !isDelete && rule.PreventForcePush {
83
 	if !isCreate && !isDelete && rule.PreventForcePush {
@@ -83,7 +90,7 @@ func Enforce(ctx context.Context, pool *pgxpool.Pool, gitDir string, repoID int6
83
 		}
90
 		}
84
 	}
91
 	}
85
 
92
 
86
-	// 3. Allowed-pushers gate.
93
+	// 4. Allowed-pushers gate.
87
 	if len(rule.AllowedPusherUserIds) > 0 {
94
 	if len(rule.AllowedPusherUserIds) > 0 {
88
 		ok := false
95
 		ok := false
89
 		for _, id := range rule.AllowedPusherUserIds {
96
 		for _, id := range rule.AllowedPusherUserIds {
@@ -97,9 +104,9 @@ func Enforce(ctx context.Context, pool *pgxpool.Pool, gitDir string, repoID int6
97
 		}
104
 		}
98
 	}
105
 	}
99
 
106
 
100
-	// 4. require_signed_commits, require_pr_for_push, status_checks_required
107
+	// 5. require_signed_commits and status_checks_required are placeholder
101
-	//    are placeholder columns wired by S20's migration; their owning
108
+	//    columns wired by S20's migration; their owning sprints flip them on.
102
-	//    sprints flip them on. No-op here.
109
+	//    No-op here.
103
 
110
 
104
 	return Decision{Allow: true, Reason: "passed all rules", RuleID: rule.ID, Pattern: rule.Pattern}, nil
111
 	return Decision{Allow: true, Reason: "passed all rules", RuleID: rule.ID, Pattern: rule.Pattern}, nil
105
 }
112
 }
internal/repos/webedit/webedit.goadded
@@ -0,0 +1,608 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+// Package webedit owns repository file mutations initiated from the web UI.
4
+// It uses the same canonical git objects and post-push worker pipeline as
5
+// smart HTTP/SSH pushes, with an update-ref CAS at the end of each commit.
6
+package webedit
7
+
8
+import (
9
+	"bytes"
10
+	"context"
11
+	"errors"
12
+	"fmt"
13
+	"log/slog"
14
+	"os"
15
+	"os/exec"
16
+	"strings"
17
+	"time"
18
+
19
+	"github.com/jackc/pgx/v5/pgtype"
20
+	"github.com/jackc/pgx/v5/pgxpool"
21
+
22
+	"github.com/tenseleyFlow/shithub/internal/repos/protection"
23
+	reposdb "github.com/tenseleyFlow/shithub/internal/repos/sqlc"
24
+	usersdb "github.com/tenseleyFlow/shithub/internal/users/sqlc"
25
+	"github.com/tenseleyFlow/shithub/internal/worker"
26
+	workerdb "github.com/tenseleyFlow/shithub/internal/worker/sqlc"
27
+)
28
+
29
+const (
30
+	// MaxTextBytes matches the blob viewer's text threshold.
31
+	MaxTextBytes = 1 * 1024 * 1024
32
+	// MaxUploadFileBytes mirrors GitHub's small web upload envelope closely
33
+	// enough for v1 while preventing accidental large object buffering.
34
+	MaxUploadFileBytes = 10 * 1024 * 1024
35
+	MaxUploadBytes     = 25 * 1024 * 1024
36
+)
37
+
38
+var (
39
+	ErrInvalidOperation = errors.New("webedit: invalid operation")
40
+	ErrInvalidPath      = errors.New("webedit: invalid path")
41
+	ErrInvalidBranch    = errors.New("webedit: invalid branch")
42
+	ErrNoVerifiedEmail  = errors.New("webedit: no verified primary email")
43
+	ErrPathExists       = errors.New("webedit: path exists")
44
+	ErrPathNotFound     = errors.New("webedit: path not found")
45
+	ErrUnsupportedEntry = errors.New("webedit: unsupported tree entry")
46
+	ErrBinary           = errors.New("webedit: binary content")
47
+	ErrBlobTooLarge     = errors.New("webedit: blob too large")
48
+	ErrConflict         = errors.New("webedit: branch moved")
49
+	ErrProtected        = errors.New("webedit: protected branch")
50
+)
51
+
52
+// Op identifies the mutation the web editor is applying.
53
+type Op string
54
+
55
+const (
56
+	OpEdit   Op = "edit"
57
+	OpCreate Op = "create"
58
+	OpRename Op = "rename"
59
+	OpDelete Op = "delete"
60
+	OpUpload Op = "upload"
61
+)
62
+
63
+// Deps wires the service from handlers.
64
+type Deps struct {
65
+	Pool   *pgxpool.Pool
66
+	Logger *slog.Logger
67
+	Now    func() time.Time
68
+}
69
+
70
+// File is one uploaded file staged into the commit.
71
+type File struct {
72
+	Path string
73
+	Body []byte
74
+}
75
+
76
+// Params describes one web file mutation.
77
+type Params struct {
78
+	GitDir      string
79
+	Repo        reposdb.Repo
80
+	Branch      string
81
+	BaseOID     string
82
+	ActorUserID int64
83
+	RequestID   string
84
+
85
+	Op         Op
86
+	SourcePath string
87
+	TargetPath string
88
+	Content    []byte
89
+	Files      []File
90
+
91
+	Message     string
92
+	Description string
93
+}
94
+
95
+// Result describes the committed ref update.
96
+type Result struct {
97
+	BeforeOID   string
98
+	AfterOID    string
99
+	CommitOID   string
100
+	Ref         string
101
+	PushEventID int64
102
+}
103
+
104
+// ValidateFilePath is the repository-file path guard shared by handlers and
105
+// service code. It accepts GitHub-style slash-separated relative paths, but
106
+// rejects traversal, control bytes, directory sentinels, and ambiguous forms.
107
+func ValidateFilePath(p string) error {
108
+	if p == "" || len(p) > 4096 {
109
+		return ErrInvalidPath
110
+	}
111
+	if strings.HasPrefix(p, "/") || strings.HasSuffix(p, "/") {
112
+		return ErrInvalidPath
113
+	}
114
+	if strings.Contains(p, "\\") || strings.Contains(p, "//") {
115
+		return ErrInvalidPath
116
+	}
117
+	for _, seg := range strings.Split(p, "/") {
118
+		if seg == "" || seg == "." || seg == ".." {
119
+			return ErrInvalidPath
120
+		}
121
+		for _, c := range seg {
122
+			if c < 0x20 || c == 0x7f {
123
+				return ErrInvalidPath
124
+			}
125
+		}
126
+	}
127
+	return nil
128
+}
129
+
130
+// ValidateDirPath accepts the empty root path and otherwise applies the same
131
+// segment checks as file paths while allowing no trailing slash.
132
+func ValidateDirPath(p string) error {
133
+	if p == "" {
134
+		return nil
135
+	}
136
+	return ValidateFilePath(p)
137
+}
138
+
139
+// IsBinary scans the first 8 KiB for a NUL byte.
140
+func IsBinary(b []byte) bool {
141
+	const window = 8192
142
+	if len(b) > window {
143
+		b = b[:window]
144
+	}
145
+	return bytes.IndexByte(b, 0) >= 0
146
+}
147
+
148
+// DefaultMessage mirrors GitHub's direct-commit defaults for each operation.
149
+func DefaultMessage(op Op, sourcePath, targetPath string, files []File) string {
150
+	switch op {
151
+	case OpCreate:
152
+		return "Create " + targetPath
153
+	case OpRename:
154
+		return "Rename " + sourcePath + " to " + targetPath
155
+	case OpDelete:
156
+		return "Delete " + sourcePath
157
+	case OpUpload:
158
+		if len(files) == 1 {
159
+			return "Upload " + files[0].Path
160
+		}
161
+		return "Upload files"
162
+	default:
163
+		return "Update " + sourcePath
164
+	}
165
+}
166
+
167
+// Commit builds one commit and atomically advances refs/heads/<branch>.
168
+func Commit(ctx context.Context, deps Deps, p Params) (Result, error) {
169
+	if deps.Pool == nil {
170
+		return Result{}, errors.New("webedit: Deps missing Pool")
171
+	}
172
+	if p.GitDir == "" || p.Repo.ID == 0 || p.ActorUserID == 0 {
173
+		return Result{}, errors.New("webedit: Params missing required field")
174
+	}
175
+	if !validBranchName(p.Branch) {
176
+		return Result{}, ErrInvalidBranch
177
+	}
178
+	if err := validateParams(p); err != nil {
179
+		return Result{}, err
180
+	}
181
+	now := deps.Now
182
+	if now == nil {
183
+		now = time.Now
184
+	}
185
+
186
+	authorName, authorEmail, err := resolveAuthor(ctx, deps.Pool, p.ActorUserID)
187
+	if err != nil {
188
+		return Result{}, err
189
+	}
190
+
191
+	ref := "refs/heads/" + p.Branch
192
+	before, err := gitOutput(ctx, p.GitDir, "", "rev-parse", "--verify", ref+"^{commit}")
193
+	if err != nil {
194
+		return Result{}, fmt.Errorf("%w: %s", ErrInvalidBranch, strings.TrimSpace(err.Error()))
195
+	}
196
+	before = strings.TrimSpace(before)
197
+	if p.BaseOID != "" && p.BaseOID != before {
198
+		return Result{}, ErrConflict
199
+	}
200
+
201
+	index, err := os.CreateTemp("", "shithub-webedit-index-*")
202
+	if err != nil {
203
+		return Result{}, fmt.Errorf("webedit: temp index: %w", err)
204
+	}
205
+	indexPath := index.Name()
206
+	_ = index.Close()
207
+	defer func() { _ = os.Remove(indexPath) }()
208
+
209
+	if _, err := gitOutput(ctx, p.GitDir, indexPath, "read-tree", before); err != nil {
210
+		return Result{}, fmt.Errorf("webedit: read-tree: %w", err)
211
+	}
212
+	if err := applyOperation(ctx, p.GitDir, indexPath, before, p); err != nil {
213
+		return Result{}, err
214
+	}
215
+
216
+	tree, err := gitOutput(ctx, p.GitDir, indexPath, "write-tree")
217
+	if err != nil {
218
+		return Result{}, fmt.Errorf("webedit: write-tree: %w", err)
219
+	}
220
+	tree = strings.TrimSpace(tree)
221
+	if tree == "" {
222
+		return Result{}, errors.New("webedit: write-tree returned empty oid")
223
+	}
224
+
225
+	message := strings.TrimSpace(p.Message)
226
+	if message == "" {
227
+		message = DefaultMessage(p.Op, p.SourcePath, p.TargetPath, p.Files)
228
+	}
229
+	if desc := strings.TrimSpace(p.Description); desc != "" {
230
+		message += "\n\n" + desc
231
+	}
232
+	commit, err := gitCommitTree(ctx, p.GitDir, tree, before, message, authorName, authorEmail, now())
233
+	if err != nil {
234
+		return Result{}, err
235
+	}
236
+	commit = strings.TrimSpace(commit)
237
+
238
+	decision, err := protection.Enforce(ctx, deps.Pool, p.GitDir, p.Repo.ID, protection.Update{
239
+		OldSHA: before,
240
+		NewSHA: commit,
241
+		Ref:    ref,
242
+		Pusher: p.ActorUserID,
243
+	})
244
+	if err != nil {
245
+		return Result{}, fmt.Errorf("webedit: branch protection: %w", err)
246
+	}
247
+	if !decision.Allow {
248
+		return Result{}, fmt.Errorf("%w: %s", ErrProtected, protection.FriendlyMessage(decision))
249
+	}
250
+
251
+	if _, err := gitOutput(ctx, p.GitDir, "", "update-ref", ref, commit, before); err != nil {
252
+		return Result{}, classifyUpdateRefError(err)
253
+	}
254
+
255
+	eventID, err := enqueuePushProcess(ctx, deps.Pool, p.Repo.ID, p.ActorUserID, before, commit, ref, p.RequestID)
256
+	if err != nil && deps.Logger != nil {
257
+		deps.Logger.WarnContext(ctx, "webedit: enqueue push process after commit", "repo_id", p.Repo.ID, "ref", ref, "commit", commit, "error", err)
258
+	}
259
+	return Result{BeforeOID: before, AfterOID: commit, CommitOID: commit, Ref: ref, PushEventID: eventID}, nil
260
+}
261
+
262
+func validateParams(p Params) error {
263
+	switch p.Op {
264
+	case OpEdit:
265
+		if err := ValidateFilePath(p.SourcePath); err != nil {
266
+			return err
267
+		}
268
+		if p.TargetPath == "" {
269
+			p.TargetPath = p.SourcePath
270
+		}
271
+		if err := ValidateFilePath(p.TargetPath); err != nil {
272
+			return err
273
+		}
274
+		if len(p.Content) > MaxTextBytes {
275
+			return ErrBlobTooLarge
276
+		}
277
+		if IsBinary(p.Content) {
278
+			return ErrBinary
279
+		}
280
+	case OpRename:
281
+		if err := ValidateFilePath(p.SourcePath); err != nil {
282
+			return err
283
+		}
284
+		if err := ValidateFilePath(p.TargetPath); err != nil {
285
+			return err
286
+		}
287
+		if p.SourcePath == p.TargetPath {
288
+			return ErrInvalidOperation
289
+		}
290
+		if len(p.Content) > MaxTextBytes {
291
+			return ErrBlobTooLarge
292
+		}
293
+		if IsBinary(p.Content) {
294
+			return ErrBinary
295
+		}
296
+	case OpCreate:
297
+		if err := ValidateFilePath(p.TargetPath); err != nil {
298
+			return err
299
+		}
300
+		if len(p.Content) > MaxTextBytes {
301
+			return ErrBlobTooLarge
302
+		}
303
+		if IsBinary(p.Content) {
304
+			return ErrBinary
305
+		}
306
+	case OpDelete:
307
+		if err := ValidateFilePath(p.SourcePath); err != nil {
308
+			return err
309
+		}
310
+	case OpUpload:
311
+		if len(p.Files) == 0 {
312
+			return ErrInvalidOperation
313
+		}
314
+		seen := map[string]struct{}{}
315
+		for _, f := range p.Files {
316
+			if err := ValidateFilePath(f.Path); err != nil {
317
+				return err
318
+			}
319
+			if _, ok := seen[f.Path]; ok {
320
+				return fmt.Errorf("%w: duplicate path %s", ErrInvalidPath, f.Path)
321
+			}
322
+			seen[f.Path] = struct{}{}
323
+			if len(f.Body) > MaxUploadFileBytes {
324
+				return ErrBlobTooLarge
325
+			}
326
+		}
327
+	default:
328
+		return ErrInvalidOperation
329
+	}
330
+	return nil
331
+}
332
+
333
+func applyOperation(ctx context.Context, gitDir, indexPath, before string, p Params) error {
334
+	switch p.Op {
335
+	case OpEdit, OpRename:
336
+		info, ok, err := objectAt(ctx, gitDir, before, p.SourcePath)
337
+		if err != nil {
338
+			return err
339
+		}
340
+		if !ok {
341
+			return ErrPathNotFound
342
+		}
343
+		if info.typ != "blob" || info.mode == "120000" || info.mode == "160000" {
344
+			return ErrUnsupportedEntry
345
+		}
346
+		target := p.TargetPath
347
+		if target == "" {
348
+			target = p.SourcePath
349
+		}
350
+		if target != p.SourcePath {
351
+			if err := ensureParentsAreTrees(ctx, gitDir, before, target); err != nil {
352
+				return err
353
+			}
354
+			if _, ok, err := objectAt(ctx, gitDir, before, target); err != nil {
355
+				return err
356
+			} else if ok {
357
+				return ErrPathExists
358
+			}
359
+			if _, err := gitOutput(ctx, gitDir, indexPath, "update-index", "--force-remove", "--", p.SourcePath); err != nil {
360
+				return fmt.Errorf("webedit: remove source: %w", err)
361
+			}
362
+		}
363
+		return addContent(ctx, gitDir, indexPath, info.mode, target, p.Content)
364
+	case OpCreate:
365
+		if _, ok, err := objectAt(ctx, gitDir, before, p.TargetPath); err != nil {
366
+			return err
367
+		} else if ok {
368
+			return ErrPathExists
369
+		}
370
+		if err := ensureParentsAreTrees(ctx, gitDir, before, p.TargetPath); err != nil {
371
+			return err
372
+		}
373
+		return addContent(ctx, gitDir, indexPath, "100644", p.TargetPath, p.Content)
374
+	case OpDelete:
375
+		info, ok, err := objectAt(ctx, gitDir, before, p.SourcePath)
376
+		if err != nil {
377
+			return err
378
+		}
379
+		if !ok {
380
+			return ErrPathNotFound
381
+		}
382
+		if info.typ != "blob" || info.mode == "120000" || info.mode == "160000" {
383
+			return ErrUnsupportedEntry
384
+		}
385
+		if _, err := gitOutput(ctx, gitDir, indexPath, "update-index", "--force-remove", "--", p.SourcePath); err != nil {
386
+			return fmt.Errorf("webedit: remove source: %w", err)
387
+		}
388
+	case OpUpload:
389
+		for _, f := range p.Files {
390
+			if _, ok, err := objectAt(ctx, gitDir, before, f.Path); err != nil {
391
+				return err
392
+			} else if ok {
393
+				return ErrPathExists
394
+			}
395
+			if err := ensureParentsAreTrees(ctx, gitDir, before, f.Path); err != nil {
396
+				return err
397
+			}
398
+			if err := addContent(ctx, gitDir, indexPath, "100644", f.Path, f.Body); err != nil {
399
+				return err
400
+			}
401
+		}
402
+	}
403
+	return nil
404
+}
405
+
406
+func addContent(ctx context.Context, gitDir, indexPath, mode, filePath string, body []byte) error {
407
+	oid, err := gitHashObject(ctx, gitDir, body)
408
+	if err != nil {
409
+		return err
410
+	}
411
+	spec := mode + "," + oid + "," + filePath
412
+	if _, err := gitOutput(ctx, gitDir, indexPath, "update-index", "--add", "--cacheinfo", spec); err != nil {
413
+		return fmt.Errorf("webedit: update-index add %s: %w", filePath, err)
414
+	}
415
+	return nil
416
+}
417
+
418
+func ensureParentsAreTrees(ctx context.Context, gitDir, rev, filePath string) error {
419
+	parent := parentPath(filePath)
420
+	for parent != "" {
421
+		info, ok, err := objectAt(ctx, gitDir, rev, parent)
422
+		if err != nil {
423
+			return err
424
+		}
425
+		if ok && info.typ != "tree" {
426
+			return ErrPathExists
427
+		}
428
+		parent = parentPath(parent)
429
+	}
430
+	return nil
431
+}
432
+
433
+type objectInfo struct {
434
+	mode string
435
+	typ  string
436
+	oid  string
437
+}
438
+
439
+func objectAt(ctx context.Context, gitDir, rev, filePath string) (objectInfo, bool, error) {
440
+	out, err := gitOutput(ctx, gitDir, "", "ls-tree", "-z", rev, "--", filePath)
441
+	if err != nil {
442
+		return objectInfo{}, false, fmt.Errorf("webedit: ls-tree %s: %w", filePath, err)
443
+	}
444
+	out = strings.TrimSuffix(out, "\x00")
445
+	if out == "" {
446
+		return objectInfo{}, false, nil
447
+	}
448
+	meta, _, ok := strings.Cut(out, "\t")
449
+	if !ok {
450
+		return objectInfo{}, false, fmt.Errorf("webedit: bad ls-tree output for %s", filePath)
451
+	}
452
+	parts := strings.Split(meta, " ")
453
+	if len(parts) != 3 {
454
+		return objectInfo{}, false, fmt.Errorf("webedit: bad ls-tree metadata for %s", filePath)
455
+	}
456
+	return objectInfo{mode: parts[0], typ: parts[1], oid: parts[2]}, true, nil
457
+}
458
+
459
+func gitHashObject(ctx context.Context, gitDir string, body []byte) (string, error) {
460
+	cmd := exec.CommandContext(ctx, "git", "-C", gitDir, "hash-object", "-w", "--stdin")
461
+	cmd.Stdin = bytes.NewReader(body)
462
+	out, err := cmd.CombinedOutput()
463
+	if err != nil {
464
+		return "", fmt.Errorf("webedit: hash-object: %w: %s", err, strings.TrimSpace(string(out)))
465
+	}
466
+	return strings.TrimSpace(string(out)), nil
467
+}
468
+
469
+func gitCommitTree(ctx context.Context, gitDir, tree, parent, message, name, email string, when time.Time) (string, error) {
470
+	cmd := exec.CommandContext(ctx, "git", "-C", gitDir, "commit-tree", tree, "-p", parent, "-m", message)
471
+	date := when.Format(time.RFC3339)
472
+	cmd.Env = append(os.Environ(),
473
+		"GIT_AUTHOR_NAME="+name,
474
+		"GIT_AUTHOR_EMAIL="+email,
475
+		"GIT_AUTHOR_DATE="+date,
476
+		"GIT_COMMITTER_NAME="+name,
477
+		"GIT_COMMITTER_EMAIL="+email,
478
+		"GIT_COMMITTER_DATE="+date,
479
+	)
480
+	out, err := cmd.CombinedOutput()
481
+	if err != nil {
482
+		return "", fmt.Errorf("webedit: commit-tree: %w: %s", err, strings.TrimSpace(string(out)))
483
+	}
484
+	return string(out), nil
485
+}
486
+
487
+func gitOutput(ctx context.Context, gitDir, indexPath string, args ...string) (string, error) {
488
+	cmdArgs := append([]string{"-C", gitDir}, args...)
489
+	cmd := exec.CommandContext(ctx, "git", cmdArgs...)
490
+	if indexPath != "" {
491
+		cmd.Env = append(os.Environ(), "GIT_INDEX_FILE="+indexPath)
492
+	}
493
+	out, err := cmd.CombinedOutput()
494
+	if err != nil {
495
+		return "", fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
496
+	}
497
+	return string(out), nil
498
+}
499
+
500
+func classifyUpdateRefError(err error) error {
501
+	msg := err.Error()
502
+	if strings.Contains(msg, "cannot lock ref") || strings.Contains(msg, "is at") || strings.Contains(msg, "but expected") {
503
+		return fmt.Errorf("%w: %v", ErrConflict, err)
504
+	}
505
+	return fmt.Errorf("webedit: update-ref: %w", err)
506
+}
507
+
508
+func enqueuePushProcess(ctx context.Context, pool *pgxpool.Pool, repoID, actorUserID int64, before, after, ref, requestID string) (int64, error) {
509
+	tx, err := pool.Begin(ctx)
510
+	if err != nil {
511
+		return 0, fmt.Errorf("webedit: begin push event tx: %w", err)
512
+	}
513
+	committed := false
514
+	defer func() {
515
+		if !committed {
516
+			_ = tx.Rollback(ctx)
517
+		}
518
+	}()
519
+
520
+	event, err := workerdb.New().InsertPushEvent(ctx, tx, workerdb.InsertPushEventParams{
521
+		RepoID:       repoID,
522
+		BeforeSha:    before,
523
+		AfterSha:     after,
524
+		Ref:          ref,
525
+		Protocol:     "web",
526
+		PusherUserID: pgtype.Int8{Int64: actorUserID, Valid: actorUserID != 0},
527
+		RequestID:    pgtype.Text{String: requestID, Valid: requestID != ""},
528
+	})
529
+	if err != nil {
530
+		return 0, fmt.Errorf("webedit: insert push event: %w", err)
531
+	}
532
+	if _, err := worker.Enqueue(ctx, tx, worker.KindPushProcess, struct {
533
+		PushEventID int64 `json:"push_event_id"`
534
+	}{PushEventID: event.ID}, worker.EnqueueOptions{}); err != nil {
535
+		return 0, err
536
+	}
537
+	if err := worker.Notify(ctx, tx); err != nil {
538
+		// Workers also poll. Keep the commit path live if NOTIFY fails.
539
+	}
540
+	if err := tx.Commit(ctx); err != nil {
541
+		return 0, fmt.Errorf("webedit: commit push event tx: %w", err)
542
+	}
543
+	committed = true
544
+	return event.ID, nil
545
+}
546
+
547
+func resolveAuthor(ctx context.Context, pool *pgxpool.Pool, userID int64) (name, addr string, err error) {
548
+	uq := usersdb.New()
549
+	user, err := uq.GetUserByID(ctx, pool, userID)
550
+	if err != nil {
551
+		return "", "", fmt.Errorf("webedit: load user: %w", err)
552
+	}
553
+	if !user.PrimaryEmailID.Valid {
554
+		return "", "", ErrNoVerifiedEmail
555
+	}
556
+	em, err := uq.GetUserEmailByID(ctx, pool, user.PrimaryEmailID.Int64)
557
+	if err != nil {
558
+		return "", "", fmt.Errorf("webedit: load primary email: %w", err)
559
+	}
560
+	if !em.Verified {
561
+		return "", "", ErrNoVerifiedEmail
562
+	}
563
+	display := strings.TrimSpace(user.DisplayName)
564
+	if display == "" {
565
+		display = user.Username
566
+	}
567
+	return display, string(em.Email), nil
568
+}
569
+
570
+func validBranchName(branch string) bool {
571
+	if branch == "" || len(branch) == 40 && isHex(branch) {
572
+		return false
573
+	}
574
+	if strings.HasPrefix(branch, "-") || strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") {
575
+		return false
576
+	}
577
+	if strings.Contains(branch, "\\") || strings.Contains(branch, "..") || strings.Contains(branch, "//") {
578
+		return false
579
+	}
580
+	if strings.HasSuffix(branch, ".lock") || strings.Contains(branch, "@{") {
581
+		return false
582
+	}
583
+	for _, c := range branch {
584
+		if c < 0x20 || c == 0x7f || strings.ContainsRune(" ~^:?*[", c) {
585
+			return false
586
+		}
587
+	}
588
+	return true
589
+}
590
+
591
+func isHex(s string) bool {
592
+	for _, c := range s {
593
+		switch {
594
+		case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
595
+		default:
596
+			return false
597
+		}
598
+	}
599
+	return true
600
+}
601
+
602
+func parentPath(p string) string {
603
+	idx := strings.LastIndex(p, "/")
604
+	if idx < 0 {
605
+		return ""
606
+	}
607
+	return p[:idx]
608
+}
internal/repos/webedit/webedit_test.goadded
@@ -0,0 +1,86 @@
1
+// SPDX-License-Identifier: AGPL-3.0-or-later
2
+
3
+package webedit
4
+
5
+import (
6
+	"errors"
7
+	"testing"
8
+)
9
+
10
+func TestValidateFilePath(t *testing.T) {
11
+	t.Parallel()
12
+	valid := []string{
13
+		"README.md",
14
+		"docs/CONTRIBUTING.md",
15
+		".github/workflows/ci.yml",
16
+		"space name/file.txt",
17
+	}
18
+	for _, p := range valid {
19
+		if err := ValidateFilePath(p); err != nil {
20
+			t.Errorf("ValidateFilePath(%q) = %v, want nil", p, err)
21
+		}
22
+	}
23
+
24
+	invalid := []string{
25
+		"",
26
+		"/README.md",
27
+		"docs/",
28
+		"docs//README.md",
29
+		"../README.md",
30
+		"docs/../README.md",
31
+		"docs/./README.md",
32
+		`docs\README.md`,
33
+		"bad\x00name",
34
+	}
35
+	for _, p := range invalid {
36
+		if err := ValidateFilePath(p); !errors.Is(err, ErrInvalidPath) {
37
+			t.Errorf("ValidateFilePath(%q) = %v, want ErrInvalidPath", p, err)
38
+		}
39
+	}
40
+}
41
+
42
+func TestDefaultMessage(t *testing.T) {
43
+	t.Parallel()
44
+	cases := []struct {
45
+		op     Op
46
+		source string
47
+		target string
48
+		files  []File
49
+		want   string
50
+	}{
51
+		{op: OpEdit, source: "README.md", want: "Update README.md"},
52
+		{op: OpCreate, target: "docs/usage.md", want: "Create docs/usage.md"},
53
+		{op: OpRename, source: "old.md", target: "new.md", want: "Rename old.md to new.md"},
54
+		{op: OpDelete, source: "SECURITY.md", want: "Delete SECURITY.md"},
55
+		{op: OpUpload, files: []File{{Path: "asset.png"}}, want: "Upload asset.png"},
56
+		{op: OpUpload, files: []File{{Path: "a"}, {Path: "b"}}, want: "Upload files"},
57
+	}
58
+	for _, c := range cases {
59
+		if got := DefaultMessage(c.op, c.source, c.target, c.files); got != c.want {
60
+			t.Errorf("DefaultMessage(%q) = %q, want %q", c.op, got, c.want)
61
+		}
62
+	}
63
+}
64
+
65
+func TestIsBinary(t *testing.T) {
66
+	t.Parallel()
67
+	if IsBinary([]byte("plain text\n")) {
68
+		t.Fatal("text detected as binary")
69
+	}
70
+	if !IsBinary([]byte{'h', 'i', 0, 'x'}) {
71
+		t.Fatal("NUL-containing content was not detected as binary")
72
+	}
73
+}
74
+
75
+func TestValidBranchNameRejectsDetachedInputs(t *testing.T) {
76
+	t.Parallel()
77
+	if !validBranchName("feature/editor-ui") {
78
+		t.Fatal("branch with slash rejected")
79
+	}
80
+	if validBranchName("0123456789abcdef0123456789abcdef01234567") {
81
+		t.Fatal("40-hex detached oid accepted as branch")
82
+	}
83
+	if validBranchName("release.lock") {
84
+		t.Fatal(".lock branch accepted")
85
+	}
86
+}