ops design and app

add more comments feature, more tags feature, and other frontend updates
This commit is contained in:
2026-06-09 18:27:59 -04:00
parent 1e939f307d
commit 04a1f2bb6d
33 changed files with 3276 additions and 113 deletions

View File

@@ -384,17 +384,36 @@ func passwordResetTokenFromEmail(t *testing.T, body string) string {
func (s *apiTestServer) token(t *testing.T, scopes ...auth.Scope) string {
t.Helper()
return s.tokenForUser(t, s.userID, scopes...)
}
created, err := s.auth.CreateAPIKey(context.Background(), s.userID, "test token", scopes, nil)
func (s *apiTestServer) tokenForUser(t *testing.T, userID string, scopes ...auth.Scope) string {
t.Helper()
created, err := s.auth.CreateAPIKey(context.Background(), userID, "test token", scopes, nil)
if err != nil {
t.Fatalf("create api token: %v", err)
}
return created.Token
}
func (s *apiTestServer) viewerUser(t *testing.T) auth.User {
t.Helper()
ctx := context.Background()
admin := auth.Principal{UserID: s.userID, Role: auth.RoleAdmin}
if _, err := s.auth.UpdateSignupsEnabled(ctx, admin, true); err != nil {
t.Fatalf("enable signups: %v", err)
}
user, err := s.auth.RegisterPasswordUser(ctx, "viewer@example.com", "Viewer", "correct horse battery staple", "viewer")
if err != nil {
t.Fatalf("create viewer: %v", err)
}
return user
}
func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
body, contentType := multipartBody(t, "file", `unsafe"name.pdf`, []byte("%PDF-1.4\n"))
request := httptest.NewRequest(http.MethodPost, "/api/uploads", body)
@@ -446,6 +465,41 @@ func TestAPIUploadStoresAndServesAttachment(t *testing.T) {
}
}
func TestAPIDocumentsArePrivateUntilResourceGrant(t *testing.T) {
server := newAPITestServer(t)
viewer := server.viewerUser(t)
token := server.tokenForUser(t, viewer.ID, auth.ScopeDocsRead)
listRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
listRequest.Header.Set("Authorization", "Bearer "+token)
listRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(listRecorder, listRequest)
if listRecorder.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String())
}
if strings.Contains(listRecorder.Body.String(), "hello.md") {
t.Fatalf("private document leaked before grant: %s", listRecorder.Body.String())
}
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: auth.PermissionRead},
}); err != nil {
t.Fatalf("grant root folder read: %v", err)
}
listRequest = httptest.NewRequest(http.MethodGet, "/api/documents", nil)
listRequest.Header.Set("Authorization", "Bearer "+token)
listRecorder = httptest.NewRecorder()
server.handler.ServeHTTP(listRecorder, listRequest)
if listRecorder.Code != http.StatusOK {
t.Fatalf("documents after grant status = %d, want %d; body=%s", listRecorder.Code, http.StatusOK, listRecorder.Body.String())
}
if !strings.Contains(listRecorder.Body.String(), "hello.md") {
t.Fatalf("document missing after folder grant: %s", listRecorder.Body.String())
}
}
func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
@@ -463,6 +517,41 @@ func TestAPIUploadRejectsTokenWithoutDocsWriteScope(t *testing.T) {
}
}
func TestAPISyncSnapshotInheritsResourcePermissions(t *testing.T) {
server := newAPITestServer(t)
viewer := server.viewerUser(t)
token := server.tokenForUser(t, viewer.ID, auth.ScopeSyncRead, auth.ScopeSyncWrite)
initRequest := jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-1"})
initRequest.Header.Set("Authorization", "Bearer "+token)
initRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(initRecorder, initRequest)
if initRecorder.Code != http.StatusOK {
t.Fatalf("sync init status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
}
if strings.Contains(initRecorder.Body.String(), "hello.md") {
t.Fatalf("private sync snapshot leaked before grant: %s", initRecorder.Body.String())
}
admin := auth.Principal{UserID: server.userID, Role: auth.RoleAdmin}
if _, err := server.auth.UpdateResourcePermissions(context.Background(), admin, auth.ResourceFolder, "", []auth.ResourcePermissionUpdate{
{UserID: viewer.ID, Permission: auth.PermissionRead},
}); err != nil {
t.Fatalf("grant root folder read: %v", err)
}
initRequest = jsonRequest(http.MethodPost, "/api/sync/init", map[string]string{"deviceId": "device-2"})
initRequest.Header.Set("Authorization", "Bearer "+token)
initRecorder = httptest.NewRecorder()
server.handler.ServeHTTP(initRecorder, initRequest)
if initRecorder.Code != http.StatusOK {
t.Fatalf("sync init after grant status = %d, want %d; body=%s", initRecorder.Code, http.StatusOK, initRecorder.Body.String())
}
if !strings.Contains(initRecorder.Body.String(), "hello.md") {
t.Fatalf("sync snapshot missing document after folder grant: %s", initRecorder.Body.String())
}
}
func TestAPISyncInitAndContentFetchUseBearerScopes(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeSyncRead, auth.ScopeSyncWrite)
@@ -575,10 +664,12 @@ func TestAPISyncDeltaReturnsServerChanges(t *testing.T) {
func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
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, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
@@ -637,9 +728,44 @@ func TestAPIDocumentSaveReturnsConflictForStaleBaseHash(t *testing.T) {
}
}
func TestAPIDocumentCreateGrantsCreatorAdminPermission(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
save := jsonRequest(http.MethodPost, "/api/documents/inbox/new-note", map[string]string{
"content": "# New Note\n\nCreated in browser",
})
save.Header.Set("Authorization", "Bearer "+token)
saveRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(saveRecorder, save)
if saveRecorder.Code != http.StatusOK {
t.Fatalf("save status = %d, want %d; body=%s", saveRecorder.Code, http.StatusOK, saveRecorder.Body.String())
}
permissions := httptest.NewRequest(http.MethodGet, "/api/permissions?resourceType=document&resourceId=inbox/new-note.md", nil)
permissions.Header.Set("Authorization", "Bearer "+token)
permissionsRecorder := httptest.NewRecorder()
server.handler.ServeHTTP(permissionsRecorder, permissions)
if permissionsRecorder.Code != http.StatusOK {
t.Fatalf("permissions status = %d, want %d; body=%s", permissionsRecorder.Code, http.StatusOK, permissionsRecorder.Body.String())
}
var payload struct {
Permissions []auth.ResourcePermission `json:"permissions"`
}
if err := json.Unmarshal(permissionsRecorder.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode permissions: %v", err)
}
if len(payload.Permissions) != 1 || payload.Permissions[0].UserID != server.userID || payload.Permissions[0].Permission != auth.PermissionAdmin {
t.Fatalf("permissions = %#v, want creator admin grant", payload.Permissions)
}
}
func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
ctx := context.Background()
nestedDir := filepath.Join(server.root, "content", "guide")
@@ -663,8 +789,10 @@ func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
documentsRequest.Header.Set("Authorization", "Bearer "+token)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}
@@ -675,7 +803,7 @@ func TestAPIDocumentArchiveSupportsNestedPaths(t *testing.T) {
func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
server := newAPITestServer(t)
token := server.token(t, auth.ScopeDocsWrite)
token := server.token(t, auth.ScopeDocsRead, auth.ScopeDocsWrite)
ctx := context.Background()
if err := os.WriteFile(filepath.Join(server.root, "content", "@dani.md"), []byte("# @dani\n\nProfile"), 0o644); err != nil {
@@ -695,8 +823,10 @@ func TestAPIDocumentArchiveUnescapesSpecialCharacters(t *testing.T) {
t.Fatalf("archive status = %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
}
documentsRequest := httptest.NewRequest(http.MethodGet, "/api/documents", nil)
documentsRequest.Header.Set("Authorization", "Bearer "+token)
documents := httptest.NewRecorder()
server.handler.ServeHTTP(documents, httptest.NewRequest(http.MethodGet, "/api/documents", nil))
server.handler.ServeHTTP(documents, documentsRequest)
if documents.Code != http.StatusOK {
t.Fatalf("documents status = %d, want %d", documents.Code, http.StatusOK)
}

View File

@@ -16,10 +16,23 @@ func (s *Server) handleListComments(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document_id is required"})
return
}
document, err := s.repository.GetDocumentByID(r.Context(), documentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canRead {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
anchorHash := r.URL.Query().Get("anchor_hash")
var comments []collaboration.Comment
var err error
if anchorHash != "" {
comments, err = s.collaboration.ListCommentsByAnchor(r.Context(), documentID, anchorHash)
} else {
@@ -45,6 +58,20 @@ func (s *Server) handleCreateComment(w http.ResponseWriter, r *http.Request) {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
document, err := s.repository.GetDocumentByID(r.Context(), input.DocumentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canWrite, err := s.canWriteDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
comment, err := s.collaboration.CreateComment(r.Context(), principal, input)
if err != nil {
@@ -63,6 +90,25 @@ func (s *Server) handleResolveComment(w http.ResponseWriter, r *http.Request) {
}
commentID := chi.URLParam(r, "id")
comment, err := s.collaboration.GetComment(r.Context(), commentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "comment not found"})
return
}
document, err := s.repository.GetDocumentByID(r.Context(), comment.DocumentID)
if err != nil {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
canWrite, err := s.canWriteDocumentRecord(r, *document)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "comment not found"})
return
}
if err := s.collaboration.ResolveComment(r.Context(), principal, commentID); err != nil {
s.logger.Warn("resolve comment", "error", err)
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})

View File

@@ -45,7 +45,10 @@ type documentData struct {
Hash string
HTML template.HTML
Breadcrumbs []breadcrumb
CanRead bool
CanWrite bool
CanAdmin bool
CanComment bool
}
type documentEditData struct {
@@ -70,11 +73,13 @@ type errorData struct {
}
type browserData struct {
Columns []browserColumn
Columns []browserColumn
CanWrite bool
}
type browserColumn struct {
Title string
Path string
Items []browserItem
}
@@ -95,19 +100,32 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
return
}
tag := r.URL.Query().Get("tag")
if tag != "" {
s.handleTagPage(w, r, tag)
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
browser := buildBrowser(items, activeFolder)
browser.CanWrite = s.canWriteFolder(r, activeFolder)
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire",
BodyClass: "page-index",
BodyTemplate: "index_content",
Data: indexData{
Browser: buildBrowser(items, activeFolder),
Browser: browser,
},
})
}
@@ -120,6 +138,11 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
if err != nil {
s.logger.Warn("search failed", "error", err)
}
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query,
@@ -135,6 +158,39 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
})
}
func (s *Server) handleTagPage(w http.ResponseWriter, r *http.Request, tag string) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results, err := s.repository.SearchDocumentsByTag(ctx, tag)
if err != nil {
s.logger.Warn("tag search failed", "error", err)
}
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
browser := buildTagBrowser(results, tag)
browser.CanWrite = s.canWriteFolder(r, "")
s.renderTemplate(w, r, http.StatusOK, "tag.gohtml", layoutData{
Title: "Tag: " + tag,
BodyClass: "page-tag",
BodyTemplate: "tag_content",
Data: struct {
Tag string
Results []docs.SearchResult
Browser browserData
}{
Tag: tag,
Results: results,
Browser: browser,
},
})
}
func (s *Server) handleDocsIndex(w http.ResponseWriter, r *http.Request) {
s.renderDocumentPage(w, r, "")
}
@@ -220,21 +276,40 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
if !canWrite {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canWrite := s.canWriteDocuments(r)
browser := buildBrowser(items, page.Path)
browser.CanWrite = canWrite
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title,
BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content",
Data: documentEditData{
Browser: buildBrowser(items, page.Path),
Browser: browser,
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
@@ -256,28 +331,60 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
record, err := s.repository.GetDocumentByPath(r.Context(), page.Path)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
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
}
canWrite, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canAdmin, err := s.canAdminDocumentRecord(r, *record)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err := s.documents.ListDocuments(r.Context())
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
items, err = s.filterReadableDocuments(r, items)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
canWrite := s.canWriteDocuments(r)
browser := buildBrowser(items, page.Path)
browser.CanWrite = canWrite
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
BodyClass: "page-document",
BodyTemplate: "document_content",
Data: documentData{
Browser: buildBrowser(items, page.Path),
Browser: browser,
Title: page.Title,
Path: page.Path,
Tags: page.Tags,
Hash: page.Hash,
HTML: template.HTML(page.HTML),
Breadcrumbs: buildBreadcrumbs(page.Path),
CanRead: canRead,
CanWrite: canWrite,
CanAdmin: canAdmin,
CanComment: canWrite,
},
})
}
@@ -333,21 +440,35 @@ func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
results, err := s.repository.SearchDocuments(ctx, query)
var results []docs.SearchResult
var err error
tag := r.URL.Query().Get("tag")
if tag != "" {
results, err = s.repository.SearchDocumentsByTag(ctx, tag)
} else {
query := r.URL.Query().Get("q")
if query == "" {
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
results, err = s.repository.SearchDocuments(ctx, query)
}
if err != nil {
s.logger.Warn("search failed", "error", err)
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
results, err = s.filterReadableSearchResults(r, results)
if err != nil {
s.logger.Warn("filter search failed", "error", err)
writeJSON(w, http.StatusOK, map[string]interface{}{"results": []any{}})
return
}
writeJSON(w, http.StatusOK, map[string]interface{}{"results": results})
}
@@ -421,7 +542,17 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusInternalServerError, "inspect existing document")
return
}
createdDocument := existing == nil
if existing != nil {
canWriteExisting, err := s.canWriteDocumentRecord(r, *existing)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "check document permissions")
return
}
if !canWriteExisting {
s.renderError(w, r, http.StatusForbidden, "permission denied")
return
}
switch duplicateAction {
case "overwrite":
case "rename":
@@ -430,6 +561,7 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
s.renderError(w, r, http.StatusInternalServerError, "choose document name")
return
}
createdDocument = true
case "reuse":
writeUploadSuccess(w, http.StatusOK, existing.CurrentHash, contentType, "document", documentPageURL(existing.Path))
return
@@ -437,12 +569,21 @@ func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) {
writeUploadConflict(w, "document", existing.CurrentHash, existing.Path, documentPageURL(existing.Path))
return
}
} else if !s.canWriteNewDocument(r, path) {
s.renderError(w, r, http.StatusForbidden, "permission denied")
return
}
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), path, string(content), "")
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, "persist uploaded document")
return
}
if createdDocument {
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
s.renderError(w, r, http.StatusInternalServerError, "grant document owner permission")
return
}
}
documentPath = page.Path
uploadURL = documentPageURL(page.Path)
uploadKind = "document"
@@ -643,6 +784,14 @@ func (s *Server) handleAttachmentsList(w http.ResponseWriter, r *http.Request) {
uploadURL := "/attachments/" + record.Hash
uploadKind := "attachment"
if record.DocumentPath != "" {
document, err := s.repository.GetDocumentByPath(r.Context(), record.DocumentPath)
if err != nil || document == nil {
continue
}
canRead, err := s.canReadDocumentRecord(r, *document)
if err != nil || !canRead {
continue
}
uploadURL = documentPageURL(record.DocumentPath)
uploadKind = "document"
}
@@ -667,6 +816,11 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
records, err = s.filterReadableDocuments(r, records)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
results := make([]map[string]any, 0, len(records))
for _, record := range records {
@@ -674,6 +828,7 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
"path": record.Path,
"title": record.Title,
"hash": record.CurrentHash,
"tags": record.Tags,
})
}
@@ -717,6 +872,27 @@ func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request,
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
createdDocument := false
if existing, err := s.documentRecordForRequestPath(r, pagePath); err == nil && existing != nil {
canWrite, err := s.canWriteDocumentRecord(r, *existing)
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if !canWrite {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
} else if errors.Is(err, sql.ErrNoRows) {
createdDocument = true
if !s.canWriteNewDocument(r, pagePath) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
} else if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
page, err := s.documents.SaveSourcePageWithBaseHash(r.Context(), pagePath, req.Content, req.BaseHash)
if err != nil {
@@ -739,6 +915,12 @@ func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request,
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if createdDocument {
if err := s.grantDocumentCreatorAdmin(r, page.Path); err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": "grant document owner permission"})
return
}
}
writeJSONWithStatus(w, http.StatusOK, map[string]any{
"status": "saved",
@@ -758,19 +940,29 @@ func writeJSONWithStatus(w http.ResponseWriter, status int, payload any) {
_ = json.NewEncoder(w).Encode(payload)
}
func (s *Server) canWriteDocuments(r *http.Request) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
return auth.Allows(principal, auth.ScopeDocsWrite)
}
func (s *Server) handleDocumentArchivePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
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 {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "document not found"})
return
}
if err := s.documents.ArchiveDocument(r.Context(), pagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -789,6 +981,11 @@ func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Reques
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
principal, _ := principalFromContext(r.Context())
if principal.Role != auth.RoleAdmin {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "permission denied"})
return
}
if err := s.documents.RestoreDocument(r.Context(), pagePath); err != nil {
if errors.Is(err, sql.ErrNoRows) {
@@ -845,16 +1042,39 @@ func buildBrowser(records []docs.DocumentRecord, activePath string) browserData
for _, prefix := range prefixes {
column := browserColumn{
Title: columnTitle(prefix),
Path: prefix,
Items: buildBrowserItems(paths, titleByPath, indexByFolder, prefix, activePath),
}
if len(column.Items) > 0 {
columns = append(columns, column)
}
columns = append(columns, column)
}
return browserData{Columns: columns}
}
func buildTagBrowser(results []docs.SearchResult, tag string) browserData {
items := make([]browserItem, 0, len(results))
for _, result := range results {
items = append(items, browserItem{
Name: displayDocumentName(result.Path, result.Title),
Path: result.Path,
URL: "/docs/" + strings.TrimSuffix(result.Path, ".md"),
})
}
sort.Slice(items, func(i, j int) bool {
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
})
return browserData{
Columns: []browserColumn{
{
Title: "#" + tag,
Path: "",
Items: items,
},
},
}
}
func buildBrowserItems(paths []string, titleByPath map[string]string, indexByFolder map[string]string, prefix string, activePath string) []browserItem {
seenFolders := make(map[string]browserItem)
files := make([]browserItem, 0)

View File

@@ -14,6 +14,7 @@ import (
"testing"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/config"
"github.com/tim/cairnquire/apps/server/internal/database"
"github.com/tim/cairnquire/apps/server/internal/docs"
@@ -211,8 +212,13 @@ func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
t.Fatalf("document path = %q, want %q", attachment.DocumentPath, tt.wantPath)
}
listRequest := httptest.NewRequest(http.MethodGet, "/api/attachments", nil)
listRequest = listRequest.WithContext(withPrincipal(listRequest.Context(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}))
listRecorder := httptest.NewRecorder()
server.handleAttachmentsList(listRecorder, httptest.NewRequest(http.MethodGet, "/api/attachments", nil))
server.handleAttachmentsList(listRecorder, listRequest)
var listPayload struct {
Attachments []struct {
Kind string `json:"kind"`
@@ -233,6 +239,17 @@ func TestHandleUploadPromotesReadableFilesToDocuments(t *testing.T) {
if !bytes.Contains([]byte(page.HTML), []byte(tt.wantHTML)) {
t.Fatalf("rendered HTML = %q, want to contain %q", page.HTML, tt.wantHTML)
}
grants, err := server.auth.ListResourcePermissions(context.Background(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}, auth.ResourceDocument, tt.wantPath)
if err != nil {
t.Fatalf("list promoted document permissions: %v", err)
}
if len(grants) != 1 || grants[0].UserID != "user:test-admin" || grants[0].Permission != auth.PermissionAdmin {
t.Fatalf("promoted document grants = %#v, want creator admin grant", grants)
}
})
}
}
@@ -376,11 +393,25 @@ func setupUploadTestServer(t *testing.T) (*Server, string) {
sourceDir := t.TempDir()
repository := docs.NewRepository(db)
documents := docs.NewService(sourceDir, contentStore, markdown.NewRenderer(), repository, slog.Default())
authRepo := auth.NewRepository(db)
authService, err := auth.NewService(authRepo, "http://localhost")
if err != nil {
t.Fatalf("create auth service: %v", err)
}
if _, err := authRepo.UpsertUser(context.Background(), auth.User{
ID: "user:test-admin",
Email: "test-admin@example.com",
DisplayName: "Test Admin",
Role: auth.RoleAdmin,
}); err != nil {
t.Fatalf("create test admin user: %v", err)
}
return &Server{
documents: documents,
repository: repository,
contentStore: contentStore,
auth: authService,
}, sourceDir
}
@@ -412,6 +443,10 @@ func multipartUploadRequest(t *testing.T, filename string, content []byte, folde
request := httptest.NewRequest(http.MethodPost, "/api/uploads", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
request = request.WithContext(withPrincipal(request.Context(), auth.Principal{
UserID: "user:test-admin",
Role: auth.RoleAdmin,
}))
return request
}

View File

@@ -67,10 +67,14 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
return
}
if !ok {
if r.Method == http.MethodGet && !strings.HasPrefix(r.URL.Path, "/api/") {
http.Redirect(w, r, "/login?next="+urlQueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
return
}
writeJSONWithStatus(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
if !auth.Allows(principal, required) {
if !allowsProtectedRoute(principal, required) {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
return
}
@@ -78,6 +82,20 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
})
}
func allowsProtectedRoute(principal auth.Principal, required auth.Scope) bool {
if required == auth.ScopeAdmin {
return auth.Allows(principal, required)
}
if len(principal.Scopes) > 0 {
return auth.HasScope(principal.Scopes, required)
}
return principal.UserID != ""
}
func urlQueryEscape(value string) string {
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
}
func (s *Server) setupMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
settings, err := s.auth.GetInstanceSettings(r.Context())
@@ -151,6 +169,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
path := r.URL.Path
method := r.Method
switch {
case path == "/" || (path == "/permissions" && method == http.MethodGet):
return auth.ScopeDocsRead, true
case path == "/permissions" && method == http.MethodPost:
return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/auth/"):
if path == "/api/auth/me" || path == "/api/auth/logout" || path == "/api/auth/password/change" || path == "/api/auth/account" {
return auth.ScopeDocsRead, true
@@ -169,6 +191,10 @@ func requiredScopeForPath(r *http.Request) (auth.Scope, bool) {
return auth.ScopeAdmin, true
case strings.HasPrefix(path, "/api/documents/") && method != http.MethodGet:
return auth.ScopeDocsWrite, true
case path == "/api/documents" || path == "/api/search" || (path == "/api/permissions" && method == http.MethodGet):
return auth.ScopeDocsRead, true
case path == "/api/permissions" && method == http.MethodPatch:
return auth.ScopeDocsWrite, true
case strings.HasPrefix(path, "/api/uploads"):
return auth.ScopeDocsWrite, true
case path == "/api/attachments":
@@ -186,6 +212,8 @@ 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/"):
return auth.ScopeDocsRead, true
case strings.HasSuffix(path, "/edit"):
return auth.ScopeDocsWrite, true
default:

View File

@@ -0,0 +1,400 @@
package httpserver
import (
"database/sql"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/docs"
)
type permissionPageData struct {
ResourceType string
ResourceID string
Title string
Users []permissionUserData
}
type permissionUserData struct {
ID string
Email string
DisplayName string
Role string
CreatedAt string
LastSeenAt string
Permission string
PermissionText string
GrantCreatedAt string
GrantedBy string
GrantSummary string
}
func (s *Server) handlePermissionsPage(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
resourceType, resourceID := permissionResourceFromRequest(r)
if resourceType == "" {
resourceType = auth.ResourceDocument
}
if resourceType == auth.ResourceDocument {
resourceID = strings.TrimSpace(resourceID)
if resourceID == "" {
resourceID = strings.TrimSpace(r.URL.Query().Get("path"))
}
}
if resourceID == "" && resourceType != auth.ResourceGlobal && resourceType != auth.ResourceFolder {
s.renderError(w, r, http.StatusBadRequest, "resource is required")
return
}
if !s.canAdminResource(r, resourceType, resourceID, nil) {
s.renderError(w, r, http.StatusNotFound, "document not found")
return
}
data, err := s.buildPermissionPageData(r, principal, resourceType, resourceID)
if err != nil {
s.renderError(w, r, http.StatusInternalServerError, err.Error())
return
}
s.renderTemplate(w, r, http.StatusOK, "permissions.gohtml", layoutData{
Title: "Permissions: " + data.Title,
BodyClass: "page-permissions",
BodyTemplate: "permissions_content",
Data: data,
})
}
func (s *Server) handlePermissionsForm(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
if err := r.ParseForm(); err != nil {
s.renderError(w, r, http.StatusBadRequest, "invalid permissions form")
return
}
resourceType := auth.ResourceType(r.FormValue("resourceType"))
resourceID := r.FormValue("resourceId")
updates := permissionUpdatesFromForm(r)
if _, err := s.auth.UpdateResourcePermissions(r.Context(), principal, resourceType, resourceID, updates); err != nil {
s.renderError(w, r, http.StatusBadRequest, err.Error())
return
}
http.Redirect(w, r, "/permissions?resourceType="+queryEscape(string(resourceType))+"&resourceId="+queryEscape(resourceID), http.StatusSeeOther)
}
func (s *Server) handlePermissionsAPI(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
s.handlePermissionsGetAPI(w, r)
case http.MethodPatch:
s.handlePermissionsPatchAPI(w, r)
default:
writeJSONWithStatus(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
}
}
func (s *Server) handlePermissionsGetAPI(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
resourceType, resourceID := permissionResourceFromRequest(r)
if !s.canAdminResource(r, resourceType, resourceID, nil) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "resource not found"})
return
}
permissions, err := s.auth.ListResourcePermissions(r.Context(), principal, resourceType, resourceID)
if err != nil {
writeJSONWithStatus(w, http.StatusForbidden, map[string]string{"error": err.Error()})
return
}
users, err := s.auth.ListUsers(r.Context())
if err != nil {
writeJSONWithStatus(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"resourceType": resourceType,
"resourceId": resourceID,
"permissions": permissions,
"users": users,
})
}
func (s *Server) handlePermissionsPatchAPI(w http.ResponseWriter, r *http.Request) {
principal, _ := requirePrincipal(r)
var req struct {
ResourceType auth.ResourceType `json:"resourceType"`
ResourceID string `json:"resourceId"`
Users []auth.ResourcePermissionUpdate `json:"users"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"})
return
}
if !s.canAdminResource(r, req.ResourceType, req.ResourceID, nil) {
writeJSONWithStatus(w, http.StatusNotFound, map[string]string{"error": "resource not found"})
return
}
permissions, err := s.auth.UpdateResourcePermissions(r.Context(), principal, req.ResourceType, req.ResourceID, req.Users)
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"permissions": permissions})
}
func (s *Server) buildPermissionPageData(r *http.Request, principal auth.Principal, resourceType auth.ResourceType, resourceID string) (permissionPageData, error) {
users, err := s.auth.ListUsers(r.Context())
if err != nil {
return permissionPageData{}, err
}
grants, err := s.auth.ListResourcePermissions(r.Context(), principal, resourceType, resourceID)
if err != nil {
return permissionPageData{}, err
}
grantsByUser := make(map[string]auth.Permission, len(grants))
grantMetaByUser := make(map[string]auth.ResourcePermission, len(grants))
for _, grant := range grants {
grantsByUser[grant.UserID] = grant.Permission
grantMetaByUser[grant.UserID] = grant
}
usersByID := make(map[string]auth.User, len(users))
for _, user := range users {
usersByID[user.ID] = user
}
result := permissionPageData{
ResourceType: string(resourceType),
ResourceID: resourceID,
Title: permissionResourceTitle(resourceType, resourceID),
Users: make([]permissionUserData, 0, len(users)),
}
for _, user := range users {
if user.Disabled {
continue
}
permission := grantsByUser[user.ID]
grant := grantMetaByUser[user.ID]
result.Users = append(result.Users, permissionUserData{
ID: user.ID,
Email: user.Email,
DisplayName: user.DisplayName,
Role: string(user.Role),
CreatedAt: formatPermissionTime(user.CreatedAt),
LastSeenAt: formatPermissionTime(user.LastSeenAt),
Permission: string(permission),
PermissionText: permissionLabel(permission),
GrantCreatedAt: formatPermissionTime(grant.CreatedAt),
GrantedBy: grantActorLabel(grant.GrantedBy, usersByID),
GrantSummary: grantSummary(grant, usersByID),
})
}
return result, nil
}
func permissionResourceTitle(resourceType auth.ResourceType, resourceID string) string {
switch resourceType {
case auth.ResourceDocument:
return "Page " + resourceID
case auth.ResourceFolder:
if resourceID == "" {
return "Root folder"
}
return "Folder " + resourceID
case auth.ResourceCollection:
return "Collection " + resourceID
default:
return string(resourceType)
}
}
func permissionResourceFromRequest(r *http.Request) (auth.ResourceType, string) {
return auth.ResourceType(r.URL.Query().Get("resourceType")), r.URL.Query().Get("resourceId")
}
func permissionUpdatesFromForm(r *http.Request) []auth.ResourcePermissionUpdate {
var updates []auth.ResourcePermissionUpdate
for key, values := range r.PostForm {
if !strings.HasPrefix(key, "permission:") || len(values) == 0 {
continue
}
userID := strings.TrimPrefix(key, "permission:")
permission := auth.Permission(values[0])
updates = append(updates, auth.ResourcePermissionUpdate{UserID: userID, Permission: permission})
}
return updates
}
func permissionLabel(permission auth.Permission) string {
switch permission {
case auth.PermissionRead:
return "View"
case auth.PermissionWrite:
return "Edit"
case auth.PermissionAdmin:
return "Admin"
default:
return "No access"
}
}
func grantSummary(grant auth.ResourcePermission, usersByID map[string]auth.User) string {
if grant.ID == "" {
return "No explicit grant"
}
parts := []string{"Explicit " + permissionLabel(grant.Permission)}
if grant.CreatedAt.IsZero() {
return strings.Join(parts, " · ")
}
parts = append(parts, "granted "+formatPermissionTime(grant.CreatedAt))
if actor := grantActorLabel(grant.GrantedBy, usersByID); actor != "" {
parts = append(parts, "by "+actor)
}
return strings.Join(parts, " · ")
}
func grantActorLabel(userID string, usersByID map[string]auth.User) string {
if userID == "" {
return ""
}
if user, ok := usersByID[userID]; ok {
if user.DisplayName != "" {
return user.DisplayName
}
return user.Email
}
return userID
}
func formatPermissionTime(value time.Time) string {
if value.IsZero() {
return "Never"
}
return value.Local().Format("Jan 2, 2006 3:04 PM")
}
func (s *Server) filterReadableDocuments(r *http.Request, records []docs.DocumentRecord) ([]docs.DocumentRecord, error) {
filtered := make([]docs.DocumentRecord, 0, len(records))
for _, record := range records {
if ok, err := s.canReadDocumentRecord(r, record); err != nil {
return nil, err
} else if ok {
filtered = append(filtered, record)
}
}
return filtered, nil
}
func (s *Server) filterReadableSearchResults(r *http.Request, results []docs.SearchResult) ([]docs.SearchResult, error) {
filtered := make([]docs.SearchResult, 0, len(results))
for _, result := range results {
record, err := s.repository.GetDocumentByPath(r.Context(), result.Path)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
continue
}
return nil, err
}
if ok, err := s.canReadDocumentRecord(r, *record); err != nil {
return nil, err
} else if ok {
filtered = append(filtered, result)
}
}
return filtered, nil
}
func (s *Server) documentRecordForRequestPath(r *http.Request, requestPath string) (*docs.DocumentRecord, error) {
clean := strings.Trim(strings.TrimSpace(requestPath), "/")
if clean == "" {
return nil, sql.ErrNoRows
}
if !strings.HasSuffix(strings.ToLower(clean), ".md") {
clean += ".md"
}
return s.repository.GetDocumentByPath(r.Context(), clean)
}
func (s *Server) canReadDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionRead)
}
func (s *Server) canWriteDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionWrite)
}
func (s *Server) canAdminDocumentRecord(r *http.Request, record docs.DocumentRecord) (bool, error) {
return s.documentPermissionAllows(r, record.Path, record.Tags, auth.PermissionAdmin)
}
func (s *Server) documentPermissionAllows(r *http.Request, path string, tags []string, required auth.Permission) (bool, error) {
principal, ok := principalFromContext(r.Context())
if !ok {
return false, nil
}
if principal.Role == auth.RoleAdmin {
return true, nil
}
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceDocument, path, tags)
if err != nil {
return false, err
}
return auth.PermissionAllows(permission, required), nil
}
func (s *Server) canAdminResource(r *http.Request, resourceType auth.ResourceType, resourceID string, tags []string) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
if principal.Role == auth.RoleAdmin {
return true
}
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, resourceType, resourceID, tags)
return err == nil && auth.PermissionAllows(permission, auth.PermissionAdmin)
}
func (s *Server) canWriteNewDocument(r *http.Request, pagePath string) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
if principal.Role == auth.RoleAdmin {
return true
}
folder := strings.TrimSuffix(pagePath, "/"+lastPathSegment(pagePath))
return s.canWriteFolder(r, folder)
}
func (s *Server) canWriteFolder(r *http.Request, folder string) bool {
principal, ok := principalFromContext(r.Context())
if !ok {
return false
}
if principal.Role == auth.RoleAdmin {
return true
}
permission, err := s.auth.EffectiveResourcePermission(r.Context(), principal, auth.ResourceFolder, folder, nil)
return err == nil && auth.PermissionAllows(permission, auth.PermissionWrite)
}
func (s *Server) grantDocumentCreatorAdmin(r *http.Request, path string) error {
principal, ok := principalFromContext(r.Context())
if !ok || principal.UserID == "" {
return nil
}
return s.auth.EnsureResourcePermission(r.Context(), principal, auth.ResourceDocument, path, auth.ResourcePermissionUpdate{
UserID: principal.UserID,
Permission: auth.PermissionAdmin,
})
}
func lastPathSegment(path string) string {
trimmed := strings.Trim(path, "/")
if trimmed == "" {
return ""
}
parts := strings.Split(trimmed, "/")
return parts[len(parts)-1]
}
func queryEscape(value string) string {
return strings.NewReplacer("%", "%25", " ", "%20", "?", "%3F", "&", "%26", "=", "%3D", "#", "%23").Replace(value)
}

View File

@@ -107,6 +107,8 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/password-reset", server.handlePasswordResetPage)
router.Get("/account", server.handleAccountPage)
router.Get("/account/tokens/new", server.handleTokenCreatePage)
router.Get("/permissions", server.handlePermissionsPage)
router.Post("/permissions", server.handlePermissionsForm)
router.Get("/device/verify", server.handleDeviceVerifyPage)
router.Get("/health", server.handleHealth)
router.Get("/healthz", server.handleHealthz)
@@ -149,6 +151,8 @@ func New(deps Dependencies) (http.Handler, error) {
router.Get("/api/documents", server.handleDocuments)
router.Post("/api/documents/*", server.handleDocumentMutation)
router.Get("/api/search", server.handleSearch)
router.Get("/api/permissions", server.handlePermissionsAPI)
router.Patch("/api/permissions", server.handlePermissionsAPI)
router.Post("/api/uploads", server.handleUpload)
router.Get("/api/attachments", server.handleAttachmentsList)
router.Get("/attachments/{hash}", server.handleAttachment)

View File

@@ -3,15 +3,69 @@
const documentPath = document.querySelector('[data-document-path]')?.dataset.documentPath;
const documentHash = document.querySelector('[data-document-hash]')?.dataset.documentHash;
const canComment = document.querySelector('[data-document-path]')?.dataset.canComment === 'true';
const article = document.querySelector('[data-document-path]');
const workspaceShell = article?.closest('.workspace-shell--document');
const commentsAsideList = document.querySelector('[data-comments-aside-list]');
const stripTrack = document.querySelector('[data-comments-strip-track]');
const documentId = documentPath ? 'doc:' + documentPath.replace(/\.md$/, '') : null;
const commentToggle = document.querySelector('[data-comment-toggle]');
const commentVisibilityKey = documentId ? `cairnquire:document-comments:${documentId}` : null;
function readCommentVisibility() {
if (!commentVisibilityKey) return false;
try {
return window.localStorage.getItem(commentVisibilityKey) === 'hidden';
} catch (err) {
return false;
}
}
if (!documentId) return;
// Hash a paragraph's text content for anchoring
function syncVisibility() {
const hidden = readCommentVisibility();
if (article) article.classList.toggle('comments-hidden', hidden);
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
}
syncVisibility();
function commentsHidden() {
return Boolean(workspaceShell?.classList.contains('comments-visible')) === false;
}
function setCommentsHidden(hidden) {
if (article) article.classList.toggle('comments-hidden', hidden);
if (workspaceShell) workspaceShell.classList.toggle('comments-visible', !hidden);
if (commentVisibilityKey) {
try {
window.localStorage.setItem(commentVisibilityKey, hidden ? 'hidden' : 'visible');
} catch (err) {
// Ignore persistence failures
}
}
if (commentToggle) {
commentToggle.setAttribute('aria-pressed', hidden ? 'false' : 'true');
commentToggle.setAttribute('aria-label', hidden ? 'Show comments' : 'Hide comments');
commentToggle.title = hidden ? 'Show comments' : 'Hide comments';
}
if (!hidden) {
renderCommentsAside();
updateStripMarkers();
}
}
if (commentToggle) {
commentToggle.setAttribute('aria-pressed', commentsHidden() ? 'false' : 'true');
commentToggle.addEventListener('click', () => {
setCommentsHidden(!commentsHidden());
});
}
function hashParagraph(el) {
const text = (el.textContent || '').trim();
if (!text) return null;
// Simple hash: first 16 chars of base64 of char codes (deterministic)
let hash = 0;
for (let i = 0; i < text.length; i++) {
const char = text.charCodeAt(i);
@@ -24,29 +78,46 @@
function addCommentButton(el, hash) {
const btn = document.createElement('button');
btn.className = 'comment-anchor-btn';
btn.type = 'button';
btn.title = 'Add comment';
btn.innerHTML = '+';
btn.setAttribute('aria-label', 'Add comment');
btn.innerHTML = `
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
<path d="M12 8v4"></path>
<path d="M10 10h4"></path>
</svg>
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
<span class="sr-only">Add comment</span>
`;
btn.addEventListener('click', () => promptComment(el, hash));
el.appendChild(btn);
}
function updateCommentButtonState(el, count) {
const btn = el.querySelector('.comment-anchor-btn');
if (!btn) return;
const hasComments = count > 0;
btn.classList.toggle('comment-anchor-btn--has-comments', hasComments);
btn.setAttribute('aria-label', hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment');
btn.title = hasComments ? `${count} comment${count === 1 ? '' : 's'}` : 'Add comment';
const countNode = btn.querySelector('.comment-anchor-count');
if (countNode) {
countNode.hidden = !hasComments;
countNode.textContent = count > 99 ? '99+' : String(count);
}
}
async function promptComment(el, anchorHash) {
const text = window.prompt('Comment on this paragraph:');
if (!text || !text.trim()) return;
try {
const res = await fetch('/api/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
documentId,
versionHash: documentHash,
content: text.trim(),
anchorHash,
}),
body: JSON.stringify({ documentId, versionHash: documentHash, content: text.trim(), anchorHash }),
});
if (!res.ok) throw new Error(await res.text());
// Refresh comments display
await loadCommentsForAnchor(el, anchorHash);
} catch (err) {
window.alert('Failed to post comment: ' + err.message);
@@ -65,11 +136,17 @@
}
function renderComments(el, comments) {
// Remove existing comment list
const existing = el.querySelector('.comment-list');
if (existing) existing.remove();
if (!comments.length) return;
updateCommentButtonState(el, comments.length);
el.dataset.hasComments = comments.length > 0 ? 'true' : 'false';
if (!comments.length) {
renderCommentsAside();
updateStripMarkers();
return;
}
const list = document.createElement('div');
list.className = 'comment-list';
@@ -77,15 +154,165 @@
const item = document.createElement('div');
item.className = 'comment-item';
item.innerHTML = `
<div class="comment-meta">
<strong>${escapeHtml(c.authorName || 'Anonymous')}</strong>
<time>${formatDate(c.createdAt)}</time>
</div>
<div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
<div class="comment-body">${escapeHtml(c.content)}</div>
`;
list.appendChild(item);
});
el.appendChild(list);
renderCommentsAside();
updateStripMarkers();
}
function renderCommentsAside() {
if (!commentsAsideList) return;
commentsAsideList.innerHTML = '';
const body = document.querySelector('.markdown-body');
if (!body) return;
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
if (!commentables.length) {
commentsAsideList.innerHTML = '<p class="document-comments-aside__empty">No comments yet.</p>';
return;
}
commentables.forEach(el => {
const comments = [];
el.querySelectorAll('.comment-item').forEach(item => {
const meta = item.querySelector('.comment-meta');
const bodyEl = item.querySelector('.comment-body');
comments.push({
authorName: meta?.querySelector('strong')?.textContent || 'Anonymous',
createdAt: meta?.querySelector('time')?.textContent || '',
content: bodyEl?.textContent || '',
});
});
if (!comments.length) return;
const targetText = (el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
const truncated = targetText.length > 120 ? targetText + '…' : targetText;
const asideItem = document.createElement('div');
asideItem.className = 'comments-aside-item';
asideItem.dataset.anchorHash = el.dataset.anchorHash;
const targetEl = document.createElement('div');
targetEl.className = 'comments-aside-item__target';
targetEl.textContent = truncated || 'Commented section';
targetEl.addEventListener('click', () => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
asideItem.appendChild(targetEl);
comments.forEach(c => {
const commentEl = document.createElement('div');
commentEl.className = 'comments-aside-item__comment';
commentEl.innerHTML = `
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
`;
asideItem.appendChild(commentEl);
});
commentsAsideList.appendChild(asideItem);
});
}
function updateStripMarkers() {
if (!stripTrack) return;
stripTrack.innerHTML = '';
const body = document.querySelector('.markdown-body');
const shell = document.querySelector('.document-shell');
if (!body || !shell) return;
const commentables = body.querySelectorAll('.commentable[data-has-comments="true"]');
const shellRect = shell.getBoundingClientRect();
const trackHeight = stripTrack.clientHeight;
commentables.forEach(el => {
const rect = el.getBoundingClientRect();
const relativeTop = rect.top - shellRect.top + shell.scrollTop;
const percent = (relativeTop / shell.scrollHeight) * 100;
const clamped = Math.max(0, Math.min(95, percent));
const marker = document.createElement('button');
marker.className = 'document-comments-strip__marker';
marker.type = 'button';
marker.style.top = clamped + '%';
marker.dataset.anchorHash = el.dataset.anchorHash;
marker.title = 'Jump to comments';
marker.addEventListener('click', () => {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
if (commentsHidden()) setCommentsHidden(false);
// Highlight the corresponding sidebar item
const asideItem = commentsAsideList?.querySelector(`[data-anchor-hash="${el.dataset.anchorHash}"]`);
if (asideItem) {
asideItem.scrollIntoView({ behavior: 'smooth', block: 'center' });
asideItem.classList.add('is-highlighted');
setTimeout(() => asideItem.classList.remove('is-highlighted'), 1500);
}
});
stripTrack.appendChild(marker);
});
}
let bidirectionalHoverInitialized = false;
function initBidirectionalHover() {
if (bidirectionalHoverInitialized) return;
bidirectionalHoverInitialized = true;
const body = document.querySelector('.markdown-body');
if (!body || !commentsAsideList) return;
// Body → sidebar + marker
body.addEventListener('mouseenter', (e) => {
const el = e.target.closest('.commentable[data-has-comments="true"]');
if (!el) return;
const hash = el.dataset.anchorHash;
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (asideItem) asideItem.classList.add('is-highlighted');
if (marker) marker.classList.add('is-active');
el.classList.add('is-highlighted');
}, true);
body.addEventListener('mouseleave', (e) => {
const el = e.target.closest('.commentable[data-has-comments="true"]');
if (!el) return;
const hash = el.dataset.anchorHash;
const asideItem = commentsAsideList.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (asideItem) asideItem.classList.remove('is-highlighted');
if (marker) marker.classList.remove('is-active');
el.classList.remove('is-highlighted');
}, true);
// Sidebar → paragraph + marker (event delegation so it survives re-renders)
commentsAsideList.addEventListener('mouseenter', (e) => {
const item = e.target.closest('.comments-aside-item');
if (!item) return;
const hash = item.dataset.anchorHash;
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (para) para.classList.add('is-highlighted');
if (marker) marker.classList.add('is-active');
item.classList.add('is-highlighted');
}, true);
commentsAsideList.addEventListener('mouseleave', (e) => {
const item = e.target.closest('.comments-aside-item');
if (!item) return;
const hash = item.dataset.anchorHash;
const para = body.querySelector(`[data-anchor-hash="${hash}"]`);
const marker = stripTrack?.querySelector(`[data-anchor-hash="${hash}"]`);
if (para) para.classList.remove('is-highlighted');
if (marker) marker.classList.remove('is-active');
item.classList.remove('is-highlighted');
}, true);
}
function escapeHtml(str) {
@@ -99,7 +326,6 @@
return d.toLocaleString();
}
// Initialize: hash all paragraphs in markdown body
function init() {
const body = document.querySelector('.markdown-body');
if (!body) return;
@@ -110,9 +336,21 @@
if (!hash) return;
el.dataset.anchorHash = hash;
el.classList.add('commentable');
addCommentButton(el, hash);
if (canComment) addCommentButton(el, hash);
loadCommentsForAnchor(el, hash);
});
renderCommentsAside();
initBidirectionalHover();
// Update markers on scroll/resize
const shell = document.querySelector('.document-shell');
if (shell) {
shell.addEventListener('scroll', () => requestAnimationFrame(updateStripMarkers));
}
window.addEventListener('resize', () => requestAnimationFrame(updateStripMarkers));
// Initial marker update after layout settles
setTimeout(updateStripMarkers, 100);
}
if (document.readyState === 'loading') {
@@ -120,4 +358,4 @@
} else {
init();
}
})();
})();

View File

@@ -0,0 +1,36 @@
(() => {
const root = document.querySelector("[data-permission-bulk]");
if (!root) return;
const selectAll = root.querySelector("[data-permission-select-all]");
const bulkLevel = root.querySelector("[data-permission-bulk-level]");
const apply = root.querySelector("[data-permission-apply]");
const rows = Array.from(document.querySelectorAll(".permission-user-row"));
const checkedRows = () => rows.filter((row) => row.querySelector("[data-permission-user]")?.checked);
const updateSelectAll = () => {
const selected = checkedRows().length;
selectAll.checked = selected > 0 && selected === rows.length;
selectAll.indeterminate = selected > 0 && selected < rows.length;
};
selectAll?.addEventListener("change", () => {
rows.forEach((row) => {
const checkbox = row.querySelector("[data-permission-user]");
if (checkbox) checkbox.checked = selectAll.checked;
});
updateSelectAll();
});
rows.forEach((row) => {
row.querySelector("[data-permission-user]")?.addEventListener("change", updateSelectAll);
});
apply?.addEventListener("click", () => {
checkedRows().forEach((row) => {
const select = row.querySelector("[data-permission-select]");
if (select) select.value = bulkLevel.value;
});
});
})();

View File

@@ -144,7 +144,7 @@
const html = document.documentElement.outerHTML;
const title = document.title;
const pagePath = window.MDHubCache.normalizePath(window.location.pathname);
const pagePath = window.MDHubCache.normalizePath(window.location.pathname + window.location.search);
window.MDHubCache.cachePage(pagePath, html, title).catch(function () {});
}
@@ -373,4 +373,72 @@
}).catch(function () {});
}
});
// Add folder button handlers
if (browser) {
browser.querySelectorAll(".miller-column-add-folder").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
var parentPath = button.getAttribute("data-folder-path");
var folderName = window.prompt("Folder name:");
if (!folderName || !folderName.trim()) return;
folderName = folderName.trim().replace(/[\\/]/g, "-");
var docPath = parentPath ? parentPath + "/" + folderName + "/index.md" : folderName + "/index.md";
var displayPath = docPath.replace(/\.md$/, "");
fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "# " + folderName + "\n\n", baseHash: "" }),
}).then(function (res) {
if (res.ok) {
return res.json().then(function (data) {
var path = data.path || docPath;
var urlPath = path.replace(/\.md$/, "");
window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/");
});
} else {
return res.json().then(function (data) {
throw new Error(data.error || "Failed to create folder");
});
}
}).catch(function (err) {
window.alert(err.message);
});
});
});
browser.querySelectorAll(".miller-column-new-file").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
var parentPath = button.getAttribute("data-folder-path");
var fileName = window.prompt("File name:");
if (!fileName || !fileName.trim()) return;
fileName = fileName.trim().replace(/[\\/]/g, "-");
if (!fileName.endsWith(".md")) fileName += ".md";
var docPath = parentPath ? parentPath + "/" + fileName : fileName;
var displayPath = docPath.replace(/\.md$/, "");
fetch("/api/documents/" + encodeURIComponent(displayPath).replace(/%2F/g, "/"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "# " + fileName.replace(/\.md$/, "") + "\n\n", baseHash: "" }),
}).then(function (res) {
if (res.ok) {
return res.json().then(function (data) {
var path = data.path || docPath;
var urlPath = path.replace(/\.md$/, "");
window.location.href = "/docs/" + encodeURIComponent(urlPath).replace(/%2F/g, "/");
});
} else {
return res.json().then(function (data) {
throw new Error(data.error || "Failed to create file");
});
}
}).catch(function (err) {
window.alert(err.message);
});
});
});
}
})();

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,7 @@ const STATIC_ASSETS = [
"/static/realtime.js",
"/static/editor.js",
"/static/render.js",
"/static/tags.js",
"/static/favicon.png",
"/static/cairnquire%20logo%402x.webp",
"/",
@@ -64,12 +65,13 @@ self.addEventListener("fetch", function (event) {
});
async function handleNavigation(request, url) {
const cacheKey = url.pathname + url.search;
try {
const response = await fetch(request);
cachePageResponse(url.pathname, response.clone());
cachePageResponse(cacheKey, response.clone());
return response;
} catch (_error) {
const cachedPage = await getCachedPage(url.pathname);
const cachedPage = await getCachedPage(cacheKey);
if (cachedPage && cachedPage.html) {
return new Response(markOfflineHTML(cachedPage.html), {
headers: {
@@ -80,6 +82,19 @@ async function handleNavigation(request, url) {
});
}
if (cacheKey !== "/") {
const fallback = await getCachedPage("/");
if (fallback && fallback.html) {
return new Response(markOfflineHTML(fallback.html), {
headers: {
"Content-Type": "text/html; charset=utf-8",
"X-MDHub-Cache": "offline",
},
status: 200,
});
}
}
const fallback = await caches.match("/");
if (fallback) {
return fallback;

View File

@@ -0,0 +1,81 @@
(function () {
const url = new URL(window.location.href);
const tag = url.searchParams.get("tag");
if (!tag) return;
// Check if we're on a proper tag page or a fallback
const hasTagPreview = document.querySelector(".tag-preview");
if (hasTagPreview) return; // Server rendered tag page correctly
// We're on a fallback page (likely offline) — build tag view from cache
if (!window.MDHubCache) return;
window.MDHubCache.getCachedDocuments().then(function (docs) {
if (!docs || !docs.length) return;
const tagged = docs.filter(function (doc) {
return doc.tags && doc.tags.indexOf(tag) !== -1;
});
// Build a minimal tag browser
const shell = document.querySelector(".workspace-shell");
if (!shell) return;
const browser = shell.querySelector(".miller-browser");
const preview = shell.querySelector(".empty-preview") || shell.querySelector(".document-shell");
if (browser) {
// Replace browser with a single column of tagged docs
const itemsHtml = tagged
.sort(function (a, b) {
const aName = (a.title || a.path).toLowerCase();
const bName = (b.title || b.path).toLowerCase();
return aName < bName ? -1 : aName > bName ? 1 : 0;
})
.map(function (doc) {
const name = doc.title || doc.path;
const path = doc.path.replace(/\.md$/, "");
return (
'<li><a href="/docs/' +
escapeHtml(path) +
'" title="' +
escapeHtml(name) +
'"><span class="browser-item-label"><svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path></svg><span class="browser-item-name">' +
escapeHtml(name) +
"</span></span></a></li>"
);
})
.join("");
browser.innerHTML =
'<section class="miller-column miller-column--root" aria-label="#' +
escapeHtml(tag) +
'"><h2><span class="miller-column-title-wrap"><span class="miller-column-title-text">#' +
escapeHtml(tag) +
"</span></span></h2><ul>" +
itemsHtml +
"</ul></section>";
}
if (preview) {
preview.outerHTML =
'<section class="tag-preview"><p class="eyebrow">Tag</p><h1>#' +
escapeHtml(tag) +
'</h1><p class="lede">' +
tagged.length +
" document" +
(tagged.length === 1 ? "" : "s") +
" found</p></section>";
}
});
function escapeHtml(str) {
if (!str) return "";
return str
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
})();

View File

@@ -29,7 +29,12 @@ func (s *Server) handleSyncInit(w http.ResponseWriter, r *http.Request) {
return
}
snapshot, err := s.syncService.InitSync(r.Context(), req.DeviceID, principal.UserID)
canRead, _, accessErr := s.syncAccessFilters(r)
snapshot, err := s.syncService.InitSyncFiltered(r.Context(), req.DeviceID, principal.UserID, canRead)
if accessErr() != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
return
}
if err != nil {
s.logger.Error("sync init failed", "error", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to initialize sync"})
@@ -60,7 +65,12 @@ func (s *Server) handleSyncDelta(w http.ResponseWriter, r *http.Request) {
return
}
result, err := s.syncService.ApplyDelta(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta})
canRead, canWrite, accessErr := s.syncAccessFilters(r)
result, err := s.syncService.ApplyDeltaFiltered(r.Context(), req.SnapshotID, principal.UserID, sync.Delta{Changes: req.ClientDelta}, canRead, canWrite)
if accessErr() != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
return
}
if err != nil {
if errors.Is(err, sync.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
@@ -100,7 +110,12 @@ func (s *Server) handleSyncResolve(w http.ResponseWriter, r *http.Request) {
return
}
snapshot, err := s.syncService.ResolveConflicts(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions)
canRead, canWrite, accessErr := s.syncAccessFilters(r)
snapshot, err := s.syncService.ResolveConflictsFiltered(r.Context(), req.SnapshotID, principal.UserID, req.Resolutions, canRead, canWrite)
if accessErr() != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": accessErr().Error()})
return
}
if err != nil {
if errors.Is(err, sync.ErrForbidden) {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "forbidden"})
@@ -148,3 +163,45 @@ func (s *Server) handleContentFetch(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
w.Write(content)
}
func (s *Server) syncAccessFilters(r *http.Request) (sync.FileAccessFunc, sync.PathAccessFunc, func() error) {
var accessErr error
canRead := func(file sync.FileEntry) bool {
if accessErr != nil {
return false
}
record, err := s.repository.GetDocumentByPath(r.Context(), file.Path)
if err != nil {
accessErr = err
return false
}
ok, err := s.canReadDocumentRecord(r, *record)
if err != nil {
accessErr = err
return false
}
return ok
}
canWrite := func(path string) bool {
if accessErr != nil {
return false
}
record, err := s.repository.GetDocumentByPath(r.Context(), path)
if err == nil && record != nil {
ok, err := s.canWriteDocumentRecord(r, *record)
if err != nil {
accessErr = err
return false
}
return ok
}
if os.IsNotExist(err) {
return s.canWriteNewDocument(r, path)
}
if err != nil {
return s.canWriteNewDocument(r, path)
}
return false
}
return canRead, canWrite, func() error { return accessErr }
}

View File

@@ -62,6 +62,8 @@
{{ template "document_edit_content" .Data }}
{{ else if eq .BodyTemplate "search_content" }}
{{ template "search_content" .Data }}
{{ else if eq .BodyTemplate "tag_content" }}
{{ template "tag_content" .Data }}
{{ else if eq .BodyTemplate "login_content" }}
{{ template "login_content" .Data }}
{{ else if eq .BodyTemplate "setup_content" }}
@@ -72,6 +74,8 @@
{{ template "account_content" .Data }}
{{ else if eq .BodyTemplate "token_create_content" }}
{{ template "token_create_content" .Data }}
{{ else if eq .BodyTemplate "permissions_content" }}
{{ template "permissions_content" .Data }}
{{ else if eq .BodyTemplate "device_verify_content" }}
{{ template "device_verify_content" .Data }}
{{ else if eq .BodyTemplate "error_content" }}
@@ -108,6 +112,8 @@
<script src="/static/render.js" defer></script>
<script src="/static/comments.js" defer></script>
<script src="/static/archive.js" defer></script>
<script src="/static/permissions.js" defer></script>
<script src="/static/tags.js" defer></script>
</body>
</html>

View File

@@ -2,8 +2,18 @@
{{ define "document_content" }}
<section class="workspace-shell workspace-shell--document">
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
{{ template "browser" .Browser }}
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
<div class="document-mobile-bar">
<button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
</button>
<span class="document-mobile-bar__title" aria-hidden="true">{{ .Title }}</span>
<button class="document-mobile-bar__btn document-mobile-bar__btn--meta" type="button" data-toggle-meta aria-label="Open document info" aria-expanded="false">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
</button>
</div>
<nav class="breadcrumbs" aria-label="Breadcrumb">
<ol>
{{ $lastIdx := sub (len .Breadcrumbs) 1 }}
@@ -18,15 +28,21 @@
{{ end }}
</ol>
</nav>
<div class="document-meta">
<div class="document-meta" data-meta-drawer>
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-meta aria-label="Close document info">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<div class="document-meta__header">
<h1>{{ .Title }}</h1>
{{ if .CanWrite }}
<div class="document-actions">
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
{{ if .CanWrite }}
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
{{ if .CanAdmin }}
<a class="document-edit-link" href="/permissions?resourceType=document&resourceId={{ .Path }}">Permissions</a>
{{ end }}
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
{{ end }}
</div>
{{ end }}
</div>
<details class="document-meta-panel">
<summary>
@@ -61,18 +77,74 @@
{{ .HTML }}
</div>
</article>
<aside class="document-comments-aside" data-comments-aside aria-label="Comments">
<div class="document-comments-strip" data-comments-strip>
<button
class="document-comments-strip__toggle"
type="button"
data-comment-toggle
aria-pressed="false"
aria-label="Toggle comments panel"
title="Toggle comments"
>
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
</svg>
</button>
<div class="document-comments-strip__track" data-comments-strip-track></div>
</div>
<div class="document-comments-panel">
<div class="document-comments-panel__list" data-comments-aside-list></div>
</div>
<!-- Mobile bottom sheet -->
<div class="document-comments-sheet" data-comments-sheet>
<div class="document-comments-sheet__handle">
<button class="document-comments-sheet__toggle" type="button" data-toggle-comments aria-expanded="false" aria-label="Toggle comments">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
</svg>
<span>Comments</span>
</button>
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-comments aria-label="Close comments">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div>
<div class="document-comments-sheet__panel">
<div class="document-comments-panel__list" data-comments-aside-list-mobile></div>
</div>
</div>
</aside>
</section>
{{ end }}
{{ define "browser" }}
<nav class="miller-browser" aria-label="Documents">
<nav class="miller-browser" aria-label="Documents" data-picker-drawer>
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-picker aria-label="Close file picker">
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
{{ range .Columns }}
<section class="miller-column {{ if eq .Title "Content" }}miller-column--root{{ end }}" aria-label="{{ .Title }}">
<h2 title="{{ .Title }}">
{{ if eq .Title "Content" }}
{{ template "icon-library" }}
<h2 title="{{ .Title }}" {{ if $.CanWrite }}class="has-actions"{{ end }}>
<span class="miller-column-title-wrap">
{{ if eq .Title "Content" }}
{{ template "icon-library" }}
{{ end }}
<span class="miller-column-title-text">{{ .Title }}</span>
</span>
{{ if $.CanWrite }}
<span class="miller-column-actions">
<button type="button" class="miller-column-new-file" data-folder-path="{{ .Path }}" aria-label="New file in {{ .Title }}">
{{ template "icon-file-plus" }}
</button>
<button type="button" class="miller-column-add-folder" data-folder-path="{{ .Path }}" aria-label="Add folder to {{ .Title }}">
{{ template "icon-folder-plus" }}
</button>
</span>
{{ end }}
<span class="miller-column-title-text">{{ .Title }}</span>
</h2>
<ul>
{{ range .Items }}
@@ -117,6 +189,23 @@
</svg>
{{ end }}
{{ define "icon-folder-plus" }}
<svg class="browser-icon browser-icon--folder" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.7-.9l-.8-1.2A2 2 0 0 0 7.9 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
{{ end }}
{{ define "icon-file-plus" }}
<svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path>
<path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
<line x1="12" y1="11" x2="12" y2="17"></line>
<line x1="9" y1="14" x2="15" y2="14"></line>
</svg>
{{ end }}
{{ define "icon-chevron-right" }}
<svg class="browser-item-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
<path d="m9 18 6-6-6-6"></path>

View File

@@ -0,0 +1,71 @@
{{ define "permissions.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "permissions_content" }}
<section class="permissions-shell">
<header class="permissions-header">
<div>
<p class="eyebrow">Access control</p>
<h1>{{ .Title }}</h1>
</div>
{{ if eq .ResourceType "document" }}
<a class="account-action" href="/docs/{{ trimMd .ResourceID }}">Back to page</a>
{{ end }}
</header>
<form class="permissions-form" method="post" action="/permissions">
<input type="hidden" name="resourceType" value="{{ .ResourceType }}" />
<input type="hidden" name="resourceId" value="{{ .ResourceID }}" />
<section class="permissions-panel">
<header class="permissions-panel__header">
<div>
<p class="account-section__kicker">People</p>
<h2>User permissions</h2>
</div>
<div class="permission-bulk-actions" data-permission-bulk>
<label class="permission-select-all">
<input type="checkbox" data-permission-select-all />
<span>Select all</span>
</label>
<select data-permission-bulk-level aria-label="Access level for selected users">
<option value="">No access</option>
<option value="read">View</option>
<option value="write">Edit</option>
<option value="admin">Admin</option>
</select>
<button class="btn-secondary permission-apply" type="button" data-permission-apply>Apply</button>
</div>
</header>
<div class="permission-user-list">
{{ range .Users }}
<label class="permission-user-row">
<input class="permission-user-check" type="checkbox" data-permission-user />
<span class="permission-user-main">
<span class="permission-user-name">
<strong>{{ .DisplayName }}</strong>
<em>{{ .Role }}</em>
</span>
<span class="permission-user-email">{{ .Email }}</span>
<span class="permission-user-meta">Created {{ .CreatedAt }} · Last seen {{ .LastSeenAt }}</span>
</span>
<span class="permission-grant-meta">
<strong>{{ .PermissionText }}</strong>
<small>{{ .GrantSummary }}</small>
</span>
<span class="permission-select-wrap">
<select name="permission:{{ .ID }}" aria-label="Permission for {{ .DisplayName }}" data-permission-select>
<option value="" {{ if eq .Permission "" }}selected{{ end }}>No access</option>
<option value="read" {{ if eq .Permission "read" }}selected{{ end }}>View</option>
<option value="write" {{ if eq .Permission "write" }}selected{{ end }}>Edit</option>
<option value="admin" {{ if eq .Permission "admin" }}selected{{ end }}>Admin</option>
</select>
</span>
</label>
{{ end }}
</div>
<div class="permissions-footer">
<button class="btn-primary" type="submit">Save permissions</button>
</div>
</section>
</form>
</section>
{{ end }}

View File

@@ -0,0 +1,12 @@
{{ define "tag.gohtml" }}{{ template "base" . }}{{ end }}
{{ define "tag_content" }}
<section class="workspace-shell workspace-shell--empty">
{{ template "browser" .Browser }}
<section class="tag-preview">
<p class="eyebrow">Tag</p>
<h1>#{{ .Tag }}</h1>
<p class="lede">{{ len .Results }} document{{ if ne (len .Results) 1 }}s{{ end }} found</p>
</section>
</section>
{{ end }}