From 03c06c0dd928c311a1ede74e9707450dbbbd0a47 Mon Sep 17 00:00:00 2001 From: Tim Bendt Date: Mon, 27 Jul 2026 11:52:13 -0400 Subject: [PATCH] feature: move docs --- .project/STATUS.md | 1 + apps/server/internal/app/app.go | 16 +-- apps/server/internal/collaboration/service.go | 37 ++++-- .../000018_document_aliases.down.sql | 2 + .../migrations/000018_document_aliases.up.sql | 8 ++ apps/server/internal/docs/repository.go | 83 ++++++++++++ apps/server/internal/docs/service.go | 119 ++++++++++++++++- apps/server/internal/docs/service_test.go | 121 ++++++++++++++++++ .../internal/httpserver/api_handlers_test.go | 84 ++++++++++++ apps/server/internal/httpserver/handlers.go | 104 ++++++++++++++- apps/server/internal/httpserver/middleware.go | 2 +- .../httpserver/permissions_handlers.go | 2 +- apps/server/internal/httpserver/server.go | 1 + .../server/internal/httpserver/static/move.js | 42 ++++++ .../httpserver/static/notification-bell.js | 3 +- .../internal/httpserver/static/realtime.js | 6 + .../internal/httpserver/sync_handlers.go | 4 +- .../internal/httpserver/templates/base.gohtml | 1 + .../httpserver/templates/document.gohtml | 4 + .../httpserver/templates/document_edit.gohtml | 2 +- 20 files changed, 607 insertions(+), 35 deletions(-) create mode 100644 apps/server/internal/database/migrations/000018_document_aliases.down.sql create mode 100644 apps/server/internal/database/migrations/000018_document_aliases.up.sql create mode 100644 apps/server/internal/httpserver/static/move.js diff --git a/.project/STATUS.md b/.project/STATUS.md index c8de286..226da73 100644 --- a/.project/STATUS.md +++ b/.project/STATUS.md @@ -41,6 +41,7 @@ Recent work has moved the project beyond the previous 2026-06-24 report: - Existing users can add another passkey from their account page. - Resource permission UI and private-by-default filtering cover documents, folders, navigation, search, API document lists, and native sync snapshots. - CodeMirror editor, wiki-link autocomplete, keyboard navigation, mobile drawers, archive UI, and tag pages are present. +- Documents can be moved from the web UI without losing IDs, versions, comments, watches, attachments, or explicit permissions; old paths redirect to the current location. - The macOS menu-bar app and CLI compile successfully. ## Milestone Progress diff --git a/apps/server/internal/app/app.go b/apps/server/internal/app/app.go index 02f99c6..da21239 100644 --- a/apps/server/internal/app/app.go +++ b/apps/server/internal/app/app.go @@ -7,7 +7,6 @@ import ( "log/slog" "net/http" "os" - "strings" "time" "github.com/tim/cairnquire/apps/server/internal/auth" @@ -120,12 +119,19 @@ func New(ctx context.Context, cfg config.Config, logger *slog.Logger) (*App, err } return auth.PermissionAllows(permission, auth.PermissionRead), nil }, + ResolveDocument: func(ctx context.Context, documentID string) (string, string, error) { + document, err := repo.GetDocumentByID(ctx, documentID) + if err != nil { + return "", "", err + } + return document.Path, document.Title, nil + }, PublicOrigin: cfg.Auth.PublicOrigin, }) service.OnChange(func(change docs.DocumentChange) { hub.Broadcast(realtime.Event{Type: "document_version", Data: change}) - if err := collabService.NotifyDocumentChanged(ctx, formatDocumentID(change.Path), change.Path, "system"); err != nil { + if err := collabService.NotifyDocumentChanged(ctx, change.DocumentID, change.Path, "system"); err != nil { logger.Warn("notify document changed", "error", err) } }) @@ -225,9 +231,3 @@ func (a *App) syncPoll(ctx context.Context) { func (a *App) Close() error { return a.db.Close() } - -func formatDocumentID(path string) string { - clean := strings.TrimPrefix(path, "/") - clean = strings.TrimSuffix(clean, ".md") - return "doc:" + clean -} diff --git a/apps/server/internal/collaboration/service.go b/apps/server/internal/collaboration/service.go index bb104f0..7ca3f31 100644 --- a/apps/server/internal/collaboration/service.go +++ b/apps/server/internal/collaboration/service.go @@ -53,6 +53,7 @@ type UserLookup interface { } type DocumentReadAuthorizer func(ctx context.Context, userID, documentID string) (bool, error) +type DocumentResolver func(ctx context.Context, documentID string) (path, title string, err error) type NoOpEmailSender struct{} @@ -68,6 +69,7 @@ type Service struct { renderer EmailRenderer users UserLookup canReadDocument DocumentReadAuthorizer + resolveDocument DocumentResolver hub *realtime.Hub logger *slog.Logger publicOrigin string @@ -79,6 +81,7 @@ type Options struct { EmailRenderer EmailRenderer UserLookup UserLookup CanReadDocument DocumentReadAuthorizer + ResolveDocument DocumentResolver PublicOrigin string } @@ -101,6 +104,7 @@ func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Log renderer: opts.EmailRenderer, users: opts.UserLookup, canReadDocument: opts.CanReadDocument, + resolveDocument: opts.ResolveDocument, hub: hub, logger: logger, publicOrigin: opts.PublicOrigin, @@ -345,7 +349,7 @@ func (s *Service) NotifyDocumentChanged(ctx context.Context, documentID string, s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "file_changed", EmailData{ ActorName: actorName, DocumentPath: documentPath, - ViewURL: s.documentURL(documentPath), + ViewURL: s.documentIDURL(documentID), }) } @@ -361,6 +365,18 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) return err } + documentPath := documentPathFromID(comment.DocumentID) + ".md" + documentTitle := "" + if s.resolveDocument != nil { + resolvedPath, resolvedTitle, err := s.resolveDocument(ctx, comment.DocumentID) + if err != nil { + s.logger.Warn("resolve comment document", "document_id", comment.DocumentID, "error", err) + } else { + documentPath = resolvedPath + documentTitle = resolvedTitle + } + } + seen := make(map[string]bool) for _, w := range watchers { if w.UserID == comment.AuthorID { @@ -391,10 +407,11 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment) continue } s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{ - ActorName: comment.AuthorName, - DocumentPath: documentPathFromID(comment.DocumentID) + ".md", - CommentBody: comment.Content, - ViewURL: s.documentURL(comment.DocumentID), + ActorName: comment.AuthorName, + DocumentPath: documentPath, + DocumentTitle: documentTitle, + CommentBody: comment.Content, + ViewURL: s.documentIDURL(comment.DocumentID), }) } return nil @@ -428,17 +445,11 @@ func (s *Service) enqueueNotificationEmail(ctx context.Context, notificationID, } } -func (s *Service) documentURL(documentPath string) string { +func (s *Service) documentIDURL(documentID string) string { if s.publicOrigin == "" { return "" } - clean := strings.TrimPrefix(documentPath, "doc:") - clean = strings.TrimSuffix(strings.TrimPrefix(clean, "/"), ".md") - parts := strings.Split(clean, "/") - for i, part := range parts { - parts[i] = url.PathEscape(part) - } - return strings.TrimRight(s.publicOrigin, "/") + "/docs/" + strings.Join(parts, "/") + return strings.TrimRight(s.publicOrigin, "/") + "/documents/" + url.PathEscape(documentID) } func (s *Service) watcherCanRead(ctx context.Context, userID, documentID string) bool { diff --git a/apps/server/internal/database/migrations/000018_document_aliases.down.sql b/apps/server/internal/database/migrations/000018_document_aliases.down.sql new file mode 100644 index 0000000..8521a85 --- /dev/null +++ b/apps/server/internal/database/migrations/000018_document_aliases.down.sql @@ -0,0 +1,2 @@ +DROP INDEX IF EXISTS idx_document_aliases_document; +DROP TABLE IF EXISTS document_aliases; diff --git a/apps/server/internal/database/migrations/000018_document_aliases.up.sql b/apps/server/internal/database/migrations/000018_document_aliases.up.sql new file mode 100644 index 0000000..23e559e --- /dev/null +++ b/apps/server/internal/database/migrations/000018_document_aliases.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS document_aliases ( + path TEXT PRIMARY KEY, + document_id TEXT NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + created_at TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_document_aliases_document +ON document_aliases(document_id); diff --git a/apps/server/internal/docs/repository.go b/apps/server/internal/docs/repository.go index f1257ab..bcf0c13 100644 --- a/apps/server/internal/docs/repository.go +++ b/apps/server/internal/docs/repository.go @@ -51,6 +51,14 @@ type PersistDocumentInput struct { Tags []string } +type MoveDocumentInput struct { + ID string + OldPath string + NewPath string + ExpectedHash string + Title string +} + func NewRepository(db *sql.DB) *Repository { return &Repository{db: db} } @@ -109,6 +117,81 @@ func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, pat return r.getDocumentByPath(ctx, path, true) } +func (r *Repository) GetDocumentByPathOrAlias(ctx context.Context, path string) (*DocumentRecord, error) { + record, err := r.GetDocumentByPath(ctx, path) + if err == nil || !errors.Is(err, sql.ErrNoRows) { + return record, err + } + + var documentID string + if err := r.db.QueryRowContext(ctx, ` + SELECT document_id FROM document_aliases WHERE path = ? + `, path).Scan(&documentID); err != nil { + return nil, err + } + return r.GetDocumentByID(ctx, documentID) +} + +func (r *Repository) MoveDocument(ctx context.Context, input MoveDocumentInput) error { + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin document move: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var aliasDocumentID string + err = tx.QueryRowContext(ctx, `SELECT document_id FROM document_aliases WHERE path = ?`, input.NewPath).Scan(&aliasDocumentID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("check destination alias: %w", err) + } + if err == nil && aliasDocumentID != input.ID { + return fmt.Errorf("destination path is reserved by another document") + } + if _, err := tx.ExecContext(ctx, `DELETE FROM document_aliases WHERE path = ? AND document_id = ?`, input.NewPath, input.ID); err != nil { + return fmt.Errorf("remove destination alias: %w", err) + } + + result, err := tx.ExecContext(ctx, ` + UPDATE documents + SET path = ?, title = ?, updated_at = ? + WHERE id = ? AND path = ? AND current_hash = ? AND archived_at IS NULL + `, input.NewPath, input.Title, time.Now().UTC().Format(time.RFC3339), input.ID, input.OldPath, input.ExpectedHash) + if err != nil { + return fmt.Errorf("update document path: %w", err) + } + changed, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("document move rows affected: %w", err) + } + if changed != 1 { + return sql.ErrNoRows + } + + if _, err := tx.ExecContext(ctx, ` + UPDATE attachments SET document_path = ? WHERE document_path = ? + `, input.NewPath, input.OldPath); err != nil { + return fmt.Errorf("move attachment metadata: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + UPDATE permissions SET resource_id = ? + WHERE resource_type = 'document' AND resource_id = ? + `, input.NewPath, input.OldPath); err != nil { + return fmt.Errorf("move document permissions: %w", err) + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO document_aliases (path, document_id, created_at) + VALUES (?, ?, ?) + ON CONFLICT(path) DO UPDATE SET document_id = excluded.document_id, created_at = excluded.created_at + `, input.OldPath, input.ID, time.Now().UTC().Format(time.RFC3339)); err != nil { + return fmt.Errorf("create document alias: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit document move: %w", err) + } + return nil +} + func (r *Repository) GetDocumentByID(ctx context.Context, id string) (*DocumentRecord, error) { var ( record DocumentRecord diff --git a/apps/server/internal/docs/service.go b/apps/server/internal/docs/service.go index 79a1a05..a0ece48 100644 --- a/apps/server/internal/docs/service.go +++ b/apps/server/internal/docs/service.go @@ -27,12 +27,25 @@ type Service struct { } type DocumentChange struct { + Type string `json:"type,omitempty"` + DocumentID string `json:"documentId"` Path string `json:"path"` + OldPath string `json:"oldPath,omitempty"` Title string `json:"title"` Hash string `json:"hash"` PreviousHash string `json:"previousHash,omitempty"` } +type DocumentMoveConflictError struct { + OldPath string + NewPath string + Reason string +} + +func (e *DocumentMoveConflictError) Error() string { + return fmt.Sprintf("cannot move %s to %s: %s", e.OldPath, e.NewPath, e.Reason) +} + type Page struct { Path string Title string @@ -146,9 +159,10 @@ func (s *Service) ArchiveDocument(ctx context.Context, path string) error { if s.onChange != nil { s.onChange(DocumentChange{ - Path: record.Path, - Title: record.Title, - Hash: record.CurrentHash, + DocumentID: record.ID, + Path: record.Path, + Title: record.Title, + Hash: record.CurrentHash, }) } @@ -170,6 +184,96 @@ func (s *Service) RestoreDocument(ctx context.Context, path string) error { return nil } +func (s *Service) MoveDocument(ctx context.Context, requestPath, destinationPath, expectedHash string) (*DocumentRecord, error) { + s.mu.Lock() + defer s.mu.Unlock() + + if _, err := s.syncSourceDirLocked(ctx); err != nil { + return nil, err + } + oldPath, err := NormalizeDocumentPath(requestPath) + if err != nil { + return nil, err + } + newPath, err := NormalizeDocumentPath(destinationPath) + if err != nil { + return nil, err + } + if oldPath == newPath { + return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is unchanged"} + } + + record, err := s.repo.GetDocumentByPath(ctx, oldPath) + if err != nil { + return nil, err + } + if expectedHash != "" && record.CurrentHash != expectedHash { + return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "document changed since the page loaded"} + } + if _, err := s.repo.GetDocumentByPathIncludingArchived(ctx, newPath); err == nil { + return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination already exists"} + } else if !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + if aliased, err := s.repo.GetDocumentByPathOrAlias(ctx, newPath); err == nil && aliased.ID != record.ID { + return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination is reserved by another document"} + } else if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + + oldFullPath := filepath.Join(s.sourceDir, filepath.FromSlash(oldPath)) + newFullPath := filepath.Join(s.sourceDir, filepath.FromSlash(newPath)) + if _, err := os.Stat(newFullPath); err == nil { + return nil, &DocumentMoveConflictError{OldPath: oldPath, NewPath: newPath, Reason: "destination file already exists"} + } else if !errors.Is(err, os.ErrNotExist) { + return nil, fmt.Errorf("check destination file: %w", err) + } + content, err := os.ReadFile(oldFullPath) + if err != nil { + return nil, fmt.Errorf("read source document: %w", err) + } + rendered, err := s.renderer.RenderPath(content, newPath) + if err != nil { + return nil, fmt.Errorf("render moved document: %w", err) + } + if err := os.MkdirAll(filepath.Dir(newFullPath), 0o755); err != nil { + return nil, fmt.Errorf("create destination folder: %w", err) + } + if err := os.Rename(oldFullPath, newFullPath); err != nil { + return nil, fmt.Errorf("move document file: %w", err) + } + + moveErr := s.repo.MoveDocument(ctx, MoveDocumentInput{ + ID: record.ID, + OldPath: oldPath, + NewPath: newPath, + ExpectedHash: record.CurrentHash, + Title: rendered.Title, + }) + if moveErr != nil { + if rollbackErr := os.Rename(newFullPath, oldFullPath); rollbackErr != nil { + return nil, fmt.Errorf("move database update failed: %v; filesystem rollback failed: %w", moveErr, rollbackErr) + } + return nil, moveErr + } + + updated, err := s.repo.GetDocumentByID(ctx, record.ID) + if err != nil { + return nil, err + } + if s.onChange != nil { + s.onChange(DocumentChange{ + Type: "move", + DocumentID: updated.ID, + Path: updated.Path, + OldPath: oldPath, + Title: updated.Title, + Hash: updated.CurrentHash, + }) + } + return updated, nil +} + func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) { if _, err := s.SyncSourceDir(ctx); err != nil { return nil, err @@ -301,10 +405,10 @@ func (s *Service) resolveRecord(ctx context.Context, requestPath string) (*Docum lastErr error ) for _, candidate := range normalizeRequestPathCandidates(requestPath) { - found, err := s.repo.GetDocumentByPath(ctx, candidate) + found, err := s.repo.GetDocumentByPathOrAlias(ctx, candidate) if err == nil { record = found - normalized = candidate + normalized = found.Path break } lastErr = err @@ -342,6 +446,10 @@ func normalizeWritableDocumentPath(path string) (string, error) { return path, nil } +func NormalizeDocumentPath(path string) (string, error) { + return normalizeWritableDocumentPath(path) +} + func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) { content, err := os.ReadFile(path) if err != nil { @@ -400,6 +508,7 @@ func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, e s.logger.Debug("synced document", "path", relative, "hash", record.Hash) return &DocumentChange{ + DocumentID: documentID, Path: relative, Title: rendered.Title, Hash: record.Hash, diff --git a/apps/server/internal/docs/service_test.go b/apps/server/internal/docs/service_test.go index aed2ec8..ce959f7 100644 --- a/apps/server/internal/docs/service_test.go +++ b/apps/server/internal/docs/service_test.go @@ -182,6 +182,127 @@ func TestSaveSourcePageRejectsTraversalForNewDocument(t *testing.T) { } } +func TestMoveDocumentPreservesIdentityAndHistory(t *testing.T) { + service, sourceDir := setupDocsTestService(t) + ctx := context.Background() + + page, err := service.LoadSourcePage(ctx, "hello") + if err != nil { + t.Fatalf("LoadSourcePage() error = %v", err) + } + before, err := service.repo.GetDocumentByPath(ctx, "hello.md") + if err != nil { + t.Fatalf("GetDocumentByPath() error = %v", err) + } + var versionsBefore int + if err := service.repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_versions WHERE document_id = ?`, before.ID).Scan(&versionsBefore); err != nil { + t.Fatalf("count versions before move: %v", err) + } + if err := service.repo.SaveAttachment(ctx, AttachmentRecord{ + Hash: "attachment-hash", OriginalName: "diagram.png", ContentType: "image/png", + SizeBytes: 10, CreatedAt: time.Now().UTC(), DocumentPath: before.Path, + }); err != nil { + t.Fatalf("save attachment: %v", err) + } + if _, err := service.repo.db.ExecContext(ctx, ` + INSERT INTO users (id, email, display_name, created_at) VALUES ('user-move', 'move@example.com', 'Mover', ?) + `, time.Now().UTC().Format(time.RFC3339)); err != nil { + t.Fatalf("create permission user: %v", err) + } + if _, err := service.repo.db.ExecContext(ctx, ` + INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, created_at) + VALUES ('permission-move', 'user-move', 'document', ?, 'write', ?) + `, before.Path, time.Now().UTC().Format(time.RFC3339)); err != nil { + t.Fatalf("create document permission: %v", err) + } + var moveChange DocumentChange + service.OnChange(func(change DocumentChange) { + moveChange = change + }) + + moved, err := service.MoveDocument(ctx, "hello.md", "guides/welcome.md", page.Hash) + if err != nil { + t.Fatalf("MoveDocument() error = %v", err) + } + if moved.ID != before.ID { + t.Fatalf("moved ID = %q, want %q", moved.ID, before.ID) + } + if moved.Path != "guides/welcome.md" { + t.Fatalf("moved path = %q, want guides/welcome.md", moved.Path) + } + if moveChange.Type != "move" || moveChange.DocumentID != moved.ID || moveChange.OldPath != "hello.md" || moveChange.Path != moved.Path { + t.Fatalf("move change = %#v", moveChange) + } + if _, err := os.Stat(filepath.Join(sourceDir, "hello.md")); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("old file still exists or stat failed: %v", err) + } + content, err := os.ReadFile(filepath.Join(sourceDir, "guides", "welcome.md")) + if err != nil { + t.Fatalf("read moved file: %v", err) + } + if string(content) != page.Content { + t.Fatalf("moved content = %q, want %q", content, page.Content) + } + + alias, err := service.repo.GetDocumentByPathOrAlias(ctx, "hello.md") + if err != nil { + t.Fatalf("resolve old path alias: %v", err) + } + if alias.ID != before.ID || alias.Path != moved.Path { + t.Fatalf("alias resolved to %#v, want ID %q at %q", alias, before.ID, moved.Path) + } + var versionsAfter int + if err := service.repo.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM document_versions WHERE document_id = ?`, before.ID).Scan(&versionsAfter); err != nil { + t.Fatalf("count versions after move: %v", err) + } + if versionsAfter != versionsBefore { + t.Fatalf("versions after move = %d, want %d", versionsAfter, versionsBefore) + } + attachment, err := service.repo.GetAttachment(ctx, "attachment-hash") + if err != nil { + t.Fatalf("get moved attachment metadata: %v", err) + } + if attachment.DocumentPath != moved.Path { + t.Fatalf("attachment document path = %q, want %q", attachment.DocumentPath, moved.Path) + } + var permissionPath string + if err := service.repo.db.QueryRowContext(ctx, `SELECT resource_id FROM permissions WHERE id = 'permission-move'`).Scan(&permissionPath); err != nil { + t.Fatalf("get moved permission: %v", err) + } + if permissionPath != moved.Path { + t.Fatalf("permission resource path = %q, want %q", permissionPath, moved.Path) + } +} + +func TestMoveDocumentRejectsStaleHashAndOccupiedDestination(t *testing.T) { + service, sourceDir := setupDocsTestService(t) + ctx := context.Background() + if _, err := service.LoadSourcePage(ctx, "hello"); err != nil { + t.Fatalf("LoadSourcePage() error = %v", err) + } + + _, err := service.MoveDocument(ctx, "hello.md", "moved.md", "stale") + var conflict *DocumentMoveConflictError + if !errors.As(err, &conflict) { + t.Fatalf("stale MoveDocument() error = %v, want DocumentMoveConflictError", err) + } + if _, err := os.Stat(filepath.Join(sourceDir, "hello.md")); err != nil { + t.Fatalf("source file changed after stale move: %v", err) + } + + if err := os.WriteFile(filepath.Join(sourceDir, "occupied.md"), []byte("# Occupied\n"), 0o644); err != nil { + t.Fatalf("create occupied destination: %v", err) + } + page, err := service.LoadSourcePage(ctx, "hello") + if err != nil { + t.Fatalf("reload source page: %v", err) + } + _, err = service.MoveDocument(ctx, "hello.md", "occupied.md", page.Hash) + if !errors.As(err, &conflict) { + t.Fatalf("occupied MoveDocument() error = %v, want DocumentMoveConflictError", err) + } +} + func TestSyncSourceDirDoesNotRebroadcastArchivedDocuments(t *testing.T) { service, _ := setupDocsTestService(t) ctx := context.Background() diff --git a/apps/server/internal/httpserver/api_handlers_test.go b/apps/server/internal/httpserver/api_handlers_test.go index 6c274f5..09a3260 100644 --- a/apps/server/internal/httpserver/api_handlers_test.go +++ b/apps/server/internal/httpserver/api_handlers_test.go @@ -827,6 +827,90 @@ func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) { } } +func TestAPIDocumentMoveKeepsStableIDAndRedirectsOldPath(t *testing.T) { + server := newAPITestServer(t) + token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite) + + documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil) + documentsRequest.Header.Set("Authorization", "Bearer "+token) + documents := httptest.NewRecorder() + server.handler.ServeHTTP(documents, documentsRequest) + var list struct { + Documents []struct { + ID string `json:"id"` + Path string `json:"path"` + Hash string `json:"hash"` + } `json:"documents"` + } + if err := json.Unmarshal(documents.Body.Bytes(), &list); err != nil || len(list.Documents) != 1 { + t.Fatalf("decode documents: err=%v payload=%#v", err, list) + } + + move := jsonRequest(http.MethodPost, "/api/documents/hello.md/move", map[string]string{ + "path": "guides/welcome.md", + "baseHash": list.Documents[0].Hash, + }) + move.Header.Set("Authorization", "Bearer "+token) + moved := httptest.NewRecorder() + server.handler.ServeHTTP(moved, move) + if moved.Code != http.StatusOK { + t.Fatalf("move status = %d, want %d; body=%s", moved.Code, http.StatusOK, moved.Body.String()) + } + var payload struct { + ID string `json:"id"` + Path string `json:"path"` + URL string `json:"url"` + } + if err := json.Unmarshal(moved.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode move response: %v", err) + } + if payload.ID != list.Documents[0].ID || payload.Path != "guides/welcome.md" || payload.URL != "/docs/guides/welcome" { + t.Fatalf("move response = %#v", payload) + } + + oldPathRequest := httptest.NewRequest(http.MethodGet, "/docs/hello", nil) + oldPathRequest.Header.Set("Authorization", "Bearer "+token) + oldPath := httptest.NewRecorder() + server.handler.ServeHTTP(oldPath, oldPathRequest) + if oldPath.Code != http.StatusFound || oldPath.Header().Get("Location") != payload.URL { + t.Fatalf("old path response = %d location=%q, want redirect to %q", oldPath.Code, oldPath.Header().Get("Location"), payload.URL) + } + + stableRequest := httptest.NewRequest(http.MethodGet, "/documents/"+url.PathEscape(payload.ID), nil) + stableRequest.Header.Set("Authorization", "Bearer "+token) + stable := httptest.NewRecorder() + server.handler.ServeHTTP(stable, stableRequest) + if stable.Code != http.StatusFound || stable.Header().Get("Location") != payload.URL { + t.Fatalf("stable ID response = %d location=%q, want redirect to %q", stable.Code, stable.Header().Get("Location"), payload.URL) + } +} + +func TestAPIDocumentMoveRequiresDestinationFolderWrite(t *testing.T) { + server := newAPITestServer(t) + viewer := server.viewerUser(t) + admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin} + if err := server.auth.EnsureResourcePermission(context.Background(), admin, auth.ResourceDocument, "hello.md", auth.ResourcePermissionUpdate{ + UserID: viewer.ID, Permission: auth.PermissionWrite, + }); err != nil { + t.Fatalf("grant source document write: %v", err) + } + page, err := server.docs.LoadSourcePage(context.Background(), "hello") + if err != nil { + t.Fatalf("load source document: %v", err) + } + token := server.tokenForUser(t, viewer.ID, auth.ScopeDocsRead, auth.ScopeDocsWrite) + move := jsonRequest(http.MethodPost, "/api/documents/hello.md/move", map[string]string{ + "path": "private/welcome.md", + "baseHash": page.Hash, + }) + move.Header.Set("Authorization", "Bearer "+token) + response := httptest.NewRecorder() + server.handler.ServeHTTP(response, move) + if response.Code != http.StatusForbidden { + t.Fatalf("move status = %d, want %d; body=%s", response.Code, http.StatusForbidden, response.Body.String()) + } +} + func TestAPIDocumentCreateGrantsCreatorAdminPermission(t *testing.T) { server := newAPITestServer(t) token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite) diff --git a/apps/server/internal/httpserver/handlers.go b/apps/server/internal/httpserver/handlers.go index e414747..4d9cc2c 100644 --- a/apps/server/internal/httpserver/handlers.go +++ b/apps/server/internal/httpserver/handlers.go @@ -54,6 +54,7 @@ type documentData struct { type documentEditData struct { Browser browserData + ID string Title string Path string Tags []string @@ -209,6 +210,24 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) { s.renderDocumentPage(w, r, pagePath) } +func (s *Server) handleDocumentByID(w http.ResponseWriter, r *http.Request) { + record, err := s.repository.GetDocumentByID(r.Context(), chi.URLParam(r, "id")) + if err != nil { + s.renderError(w, r, http.StatusNotFound, "document not found") + return + } + canRead, err := s.canReadDocumentRecord(r, *record) + if err != nil { + s.renderError(w, r, http.StatusInternalServerError, err.Error()) + return + } + if !canRead { + s.renderError(w, r, http.StatusNotFound, "document not found") + return + } + http.Redirect(w, r, documentPageURL(record.Path), http.StatusFound) +} + func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) { cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/"))) if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) { @@ -291,6 +310,10 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa s.renderError(w, r, http.StatusNotFound, "document not found") return } + if !requestPathMatchesDocument(pagePath, page.Path) { + http.Redirect(w, r, documentPageURL(page.Path)+"/edit", http.StatusFound) + return + } items, err := s.documents.ListDocuments(r.Context()) if err != nil { @@ -311,6 +334,7 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa BodyTemplate: "document_edit_content", Data: documentEditData{ Browser: browser, + ID: record.ID, Title: page.Title, Path: page.Path, Tags: page.Tags, @@ -346,6 +370,10 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page s.renderError(w, r, http.StatusNotFound, "document not found") return } + if !requestPathMatchesDocument(pagePath, page.Path) { + http.Redirect(w, r, documentPageURL(page.Path), http.StatusFound) + return + } canWrite, err := s.canWriteDocumentRecord(r, *record) if err != nil { s.renderError(w, r, http.StatusInternalServerError, err.Error()) @@ -539,7 +567,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { s.renderError(w, r, http.StatusInternalServerError, "inspect existing documents") return } - existing, err := s.repository.GetDocumentByPath(r.Context(), path) + existing, err := s.repository.GetDocumentByPathOrAlias(r.Context(), path) if err != nil && !errors.Is(err, sql.ErrNoRows) { s.renderError(w, r, http.StatusInternalServerError, "inspect existing document") return @@ -611,7 +639,7 @@ func (s *Server) availableDocumentPath(ctx context.Context, path string) (string base := strings.TrimSuffix(path, extension) for suffix := 2; ; suffix++ { candidate := fmt.Sprintf("%s-%d%s", base, suffix, extension) - existing, err := s.repository.GetDocumentByPath(ctx, candidate) + existing, err := s.repository.GetDocumentByPathOrAlias(ctx, candidate) if errors.Is(err, sql.ErrNoRows) { return candidate, nil } @@ -827,6 +855,7 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) { results := make([]map[string]any, 0, len(records)) for _, record := range records { results = append(results, map[string]any{ + "id": record.ID, "path": record.Path, "title": record.Title, "hash": record.CurrentHash, @@ -844,6 +873,8 @@ func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) return } switch { + case strings.HasSuffix(pagePath, "/move"): + s.handleDocumentMovePath(w, r, strings.TrimSuffix(pagePath, "/move")) case strings.HasSuffix(pagePath, "/archive"): s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive")) case strings.HasSuffix(pagePath, "/restore"): @@ -853,6 +884,64 @@ func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) } } +func (s *Server) handleDocumentMovePath(w http.ResponseWriter, r *http.Request, pagePath string) { + if pagePath == "" { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"}) + return + } + var req struct { + Path string `json:"path"` + BaseHash string `json:"baseHash"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + destination, err := docs.NormalizeDocumentPath(req.Path) + if err != nil { + writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) + return + } + record, err := s.documentRecordForRequestPath(r, pagePath) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"}) + return + } + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + canWrite, err := s.canWriteDocumentRecord(r, *record) + if err != nil { + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + if !canWrite || !s.canWriteFolder(r, documentFolder(destination)) { + writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"}) + return + } + + moved, err := s.documents.MoveDocument(r.Context(), record.Path, destination, req.BaseHash) + if err != nil { + var conflict *docs.DocumentMoveConflictError + switch { + case errors.As(err, &conflict): + writeJSONWithStatus(w, http.StatusConflict, map[string]string{"error": conflict.Error()}) + case errors.Is(err, sql.ErrNoRows): + writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"}) + default: + writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + } + return + } + writeJSONWithStatus(w, http.StatusOK, map[string]any{ + "status": "moved", + "id": moved.ID, + "path": moved.Path, + "url": documentPageURL(moved.Path), + }) +} + func unescapeDocumentRoutePath(path string) (string, error) { if path == "" { return "", nil @@ -1011,6 +1100,17 @@ func trimEditSuffix(path string) (string, bool) { return strings.TrimSuffix(path, "/edit"), true } +func requestPathMatchesDocument(requestPath, documentPath string) bool { + requestPath = strings.Trim(requestPath, "/") + if requestPath == "" { + return documentPath == "index.md" || documentPath == "getting-started.md" + } + if strings.HasSuffix(requestPath, ".md") { + return requestPath == documentPath + } + return requestPath+".md" == documentPath || requestPath+"/index.md" == documentPath +} + func isContentAssetPath(path string) bool { extension := strings.ToLower(filepath.Ext(path)) return extension != "" && extension != ".md" diff --git a/apps/server/internal/httpserver/middleware.go b/apps/server/internal/httpserver/middleware.go index 7085d51..e45c560 100644 --- a/apps/server/internal/httpserver/middleware.go +++ b/apps/server/internal/httpserver/middleware.go @@ -215,7 +215,7 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) { return auth.ScopeDocsRead, true case strings.HasPrefix(path, "/api/watch"): return auth.ScopeDocsRead, true - case path == "/docs" || strings.HasPrefix(path, "/docs/"): + case path == "/docs" || strings.HasPrefix(path, "/docs/") || strings.HasPrefix(path, "/documents/"): return auth.ScopeDocsRead, true case strings.HasSuffix(path, "/edit"): return auth.ScopeDocsWrite, true diff --git a/apps/server/internal/httpserver/permissions_handlers.go b/apps/server/internal/httpserver/permissions_handlers.go index f92c3a5..c7e960b 100644 --- a/apps/server/internal/httpserver/permissions_handlers.go +++ b/apps/server/internal/httpserver/permissions_handlers.go @@ -309,7 +309,7 @@ func (s *Server) documentRecordForRequestPath(r *http.Request, requestPath strin if !strings.HasSuffix(strings.ToLower(clean), ".md") { clean += ".md" } - return s.repository.GetDocumentByPath(r.Context(), clean) + return s.repository.GetDocumentByPathOrAlias(r.Context(), clean) } func (s *Server) canReadDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) { diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index 4831268..de2fed9 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -150,6 +150,7 @@ func New(deps Dependencies) (http.Handler, error) { router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync) router.Get("/docs", server.handleDocsIndex) router.Get("/docs/*", server.handleDocument) + router.Get("/documents/{id}", server.handleDocumentByID) router.Get("/api/documents", server.handleDocuments) router.Post("/api/documents/*", server.handleDocumentMutation) router.Get("/api/search", server.handleSearch) diff --git a/apps/server/internal/httpserver/static/move.js b/apps/server/internal/httpserver/static/move.js new file mode 100644 index 0000000..3ba3405 --- /dev/null +++ b/apps/server/internal/httpserver/static/move.js @@ -0,0 +1,42 @@ +(function () { + 'use strict'; + + function apiDocumentPath(path) { + return (path || '') + .replace(/^\/+/, '') + .split('/') + .map(encodeURIComponent) + .join('/'); + } + + document.addEventListener('click', function (event) { + var button = event.target.closest('[data-move-path]'); + if (!button) return; + + var currentPath = button.getAttribute('data-move-path'); + var baseHash = button.getAttribute('data-move-hash'); + var destination = window.prompt('Move document to:', currentPath); + if (destination === null || destination.trim() === '' || destination.trim() === currentPath) return; + + button.disabled = true; + fetch('/api/documents/' + apiDocumentPath(currentPath) + '/move', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path: destination.trim(), baseHash: baseHash }), + }) + .then(function (response) { + return response.json().then(function (data) { + if (!response.ok) throw new Error(data.error || 'Move failed'); + return data; + }); + }) + .then(function (data) { + window.location.assign(data.url); + }) + .catch(function (error) { + window.alert(error.message || 'Failed to move document'); + button.disabled = false; + }); + }); +})(); diff --git a/apps/server/internal/httpserver/static/notification-bell.js b/apps/server/internal/httpserver/static/notification-bell.js index 494fec1..9c361e3 100644 --- a/apps/server/internal/httpserver/static/notification-bell.js +++ b/apps/server/internal/httpserver/static/notification-bell.js @@ -142,8 +142,7 @@ function notificationURL(notification) { if (notification.resourceType !== 'document' || !notification.resourceId) return ''; - const path = notification.resourceId.replace(/^doc:/, '').split('/').map(encodeURIComponent).join('/'); - return '/docs/' + path; + return '/documents/' + encodeURIComponent(notification.resourceId); } // Listen for WebSocket notification events diff --git a/apps/server/internal/httpserver/static/realtime.js b/apps/server/internal/httpserver/static/realtime.js index 307b3a7..370483b 100644 --- a/apps/server/internal/httpserver/static/realtime.js +++ b/apps/server/internal/httpserver/static/realtime.js @@ -298,6 +298,7 @@ const currentPath = documentShell ? documentShell.getAttribute("data-document-path") : null; const currentHash = documentShell ? documentShell.getAttribute("data-document-hash") : null; + const currentDocumentID = documentShell ? documentShell.getAttribute("data-document-id") : null; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; let reconnectTimer = null; @@ -332,6 +333,11 @@ const change = payload.data; + if (change.type === "move" && currentDocumentID && change.documentId === currentDocumentID) { + window.location.assign("/documents/" + encodeURIComponent(currentDocumentID)); + return; + } + if (isEditing && currentPath && change.path === currentPath) { return; } diff --git a/apps/server/internal/httpserver/sync_handlers.go b/apps/server/internal/httpserver/sync_handlers.go index e32e145..06fcbb7 100644 --- a/apps/server/internal/httpserver/sync_handlers.go +++ b/apps/server/internal/httpserver/sync_handlers.go @@ -171,7 +171,7 @@ func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.P if accessErr != nil { return false } - record, err := s.repository.GetDocumentByPath(r.Context(), file.Path) + record, err := s.repository.GetDocumentByPathOrAlias(r.Context(), file.Path) if err != nil { if errors.Is(err, sql.ErrNoRows) { return s.canReadNewDocument(r, file.Path) @@ -190,7 +190,7 @@ func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.P if accessErr != nil { return false } - record, err := s.repository.GetDocumentByPath(r.Context(), path) + record, err := s.repository.GetDocumentByPathOrAlias(r.Context(), path) if err == nil && record != nil { ok, err := s.canWriteDocumentRecord(r, *record) if err != nil { diff --git a/apps/server/internal/httpserver/templates/base.gohtml b/apps/server/internal/httpserver/templates/base.gohtml index 8f2a1f1..b9db8c7 100644 --- a/apps/server/internal/httpserver/templates/base.gohtml +++ b/apps/server/internal/httpserver/templates/base.gohtml @@ -115,6 +115,7 @@ + diff --git a/apps/server/internal/httpserver/templates/document.gohtml b/apps/server/internal/httpserver/templates/document.gohtml index ee8d18f..8beb5f3 100644 --- a/apps/server/internal/httpserver/templates/document.gohtml +++ b/apps/server/internal/httpserver/templates/document.gohtml @@ -52,6 +52,10 @@ Edit + {{ if .CanAdmin }} diff --git a/apps/server/internal/httpserver/templates/document_edit.gohtml b/apps/server/internal/httpserver/templates/document_edit.gohtml index 69f6252..cec16e7 100644 --- a/apps/server/internal/httpserver/templates/document_edit.gohtml +++ b/apps/server/internal/httpserver/templates/document_edit.gohtml @@ -3,7 +3,7 @@ {{ define "document_edit_content" }}
{{ template "browser" .Browser }} -
+