feature: move docs

This commit is contained in:
2026-07-27 11:52:13 -04:00
parent ae0939177c
commit 03c06c0dd9
20 changed files with 607 additions and 35 deletions

View File

@@ -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. - 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. - 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. - 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. - The macOS menu-bar app and CLI compile successfully.
## Milestone Progress ## Milestone Progress

View File

@@ -7,7 +7,6 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"os" "os"
"strings"
"time" "time"
"github.com/tim/cairnquire/apps/server/internal/auth" "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 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, PublicOrigin: cfg.Auth.PublicOrigin,
}) })
service.OnChange(func(change docs.DocumentChange) { service.OnChange(func(change docs.DocumentChange) {
hub.Broadcast(realtime.Event{Type: "document_version", Data: change}) 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) logger.Warn("notify document changed", "error", err)
} }
}) })
@@ -225,9 +231,3 @@ func (a *App) syncPoll(ctx context.Context) {
func (a *App) Close() error { func (a *App) Close() error {
return a.db.Close() return a.db.Close()
} }
func formatDocumentID(path string) string {
clean := strings.TrimPrefix(path, "/")
clean = strings.TrimSuffix(clean, ".md")
return "doc:" + clean
}

View File

@@ -53,6 +53,7 @@ type UserLookup interface {
} }
type DocumentReadAuthorizer func(ctx context.Context, userID, documentID string) (bool, error) 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{} type NoOpEmailSender struct{}
@@ -68,6 +69,7 @@ type Service struct {
renderer EmailRenderer renderer EmailRenderer
users UserLookup users UserLookup
canReadDocument DocumentReadAuthorizer canReadDocument DocumentReadAuthorizer
resolveDocument DocumentResolver
hub *realtime.Hub hub *realtime.Hub
logger *slog.Logger logger *slog.Logger
publicOrigin string publicOrigin string
@@ -79,6 +81,7 @@ type Options struct {
EmailRenderer EmailRenderer EmailRenderer EmailRenderer
UserLookup UserLookup UserLookup UserLookup
CanReadDocument DocumentReadAuthorizer CanReadDocument DocumentReadAuthorizer
ResolveDocument DocumentResolver
PublicOrigin string PublicOrigin string
} }
@@ -101,6 +104,7 @@ func NewServiceWithOptions(repo *Repository, hub *realtime.Hub, logger *slog.Log
renderer: opts.EmailRenderer, renderer: opts.EmailRenderer,
users: opts.UserLookup, users: opts.UserLookup,
canReadDocument: opts.CanReadDocument, canReadDocument: opts.CanReadDocument,
resolveDocument: opts.ResolveDocument,
hub: hub, hub: hub,
logger: logger, logger: logger,
publicOrigin: opts.PublicOrigin, 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{ s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "file_changed", EmailData{
ActorName: actorName, ActorName: actorName,
DocumentPath: documentPath, 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 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) seen := make(map[string]bool)
for _, w := range watchers { for _, w := range watchers {
if w.UserID == comment.AuthorID { if w.UserID == comment.AuthorID {
@@ -391,10 +407,11 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment)
continue continue
} }
s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{ s.enqueueNotificationEmail(ctx, notification.ID, w.UserID, "comment", EmailData{
ActorName: comment.AuthorName, ActorName: comment.AuthorName,
DocumentPath: documentPathFromID(comment.DocumentID) + ".md", DocumentPath: documentPath,
CommentBody: comment.Content, DocumentTitle: documentTitle,
ViewURL: s.documentURL(comment.DocumentID), CommentBody: comment.Content,
ViewURL: s.documentIDURL(comment.DocumentID),
}) })
} }
return nil 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 == "" { if s.publicOrigin == "" {
return "" return ""
} }
clean := strings.TrimPrefix(documentPath, "doc:") return strings.TrimRight(s.publicOrigin, "/") + "/documents/" + url.PathEscape(documentID)
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, "/")
} }
func (s *Service) watcherCanRead(ctx context.Context, userID, documentID string) bool { func (s *Service) watcherCanRead(ctx context.Context, userID, documentID string) bool {

View File

@@ -0,0 +1,2 @@
DROP INDEX IF EXISTS idx_document_aliases_document;
DROP TABLE IF EXISTS document_aliases;

View File

@@ -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);

View File

@@ -51,6 +51,14 @@ type PersistDocumentInput struct {
Tags []string Tags []string
} }
type MoveDocumentInput struct {
ID string
OldPath string
NewPath string
ExpectedHash string
Title string
}
func NewRepository(db *sql.DB) *Repository { func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db} return &Repository{db: db}
} }
@@ -109,6 +117,81 @@ func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, pat
return r.getDocumentByPath(ctx, path, true) 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) { func (r *Repository) GetDocumentByID(ctx context.Context, id string) (*DocumentRecord, error) {
var ( var (
record DocumentRecord record DocumentRecord

View File

@@ -27,12 +27,25 @@ type Service struct {
} }
type DocumentChange struct { type DocumentChange struct {
Type string `json:"type,omitempty"`
DocumentID string `json:"documentId"`
Path string `json:"path"` Path string `json:"path"`
OldPath string `json:"oldPath,omitempty"`
Title string `json:"title"` Title string `json:"title"`
Hash string `json:"hash"` Hash string `json:"hash"`
PreviousHash string `json:"previousHash,omitempty"` 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 { type Page struct {
Path string Path string
Title string Title string
@@ -146,9 +159,10 @@ func (s *Service) ArchiveDocument(ctx context.Context, path string) error {
if s.onChange != nil { if s.onChange != nil {
s.onChange(DocumentChange{ s.onChange(DocumentChange{
Path: record.Path, DocumentID: record.ID,
Title: record.Title, Path: record.Path,
Hash: record.CurrentHash, Title: record.Title,
Hash: record.CurrentHash,
}) })
} }
@@ -170,6 +184,96 @@ func (s *Service) RestoreDocument(ctx context.Context, path string) error {
return nil 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) { func (s *Service) LoadPage(ctx context.Context, requestPath string) (*Page, error) {
if _, err := s.SyncSourceDir(ctx); err != nil { if _, err := s.SyncSourceDir(ctx); err != nil {
return nil, err return nil, err
@@ -301,10 +405,10 @@ func (s *Service) resolveRecord(ctx context.Context, requestPath string) (*Docum
lastErr error lastErr error
) )
for _, candidate := range normalizeRequestPathCandidates(requestPath) { for _, candidate := range normalizeRequestPathCandidates(requestPath) {
found, err := s.repo.GetDocumentByPath(ctx, candidate) found, err := s.repo.GetDocumentByPathOrAlias(ctx, candidate)
if err == nil { if err == nil {
record = found record = found
normalized = candidate normalized = found.Path
break break
} }
lastErr = err lastErr = err
@@ -342,6 +446,10 @@ func normalizeWritableDocumentPath(path string) (string, error) {
return path, nil return path, nil
} }
func NormalizeDocumentPath(path string) (string, error) {
return normalizeWritableDocumentPath(path)
}
func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) { func (s *Service) syncFile(ctx context.Context, path string) (*DocumentChange, error) {
content, err := os.ReadFile(path) content, err := os.ReadFile(path)
if err != nil { 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) s.logger.Debug("synced document", "path", relative, "hash", record.Hash)
return &DocumentChange{ return &DocumentChange{
DocumentID: documentID,
Path: relative, Path: relative,
Title: rendered.Title, Title: rendered.Title,
Hash: record.Hash, Hash: record.Hash,

View File

@@ -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) { func TestSyncSourceDirDoesNotRebroadcastArchivedDocuments(t *testing.T) {
service, _ := setupDocsTestService(t) service, _ := setupDocsTestService(t)
ctx := context.Background() ctx := context.Background()

View File

@@ -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) { func TestAPIDocumentCreateGrantsCreatorAdminPermission(t *testing.T) {
server := newAPITestServer(t) server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite) token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)

View File

@@ -54,6 +54,7 @@ type documentData struct {
type documentEditData struct { type documentEditData struct {
Browser browserData Browser browserData
ID string
Title string Title string
Path string Path string
Tags []string Tags []string
@@ -209,6 +210,24 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
s.renderDocumentPage(w, r, pagePath) 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) { func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) {
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/"))) cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/")))
if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) { 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") s.renderError(w, r, http.StatusNotFound, "document not found")
return return
} }
if !requestPathMatchesDocument(pagePath, page.Path) {
http.Redirect(w, r, documentPageURL(page.Path)+"/edit", http.StatusFound)
return
}
items, err := s.documents.ListDocuments(r.Context()) items, err := s.documents.ListDocuments(r.Context())
if err != nil { if err != nil {
@@ -311,6 +334,7 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
BodyTemplate: "document_edit_content", BodyTemplate: "document_edit_content",
Data: documentEditData{ Data: documentEditData{
Browser: browser, Browser: browser,
ID: record.ID,
Title: page.Title, Title: page.Title,
Path: page.Path, Path: page.Path,
Tags: page.Tags, 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") s.renderError(w, r, http.StatusNotFound, "document not found")
return return
} }
if !requestPathMatchesDocument(pagePath, page.Path) {
http.Redirect(w, r, documentPageURL(page.Path), http.StatusFound)
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record) canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil { if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error()) 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") s.renderError(w, r, http.StatusInternalServerError, "inspect existing documents")
return 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) { if err != nil && !errors.Is(err, sql.ErrNoRows) {
s.renderError(w, r, http.StatusInternalServerError, "inspect existing document") s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
return return
@@ -611,7 +639,7 @@ func (s *Server) availableDocumentPath(ctx context.Context, path string) (string
base := strings.TrimSuffix(path, extension) base := strings.TrimSuffix(path, extension)
for suffix := 2; ; suffix++ { for suffix := 2; ; suffix++ {
candidate := fmt.Sprintf("%s-%d%s", base, suffix, extension) 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) { if errors.Is(err, sql.ErrNoRows) {
return candidate, nil 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)) results := make([]map[string]any, 0, len(records))
for _, record := range records { for _, record := range records {
results = append(results, map[string]any{ results = append(results, map[string]any{
"id": record.ID,
"path": record.Path, "path": record.Path,
"title": record.Title, "title": record.Title,
"hash": record.CurrentHash, "hash": record.CurrentHash,
@@ -844,6 +873,8 @@ func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request)
return return
} }
switch { switch {
case strings.HasSuffix(pagePath, "/move"):
s.handleDocumentMovePath(w, r, strings.TrimSuffix(pagePath, "/move"))
case strings.HasSuffix(pagePath, "/archive"): case strings.HasSuffix(pagePath, "/archive"):
s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive")) s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
case strings.HasSuffix(pagePath, "/restore"): 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) { func unescapeDocumentRoutePath(path string) (string, error) {
if path == "" { if path == "" {
return "", nil return "", nil
@@ -1011,6 +1100,17 @@ func trimEditSuffix(path string) (string, bool) {
return strings.TrimSuffix(path, "/edit"), true 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 { func isContentAssetPath(path string) bool {
extension := strings.ToLower(filepath.Ext(path)) extension := strings.ToLower(filepath.Ext(path))
return extension != "" && extension != ".md" return extension != "" && extension != ".md"

View File

@@ -215,7 +215,7 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
return auth.ScopeDocsRead, true return auth.ScopeDocsRead, true
case strings.HasPrefix(path, "/api/watch"): case strings.HasPrefix(path, "/api/watch"):
return auth.ScopeDocsRead, true 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 return auth.ScopeDocsRead, true
case strings.HasSuffix(path, "/edit"): case strings.HasSuffix(path, "/edit"):
return auth.ScopeDocsWrite, true return auth.ScopeDocsWrite, true

View File

@@ -309,7 +309,7 @@ func (s *Server) documentRecordForRequestPath(r *http.Request, requestPath strin
if !strings.HasSuffix(strings.ToLower(clean), ".md") { if !strings.HasSuffix(strings.ToLower(clean), ".md") {
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) { func (s *Server) canReadDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {

View File

@@ -150,6 +150,7 @@ func New(deps Dependencies) (http.Handler, error) {
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync) router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
router.Get("/docs", server.handleDocsIndex) router.Get("/docs", server.handleDocsIndex)
router.Get("/docs/*", server.handleDocument) router.Get("/docs/*", server.handleDocument)
router.Get("/documents/{id}", server.handleDocumentByID)
router.Get("/api/documents", server.handleDocuments) router.Get("/api/documents", server.handleDocuments)
router.Post("/api/documents/*", server.handleDocumentMutation) router.Post("/api/documents/*", server.handleDocumentMutation)
router.Get("/api/search", server.handleSearch) router.Get("/api/search", server.handleSearch)

View File

@@ -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;
});
});
})();

View File

@@ -142,8 +142,7 @@
function notificationURL(notification) { function notificationURL(notification) {
if (notification.resourceType !== 'document' || !notification.resourceId) return ''; if (notification.resourceType !== 'document' || !notification.resourceId) return '';
const path = notification.resourceId.replace(/^doc:/, '').split('/').map(encodeURIComponent).join('/'); return '/documents/' + encodeURIComponent(notification.resourceId);
return '/docs/' + path;
} }
// Listen for WebSocket notification events // Listen for WebSocket notification events

View File

@@ -298,6 +298,7 @@
const currentPath = documentShell ? documentShell.getAttribute("data-document-path") : null; const currentPath = documentShell ? documentShell.getAttribute("data-document-path") : null;
const currentHash = documentShell ? documentShell.getAttribute("data-document-hash") : 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:"; const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
let reconnectTimer = null; let reconnectTimer = null;
@@ -332,6 +333,11 @@
const change = payload.data; 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) { if (isEditing && currentPath && change.path === currentPath) {
return; return;
} }

View File

@@ -171,7 +171,7 @@ func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.P
if accessErr != nil { if accessErr != nil {
return false return false
} }
record, err := s.repository.GetDocumentByPath(r.Context(), file.Path) record, err := s.repository.GetDocumentByPathOrAlias(r.Context(), file.Path)
if err != nil { if err != nil {
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
return s.canReadNewDocument(r, file.Path) return s.canReadNewDocument(r, file.Path)
@@ -190,7 +190,7 @@ func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.P
if accessErr != nil { if accessErr != nil {
return false return false
} }
record, err := s.repository.GetDocumentByPath(r.Context(), path) record, err := s.repository.GetDocumentByPathOrAlias(r.Context(), path)
if err == nil && record != nil { if err == nil && record != nil {
ok, err := s.canWriteDocumentRecord(r, *record) ok, err := s.canWriteDocumentRecord(r, *record)
if err != nil { if err != nil {

View File

@@ -115,6 +115,7 @@
<script src="/static/keyboard-nav.js" defer></script> <script src="/static/keyboard-nav.js" defer></script>
<script src="/static/mobile.js" defer></script> <script src="/static/mobile.js" defer></script>
<script src="/static/archive.js" defer></script> <script src="/static/archive.js" defer></script>
<script src="/static/move.js" defer></script>
<script src="/static/permissions.js" defer></script> <script src="/static/permissions.js" defer></script>
<script src="/static/tags.js" defer></script> <script src="/static/tags.js" defer></script>
</body> </body>

View File

@@ -52,6 +52,10 @@
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"></path><path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4L16.5 3.5z"></path></svg>
<span>Edit</span> <span>Edit</span>
</a> </a>
<button class="document-actions-dropdown__item" type="button" data-move-path="{{ .Path }}" data-move-hash="{{ .Hash }}">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 9V5h4"></path><path d="m5 5 6 6"></path><path d="M19 15v4h-4"></path><path d="m19 19-6-6"></path></svg>
<span>Move</span>
</button>
{{ if .CanAdmin }} {{ if .CanAdmin }}
<a class="document-actions-dropdown__item" href="/permissions?resourceType=document&resourceId={{ .Path }}"> <a class="document-actions-dropdown__item" href="/permissions?resourceType=document&resourceId={{ .Path }}">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg> <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg>

View File

@@ -3,7 +3,7 @@
{{ define "document_edit_content" }} {{ define "document_edit_content" }}
<section class="workspace-shell workspace-shell--document-edit"> <section class="workspace-shell workspace-shell--document-edit">
{{ template "browser" .Browser }} {{ template "browser" .Browser }}
<article class="document-shell document-shell--editor" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}"> <article class="document-shell document-shell--editor" data-document-id="{{ .ID }}" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
<nav class="breadcrumbs" aria-label="Breadcrumb"> <nav class="breadcrumbs" aria-label="Breadcrumb">
<ol> <ol>
{{ $lastIdx := sub (len .Breadcrumbs) 1 }} {{ $lastIdx := sub (len .Breadcrumbs) 1 }}