feature: move docs
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -392,9 +408,10 @@ func (s *Service) notifyWatchersOfComment(ctx context.Context, comment Comment)
|
|||||||
}
|
}
|
||||||
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,
|
||||||
|
DocumentTitle: documentTitle,
|
||||||
CommentBody: comment.Content,
|
CommentBody: comment.Content,
|
||||||
ViewURL: s.documentURL(comment.DocumentID),
|
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 {
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
DROP INDEX IF EXISTS idx_document_aliases_document;
|
||||||
|
DROP TABLE IF EXISTS document_aliases;
|
||||||
@@ -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);
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,6 +159,7 @@ func (s *Service) ArchiveDocument(ctx context.Context, path string) error {
|
|||||||
|
|
||||||
if s.onChange != nil {
|
if s.onChange != nil {
|
||||||
s.onChange(DocumentChange{
|
s.onChange(DocumentChange{
|
||||||
|
DocumentID: record.ID,
|
||||||
Path: record.Path,
|
Path: record.Path,
|
||||||
Title: record.Title,
|
Title: record.Title,
|
||||||
Hash: record.CurrentHash,
|
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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
42
apps/server/internal/httpserver/static/move.js
Normal file
42
apps/server/internal/httpserver/static/move.js
Normal 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;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
Reference in New Issue
Block a user