feature: move docs
This commit is contained in:
@@ -827,6 +827,90 @@ func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentMoveKeepsStableIDAndRedirectsOldPath(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||
|
||||
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
|
||||
documentsRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
documents := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(documents, documentsRequest)
|
||||
var list struct {
|
||||
Documents []struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
Hash string `json:"hash"`
|
||||
} `json:"documents"`
|
||||
}
|
||||
if err := json.Unmarshal(documents.Body.Bytes(), &list); err != nil || len(list.Documents) != 1 {
|
||||
t.Fatalf("decode documents: err=%v payload=%#v", err, list)
|
||||
}
|
||||
|
||||
move := jsonRequest(http.MethodPost, "/api/documents/hello.md/move", map[string]string{
|
||||
"path": "guides/welcome.md",
|
||||
"baseHash": list.Documents[0].Hash,
|
||||
})
|
||||
move.Header.Set("Authorization", "Bearer "+token)
|
||||
moved := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(moved, move)
|
||||
if moved.Code != http.StatusOK {
|
||||
t.Fatalf("move status = %d, want %d; body=%s", moved.Code, http.StatusOK, moved.Body.String())
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal(moved.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode move response: %v", err)
|
||||
}
|
||||
if payload.ID != list.Documents[0].ID || payload.Path != "guides/welcome.md" || payload.URL != "/docs/guides/welcome" {
|
||||
t.Fatalf("move response = %#v", payload)
|
||||
}
|
||||
|
||||
oldPathRequest := httptest.NewRequest(http.MethodGet, "/docs/hello", nil)
|
||||
oldPathRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
oldPath := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(oldPath, oldPathRequest)
|
||||
if oldPath.Code != http.StatusFound || oldPath.Header().Get("Location") != payload.URL {
|
||||
t.Fatalf("old path response = %d location=%q, want redirect to %q", oldPath.Code, oldPath.Header().Get("Location"), payload.URL)
|
||||
}
|
||||
|
||||
stableRequest := httptest.NewRequest(http.MethodGet, "/documents/"+url.PathEscape(payload.ID), nil)
|
||||
stableRequest.Header.Set("Authorization", "Bearer "+token)
|
||||
stable := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(stable, stableRequest)
|
||||
if stable.Code != http.StatusFound || stable.Header().Get("Location") != payload.URL {
|
||||
t.Fatalf("stable ID response = %d location=%q, want redirect to %q", stable.Code, stable.Header().Get("Location"), payload.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentMoveRequiresDestinationFolderWrite(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
viewer := server.viewerUser(t)
|
||||
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
|
||||
if err := server.auth.EnsureResourcePermission(context.Background(), admin, auth.ResourceDocument, "hello.md", auth.ResourcePermissionUpdate{
|
||||
UserID: viewer.ID, Permission: auth.PermissionWrite,
|
||||
}); err != nil {
|
||||
t.Fatalf("grant source document write: %v", err)
|
||||
}
|
||||
page, err := server.docs.LoadSourcePage(context.Background(), "hello")
|
||||
if err != nil {
|
||||
t.Fatalf("load source document: %v", err)
|
||||
}
|
||||
token := server.tokenForUser(t, viewer.ID, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||
move := jsonRequest(http.MethodPost, "/api/documents/hello.md/move", map[string]string{
|
||||
"path": "private/welcome.md",
|
||||
"baseHash": page.Hash,
|
||||
})
|
||||
move.Header.Set("Authorization", "Bearer "+token)
|
||||
response := httptest.NewRecorder()
|
||||
server.handler.ServeHTTP(response, move)
|
||||
if response.Code != http.StatusForbidden {
|
||||
t.Fatalf("move status = %d, want %d; body=%s", response.Code, http.StatusForbidden, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIDocumentCreateGrantsCreatorAdminPermission(t *testing.T) {
|
||||
server := newAPITestServer(t)
|
||||
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
|
||||
|
||||
@@ -54,6 +54,7 @@ type documentData struct {
|
||||
|
||||
type documentEditData struct {
|
||||
Browser browserData
|
||||
ID string
|
||||
Title string
|
||||
Path string
|
||||
Tags []string
|
||||
@@ -209,6 +210,24 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderDocumentPage(w, r, pagePath)
|
||||
}
|
||||
|
||||
func (s *Server) handleDocumentByID(w http.ResponseWriter, r *http.Request) {
|
||||
record, err := s.repository.GetDocumentByID(r.Context(), chi.URLParam(r, "id"))
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
canRead, err := s.canReadDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
if !canRead {
|
||||
s.renderError(w, r, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
http.Redirect(w, r, documentPageURL(record.Path), http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleContentAsset(w http.ResponseWriter, r *http.Request, assetPath string) {
|
||||
cleanPath := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(assetPath, "/")))
|
||||
if cleanPath == "." || strings.HasPrefix(cleanPath, ".."+string(filepath.Separator)) || filepath.IsAbs(cleanPath) {
|
||||
@@ -291,6 +310,10 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
|
||||
s.renderError(w, r, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
if !requestPathMatchesDocument(pagePath, page.Path) {
|
||||
http.Redirect(w, r, documentPageURL(page.Path)+"/edit", http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
items, err := s.documents.ListDocuments(r.Context())
|
||||
if err != nil {
|
||||
@@ -311,6 +334,7 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
|
||||
BodyTemplate: "document_edit_content",
|
||||
Data: documentEditData{
|
||||
Browser: browser,
|
||||
ID: record.ID,
|
||||
Title: page.Title,
|
||||
Path: page.Path,
|
||||
Tags: page.Tags,
|
||||
@@ -346,6 +370,10 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
|
||||
s.renderError(w, r, http.StatusNotFound, "document not found")
|
||||
return
|
||||
}
|
||||
if !requestPathMatchesDocument(pagePath, page.Path) {
|
||||
http.Redirect(w, r, documentPageURL(page.Path), http.StatusFound)
|
||||
return
|
||||
}
|
||||
canWrite, err := s.canWriteDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
s.renderError(w, r, http.StatusInternalServerError, err.Error())
|
||||
@@ -539,7 +567,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "inspect existing documents")
|
||||
return
|
||||
}
|
||||
existing, err := s.repository.GetDocumentByPath(r.Context(), path)
|
||||
existing, err := s.repository.GetDocumentByPathOrAlias(r.Context(), path)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
|
||||
return
|
||||
@@ -611,7 +639,7 @@ func (s *Server) availableDocumentPath(ctx context.Context, path string) (string
|
||||
base := strings.TrimSuffix(path, extension)
|
||||
for suffix := 2; ; suffix++ {
|
||||
candidate := fmt.Sprintf("%s-%d%s", base, suffix, extension)
|
||||
existing, err := s.repository.GetDocumentByPath(ctx, candidate)
|
||||
existing, err := s.repository.GetDocumentByPathOrAlias(ctx, candidate)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return candidate, nil
|
||||
}
|
||||
@@ -827,6 +855,7 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
|
||||
results := make([]map[string]any, 0, len(records))
|
||||
for _, record := range records {
|
||||
results = append(results, map[string]any{
|
||||
"id": record.ID,
|
||||
"path": record.Path,
|
||||
"title": record.Title,
|
||||
"hash": record.CurrentHash,
|
||||
@@ -844,6 +873,8 @@ func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(pagePath, "/move"):
|
||||
s.handleDocumentMovePath(w, r, strings.TrimSuffix(pagePath, "/move"))
|
||||
case strings.HasSuffix(pagePath, "/archive"):
|
||||
s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
|
||||
case strings.HasSuffix(pagePath, "/restore"):
|
||||
@@ -853,6 +884,64 @@ func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleDocumentMovePath(w http.ResponseWriter, r *http.Request, pagePath string) {
|
||||
if pagePath == "" {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Path string `json:"path"`
|
||||
BaseHash string `json:"baseHash"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
destination, err := docs.NormalizeDocumentPath(req.Path)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
record, err := s.documentRecordForRequestPath(r, pagePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
return
|
||||
}
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
canWrite, err := s.canWriteDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if !canWrite || !s.canWriteFolder(r, documentFolder(destination)) {
|
||||
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
|
||||
return
|
||||
}
|
||||
|
||||
moved, err := s.documents.MoveDocument(r.Context(), record.Path, destination, req.BaseHash)
|
||||
if err != nil {
|
||||
var conflict *docs.DocumentMoveConflictError
|
||||
switch {
|
||||
case errors.As(err, &conflict):
|
||||
writeJSONWithStatus(w, http.StatusConflict, map[string]string{"error": conflict.Error()})
|
||||
case errors.Is(err, sql.ErrNoRows):
|
||||
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
|
||||
default:
|
||||
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
}
|
||||
return
|
||||
}
|
||||
writeJSONWithStatus(w, http.StatusOK, map[string]any{
|
||||
"status": "moved",
|
||||
"id": moved.ID,
|
||||
"path": moved.Path,
|
||||
"url": documentPageURL(moved.Path),
|
||||
})
|
||||
}
|
||||
|
||||
func unescapeDocumentRoutePath(path string) (string, error) {
|
||||
if path == "" {
|
||||
return "", nil
|
||||
@@ -1011,6 +1100,17 @@ func trimEditSuffix(path string) (string, bool) {
|
||||
return strings.TrimSuffix(path, "/edit"), true
|
||||
}
|
||||
|
||||
func requestPathMatchesDocument(requestPath, documentPath string) bool {
|
||||
requestPath = strings.Trim(requestPath, "/")
|
||||
if requestPath == "" {
|
||||
return documentPath == "index.md" || documentPath == "getting-started.md"
|
||||
}
|
||||
if strings.HasSuffix(requestPath, ".md") {
|
||||
return requestPath == documentPath
|
||||
}
|
||||
return requestPath+".md" == documentPath || requestPath+"/index.md" == documentPath
|
||||
}
|
||||
|
||||
func isContentAssetPath(path string) bool {
|
||||
extension := strings.ToLower(filepath.Ext(path))
|
||||
return extension != "" && extension != ".md"
|
||||
|
||||
@@ -215,7 +215,7 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
|
||||
return auth.ScopeDocsRead, true
|
||||
case strings.HasPrefix(path, "/api/watch"):
|
||||
return auth.ScopeDocsRead, true
|
||||
case path == "/docs" || strings.HasPrefix(path, "/docs/"):
|
||||
case path == "/docs" || strings.HasPrefix(path, "/docs/") || strings.HasPrefix(path, "/documents/"):
|
||||
return auth.ScopeDocsRead, true
|
||||
case strings.HasSuffix(path, "/edit"):
|
||||
return auth.ScopeDocsWrite, true
|
||||
|
||||
@@ -309,7 +309,7 @@ func (s *Server) documentRecordForRequestPath(r *http.Request, requestPath strin
|
||||
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
|
||||
clean += ".md"
|
||||
}
|
||||
return s.repository.GetDocumentByPath(r.Context(), clean)
|
||||
return s.repository.GetDocumentByPathOrAlias(r.Context(), clean)
|
||||
}
|
||||
|
||||
func (s *Server) canReadDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
|
||||
|
||||
@@ -150,6 +150,7 @@ func New(deps Dependencies) (http.Handler, error) {
|
||||
router.Post("/api/admin/workspace/sync", server.handleAdminWorkspaceSync)
|
||||
router.Get("/docs", server.handleDocsIndex)
|
||||
router.Get("/docs/*", server.handleDocument)
|
||||
router.Get("/documents/{id}", server.handleDocumentByID)
|
||||
router.Get("/api/documents", server.handleDocuments)
|
||||
router.Post("/api/documents/*", server.handleDocumentMutation)
|
||||
router.Get("/api/search", server.handleSearch)
|
||||
|
||||
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) {
|
||||
if (notification.resourceType !== 'document' || !notification.resourceId) return '';
|
||||
const path = notification.resourceId.replace(/^doc:/, '').split('/').map(encodeURIComponent).join('/');
|
||||
return '/docs/' + path;
|
||||
return '/documents/' + encodeURIComponent(notification.resourceId);
|
||||
}
|
||||
|
||||
// Listen for WebSocket notification events
|
||||
|
||||
@@ -298,6 +298,7 @@
|
||||
|
||||
const currentPath = documentShell ? documentShell.getAttribute("data-document-path") : null;
|
||||
const currentHash = documentShell ? documentShell.getAttribute("data-document-hash") : null;
|
||||
const currentDocumentID = documentShell ? documentShell.getAttribute("data-document-id") : null;
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
let reconnectTimer = null;
|
||||
|
||||
@@ -332,6 +333,11 @@
|
||||
|
||||
const change = payload.data;
|
||||
|
||||
if (change.type === "move" && currentDocumentID && change.documentId === currentDocumentID) {
|
||||
window.location.assign("/documents/" + encodeURIComponent(currentDocumentID));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isEditing && currentPath && change.path === currentPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -171,7 +171,7 @@ func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.P
|
||||
if accessErr != nil {
|
||||
return false
|
||||
}
|
||||
record, err := s.repository.GetDocumentByPath(r.Context(), file.Path)
|
||||
record, err := s.repository.GetDocumentByPathOrAlias(r.Context(), file.Path)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return s.canReadNewDocument(r, file.Path)
|
||||
@@ -190,7 +190,7 @@ func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.P
|
||||
if accessErr != nil {
|
||||
return false
|
||||
}
|
||||
record, err := s.repository.GetDocumentByPath(r.Context(), path)
|
||||
record, err := s.repository.GetDocumentByPathOrAlias(r.Context(), path)
|
||||
if err == nil && record != nil {
|
||||
ok, err := s.canWriteDocumentRecord(r, *record)
|
||||
if err != nil {
|
||||
|
||||
@@ -115,6 +115,7 @@
|
||||
<script src="/static/keyboard-nav.js" defer></script>
|
||||
<script src="/static/mobile.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/tags.js" defer></script>
|
||||
</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>
|
||||
<span>Edit</span>
|
||||
</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 }}
|
||||
<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>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
{{ define "document_edit_content" }}
|
||||
<section class="workspace-shell workspace-shell--document-edit">
|
||||
{{ 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">
|
||||
<ol>
|
||||
{{ $lastIdx := sub (len .Breadcrumbs) 1 }}
|
||||
|
||||
Reference in New Issue
Block a user