Go · 17321 bytes Raw Blame History
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 _ = worker.Notify(ctx, tx) // Workers also poll; keep the commit path live if NOTIFY fails.
538 if err := tx.Commit(ctx); err != nil {
539 return 0, fmt.Errorf("webedit: commit push event tx: %w", err)
540 }
541 committed = true
542 return event.ID, nil
543 }
544
545 func resolveAuthor(ctx context.Context, pool *pgxpool.Pool, userID int64) (name, addr string, err error) {
546 uq := usersdb.New()
547 user, err := uq.GetUserByID(ctx, pool, userID)
548 if err != nil {
549 return "", "", fmt.Errorf("webedit: load user: %w", err)
550 }
551 if !user.PrimaryEmailID.Valid {
552 return "", "", ErrNoVerifiedEmail
553 }
554 em, err := uq.GetUserEmailByID(ctx, pool, user.PrimaryEmailID.Int64)
555 if err != nil {
556 return "", "", fmt.Errorf("webedit: load primary email: %w", err)
557 }
558 if !em.Verified {
559 return "", "", ErrNoVerifiedEmail
560 }
561 display := strings.TrimSpace(user.DisplayName)
562 if display == "" {
563 display = user.Username
564 }
565 return display, string(em.Email), nil
566 }
567
568 func validBranchName(branch string) bool {
569 if branch == "" || len(branch) == 40 && isHex(branch) {
570 return false
571 }
572 if strings.HasPrefix(branch, "-") || strings.HasPrefix(branch, "/") || strings.HasSuffix(branch, "/") {
573 return false
574 }
575 if strings.Contains(branch, "\\") || strings.Contains(branch, "..") || strings.Contains(branch, "//") {
576 return false
577 }
578 if strings.HasSuffix(branch, ".lock") || strings.Contains(branch, "@{") {
579 return false
580 }
581 for _, c := range branch {
582 if c < 0x20 || c == 0x7f || strings.ContainsRune(" ~^:?*[", c) {
583 return false
584 }
585 }
586 return true
587 }
588
589 func isHex(s string) bool {
590 for _, c := range s {
591 switch {
592 case c >= '0' && c <= '9', c >= 'a' && c <= 'f', c >= 'A' && c <= 'F':
593 default:
594 return false
595 }
596 }
597 return true
598 }
599
600 func parentPath(p string) string {
601 idx := strings.LastIndex(p, "/")
602 if idx < 0 {
603 return ""
604 }
605 return p[:idx]
606 }
607