ops design and app
add more comments feature, more tags feature, and other frontend updates
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user