accounts and email

This commit is contained in:
2026-06-04 08:50:34 -04:00
parent 73d505ac7e
commit ddc7d5cbf5
46 changed files with 2685 additions and 309 deletions

View File

@@ -21,6 +21,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/tim/cairnquire/apps/server/internal/auth"
"github.com/tim/cairnquire/apps/server/internal/docs"
)
@@ -29,6 +30,7 @@ type layoutData struct {
BodyClass string
BodyTemplate string
Data any
Principal *auth.Principal
}
type indexData struct {
@@ -43,6 +45,7 @@ type documentData struct {
Hash string
HTML template.HTML
Breadcrumbs []breadcrumb
CanWrite bool
}
type documentEditData struct {
@@ -53,6 +56,7 @@ type documentEditData struct {
Hash string
Source string
Breadcrumbs []breadcrumb
CanWrite bool
}
type breadcrumb struct {
@@ -98,7 +102,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
}
activeFolder := strings.Trim(r.URL.Query().Get("folder"), "/")
s.renderTemplate(w, http.StatusOK, "index.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "index.gohtml", layoutData{
Title: "Cairnquire",
BodyClass: "page-index",
BodyTemplate: "index_content",
@@ -117,7 +121,7 @@ func (s *Server) handleSearchPage(w http.ResponseWriter, r *http.Request, query
s.logger.Warn("search failed", "error", err)
}
s.renderTemplate(w, http.StatusOK, "search.gohtml", layoutData{
s.renderTemplate(w, r, http.StatusOK, "search.gohtml", layoutData{
Title: "Search: " + query,
BodyClass: "page-search",
BodyTemplate: "search_content",
@@ -223,7 +227,9 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
return
}
s.renderTemplate(w, http.StatusOK, "document_edit.gohtml", layoutData{
canWrite := s.canWriteDocuments(r)
s.renderTemplate(w, r, http.StatusOK, "document_edit.gohtml", layoutData{
Title: "Edit: " + page.Title,
BodyClass: "page-document-edit",
BodyTemplate: "document_edit_content",
@@ -235,6 +241,7 @@ func (s *Server) renderDocumentEditor(w http.ResponseWriter, r *http.Request, pa
Hash: page.Hash,
Source: page.Content,
Breadcrumbs: buildBreadcrumbs(page.Path),
CanWrite: canWrite,
},
})
}
@@ -256,7 +263,9 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
return
}
s.renderTemplate(w, http.StatusOK, "document.gohtml", layoutData{
canWrite := s.canWriteDocuments(r)
s.renderTemplate(w, r, http.StatusOK, "document.gohtml", layoutData{
Title: page.Title,
BodyClass: "page-document",
BodyTemplate: "document_content",
@@ -268,11 +277,20 @@ func (s *Server) renderDocumentPage(w http.ResponseWriter, r *http.Request, page
Hash: page.Hash,
HTML: template.HTML(page.HTML),
Breadcrumbs: buildBreadcrumbs(page.Path),
CanWrite: canWrite,
},
})
}
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
s.handleReadyz(w, r)
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
defer cancel()
@@ -286,11 +304,18 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
contentStatus = err.Error()
}
writeJSON(w, http.StatusOK, map[string]string{
ready := dbStatus == "ok" && contentStatus == "ok"
payload := map[string]string{
"status": "ok",
"database": dbStatus,
"contentStore": contentStatus,
})
}
if !ready {
payload["status"] = "unavailable"
writeJSONWithStatus(w, http.StatusServiceUnavailable, payload)
return
}
writeJSON(w, http.StatusOK, payload)
}
func (s *Server) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
@@ -515,7 +540,13 @@ func (s *Server) handleAttachment(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(content)
}
func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string, data layoutData) {
func (s *Server) renderTemplate(w http.ResponseWriter, r *http.Request, status int, name string, data layoutData) {
if data.Principal == nil {
if principal, ok := principalFromContext(r.Context()); ok {
p := principal
data.Principal = &p
}
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if err := s.templates.ExecuteTemplate(w, name, data); err != nil {
@@ -524,7 +555,7 @@ func (s *Server) renderTemplate(w http.ResponseWriter, status int, name string,
}
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
s.renderTemplate(w, status, "error.gohtml", layoutData{
s.renderTemplate(w, r, status, "error.gohtml", layoutData{
Title: http.StatusText(status),
BodyClass: "page-error",
BodyTemplate: "error_content",
@@ -649,8 +680,30 @@ func (s *Server) handleDocuments(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"documents": results})
}
func (s *Server) handleDocumentSave(w http.ResponseWriter, r *http.Request) {
pagePath := chi.URLParam(r, "*")
func (s *Server) handleDocumentMutation(w http.ResponseWriter, r *http.Request) {
pagePath, err := unescapeDocumentRoutePath(chi.URLParam(r, "*"))
if err != nil {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "invalid document path"})
return
}
switch {
case strings.HasSuffix(pagePath, "/archive"):
s.handleDocumentArchivePath(w, r, strings.TrimSuffix(pagePath, "/archive"))
case strings.HasSuffix(pagePath, "/restore"):
s.handleDocumentRestorePath(w, r, strings.TrimSuffix(pagePath, "/restore"))
default:
s.handleDocumentSavePath(w, r, pagePath)
}
}
func unescapeDocumentRoutePath(path string) (string, error) {
if path == "" {
return "", nil
}
return url.PathUnescape(path)
}
func (s *Server) handleDocumentSavePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
@@ -705,6 +758,50 @@ 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
}
if err := s.documents.ArchiveDocument(r.Context(), pagePath); 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
}
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "archived"})
}
func (s *Server) handleDocumentRestorePath(w http.ResponseWriter, r *http.Request, pagePath string) {
if pagePath == "" {
writeJSONWithStatus(w, http.StatusBadRequest, map[string]string{"error": "document path is required"})
return
}
if err := s.documents.RestoreDocument(r.Context(), pagePath); 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
}
writeJSONWithStatus(w, http.StatusOK, map[string]string{"status": "restored"})
}
func trimEditSuffix(path string) (string, bool) {
if path == "edit" {
return "", true