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

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