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
7070
 		return deny(rule, "deletion of this branch is blocked by protection rule"), nil
7171
 	}
7272
 
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
7481
 	// existing branch (both sides non-zero). Skipping allows the create
7582
 	// case (oldSHA all-zero) and the delete case (handled above).
7683
 	if !isCreate && !isDelete && rule.PreventForcePush {
@@ -83,7 +90,7 @@ func Enforce(ctx context.Context, pool *pgxpool.Pool, gitDir string, repoID int6
8390
 		}
8491
 	}
8592
 
86
-	// 3. Allowed-pushers gate.
93
+	// 4. Allowed-pushers gate.
8794
 	if len(rule.AllowedPusherUserIds) > 0 {
8895
 		ok := false
8996
 		for _, id := range rule.AllowedPusherUserIds {
@@ -97,9 +104,9 @@ func Enforce(ctx context.Context, pool *pgxpool.Pool, gitDir string, repoID int6
97104
 		}
98105
 	}
99106
 
100
-	// 4. require_signed_commits, require_pr_for_push, status_checks_required
101
-	//    are placeholder columns wired by S20's migration; their owning
102
-	//    sprints flip them on. No-op here.
107
+	// 5. require_signed_commits and status_checks_required are placeholder
108
+	//    columns wired by S20's migration; their owning sprints flip them on.
109
+	//    No-op here.
103110
 
104111
 	return Decision{Allow: true, Reason: "passed all rules", RuleID: rule.ID, Pattern: rule.Pattern}, nil
105112
 }
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
+}