diff --git a/.project/project-plan.md b/.project/project-plan.md index a4da829..be86f9f 100644 --- a/.project/project-plan.md +++ b/.project/project-plan.md @@ -133,7 +133,14 @@ A minimal-dependency, ultra-fast documentation platform that publishes markdown **What's Next:** - Complete the independent penetration-test checklist during production hardening -- Decide how deep per-document/per-collection permissions should go before collaboration features +- Implement resource permissions for pages, folders, and collections: + - Content is secure/private by default unless a user is granted access directly or through a folder/collection grant + - Supported levels remain view, edit, and admin; commenting is available to editors and admins only + - Global admins and resource admins can change resource permissions + - Unauthorized pages disappear from navigation, search, and sync results + - Most permissive matching grant wins, so shared folder/collection access can expose otherwise-default-private pages + - API tokens and native sync clients inherit the owning user's effective resource permissions + - Public UUID/hash-style sharing remains supported for explicitly shared/uploaded assets - Add optional multiple-passkey management and session refresh polish if real usage calls for it --- diff --git a/Dockerfile b/Dockerfile index 04e133a..ae45c0d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,17 +9,18 @@ COPY apps/server/ ./ RUN CGO_ENABLED=1 go build -trimpath -ldflags="-s -w" -o /workspace/cairnquire ./cmd/cairnquire FROM debian:bookworm-slim -RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl gosu && rm -rf /var/lib/apt/lists/* RUN useradd --create-home --shell /usr/sbin/nologin appuser WORKDIR /workspace COPY --from=server-build /workspace/cairnquire /usr/local/bin/cairnquire COPY content /workspace/content -RUN mkdir -p /workspace/data/files && chown -R appuser:appuser /workspace -USER appuser +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN mkdir -p /workspace/data/files && chmod +x /usr/local/bin/entrypoint.sh EXPOSE 8080 ENV CAIRNQUIRE_SERVER_ADDR=:8080 ENV CAIRNQUIRE_DATABASE_PATH=/workspace/data/db.sqlite ENV CAIRNQUIRE_CONTENT_SOURCE_DIR=/workspace/content ENV CAIRNQUIRE_CONTENT_STORE_DIR=/workspace/data/files -HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/health || exit 1 -ENTRYPOINT ["/usr/local/bin/cairnquire"] +HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=5 CMD curl --fail http://127.0.0.1:8080/healthz || exit 1 +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["/usr/local/bin/cairnquire"] diff --git a/apps/server/cairnquire b/apps/server/cairnquire new file mode 100755 index 0000000..ba97ade Binary files /dev/null and b/apps/server/cairnquire differ diff --git a/apps/server/internal/auth/crypto.go b/apps/server/internal/auth/crypto.go index 977cda4..5c0abcb 100644 --- a/apps/server/internal/auth/crypto.go +++ b/apps/server/internal/auth/crypto.go @@ -169,3 +169,7 @@ func hasScope(scopes []Scope, required Scope) bool { } return false } + +func HasScope(scopes []Scope, required Scope) bool { + return hasScope(scopes, required) +} diff --git a/apps/server/internal/auth/repository.go b/apps/server/internal/auth/repository.go index 02c8056..dc535a8 100644 --- a/apps/server/internal/auth/repository.go +++ b/apps/server/internal/auth/repository.go @@ -6,6 +6,8 @@ import ( "encoding/json" "errors" "fmt" + "path" + "strings" "time" "github.com/go-webauthn/webauthn/webauthn" @@ -284,6 +286,349 @@ func (r *Repository) UpdateUserAccess(ctx context.Context, updates []UserAccessU return nil } +type resourceMatch struct { + resourceType ResourceType + resourceID string +} + +func (r *Repository) ListResourcePermissions(ctx context.Context, resourceType ResourceType, resourceID string) ([]ResourcePermission, error) { + resourceType, resourceID, err := normalizeResource(resourceType, resourceID) + if err != nil { + return nil, err + } + rows, err := r.db.QueryContext(ctx, ` + SELECT p.id, p.user_id, COALESCE(u.email, ''), COALESCE(u.display_name, ''), p.resource_type, COALESCE(p.resource_id, ''), p.permission, COALESCE(p.granted_by, ''), p.created_at + FROM permissions p + LEFT JOIN users u ON u.id = p.user_id + WHERE p.resource_type = ? AND COALESCE(p.resource_id, '') = ? + ORDER BY u.email ASC, p.created_at ASC + `, string(resourceType), resourceID) + if err != nil { + return nil, fmt.Errorf("list resource permissions: %w", err) + } + defer rows.Close() + + var permissions []ResourcePermission + for rows.Next() { + permission, err := scanResourcePermission(rows) + if err != nil { + return nil, err + } + permissions = append(permissions, permission) + } + return permissions, rows.Err() +} + +func (r *Repository) ReplaceResourcePermissions(ctx context.Context, resourceType ResourceType, resourceID string, actorID string, updates []ResourcePermissionUpdate) error { + resourceType, resourceID, err := normalizeResource(resourceType, resourceID) + if err != nil { + return err + } + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin resource permissions update: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, ` + DELETE FROM permissions + WHERE resource_type = ? AND COALESCE(resource_id, '') = ? + `, string(resourceType), resourceID); err != nil { + return fmt.Errorf("clear resource permissions: %w", err) + } + + now := time.Now().UTC().Format(time.RFC3339) + for _, update := range updates { + update.UserID = strings.TrimSpace(update.UserID) + if update.UserID == "" || update.Permission == PermissionNone { + continue + } + if !validPermission(update.Permission) { + return fmt.Errorf("invalid permission") + } + var exists int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE id = ? AND disabled = 0`, update.UserID).Scan(&exists); err != nil { + return fmt.Errorf("check permission user: %w", err) + } + if exists == 0 { + return fmt.Errorf("user %s does not exist", update.UserID) + } + idPart, err := randomToken(18) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, granted_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, "perm:"+idPart, update.UserID, string(resourceType), nullString(resourceID), string(update.Permission), nullString(actorID), now); err != nil { + return fmt.Errorf("insert resource permission: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit resource permissions update: %w", err) + } + return nil +} + +func (r *Repository) EnsureResourcePermission(ctx context.Context, resourceType ResourceType, resourceID string, actorID string, update ResourcePermissionUpdate) error { + resourceType, resourceID, err := normalizeResource(resourceType, resourceID) + if err != nil { + return err + } + update.UserID = strings.TrimSpace(update.UserID) + if update.UserID == "" { + return fmt.Errorf("user id is required") + } + if !validPermission(update.Permission) || update.Permission == PermissionNone { + return fmt.Errorf("invalid permission") + } + + tx, err := r.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("begin resource permission ensure: %w", err) + } + defer func() { _ = tx.Rollback() }() + + var exists int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM users WHERE id = ? AND disabled = 0`, update.UserID).Scan(&exists); err != nil { + return fmt.Errorf("check permission user: %w", err) + } + if exists == 0 { + return fmt.Errorf("user %s does not exist", update.UserID) + } + + best := PermissionNone + rows, err := tx.QueryContext(ctx, ` + SELECT permission + FROM permissions + WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ? + `, update.UserID, string(resourceType), resourceID) + if err != nil { + return fmt.Errorf("lookup existing resource permission: %w", err) + } + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + rows.Close() + return fmt.Errorf("scan existing resource permission: %w", err) + } + best = maxPermission(best, Permission(raw)) + } + if err := rows.Close(); err != nil { + return err + } + if PermissionAllows(best, update.Permission) { + return tx.Commit() + } + + if _, err := tx.ExecContext(ctx, ` + DELETE FROM permissions + WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ? + `, update.UserID, string(resourceType), resourceID); err != nil { + return fmt.Errorf("clear user resource permission: %w", err) + } + + idPart, err := randomToken(18) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO permissions (id, user_id, resource_type, resource_id, permission, granted_by, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, "perm:"+idPart, update.UserID, string(resourceType), nullString(resourceID), string(update.Permission), nullString(actorID), time.Now().UTC().Format(time.RFC3339)); err != nil { + return fmt.Errorf("insert resource permission: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit resource permission ensure: %w", err) + } + return nil +} + +func (r *Repository) EffectiveResourcePermission(ctx context.Context, principal Principal, resourceType ResourceType, resourceID string, collectionIDs []string) (Permission, error) { + if principal.UserID == "" { + return PermissionNone, nil + } + if principal.Role == RoleAdmin { + return PermissionAdmin, nil + } + resourceType, resourceID, err := normalizeResource(resourceType, resourceID) + if err != nil { + return PermissionNone, err + } + + matches := []resourceMatch{{resourceType: ResourceGlobal, resourceID: ""}} + switch resourceType { + case ResourceDocument: + for _, folder := range folderMatchesForDocument(resourceID) { + matches = append(matches, resourceMatch{resourceType: ResourceFolder, resourceID: folder}) + } + for _, collectionID := range collectionIDs { + normalized := normalizeCollectionID(collectionID) + if normalized != "" { + matches = append(matches, resourceMatch{resourceType: ResourceCollection, resourceID: normalized}) + } + } + matches = append(matches, resourceMatch{resourceType: ResourceDocument, resourceID: resourceID}) + case ResourceFolder: + for _, folder := range folderMatches(resourceID) { + matches = append(matches, resourceMatch{resourceType: ResourceFolder, resourceID: folder}) + } + case ResourceCollection: + matches = append(matches, resourceMatch{resourceType: ResourceCollection, resourceID: resourceID}) + default: + matches = append(matches, resourceMatch{resourceType: resourceType, resourceID: resourceID}) + } + + best := PermissionNone + for _, match := range matches { + rows, err := r.db.QueryContext(ctx, ` + SELECT permission + FROM permissions + WHERE user_id = ? AND resource_type = ? AND COALESCE(resource_id, '') = ? + `, principal.UserID, string(match.resourceType), match.resourceID) + if err != nil { + return PermissionNone, fmt.Errorf("lookup resource permission: %w", err) + } + for rows.Next() { + var raw string + if err := rows.Scan(&raw); err != nil { + rows.Close() + return PermissionNone, fmt.Errorf("scan resource permission: %w", err) + } + best = maxPermission(best, Permission(raw)) + } + if err := rows.Close(); err != nil { + return PermissionNone, err + } + } + return best, nil +} + +func scanResourcePermission(rows *sql.Rows) (ResourcePermission, error) { + var ( + permission ResourcePermission + resourceType string + resourceID string + rawPermission string + created string + ) + if err := rows.Scan(&permission.ID, &permission.UserID, &permission.UserEmail, &permission.UserName, &resourceType, &resourceID, &rawPermission, &permission.GrantedBy, &created); err != nil { + return ResourcePermission{}, fmt.Errorf("scan resource permission: %w", err) + } + permission.ResourceType = ResourceType(resourceType) + permission.ResourceID = resourceID + permission.Permission = Permission(rawPermission) + createdAt, err := time.Parse(time.RFC3339, created) + if err != nil { + return ResourcePermission{}, fmt.Errorf("parse resource permission created_at: %w", err) + } + permission.CreatedAt = createdAt + return permission, nil +} + +func normalizeResource(resourceType ResourceType, resourceID string) (ResourceType, string, error) { + resourceType = ResourceType(strings.TrimSpace(strings.ToLower(string(resourceType)))) + switch resourceType { + case ResourceGlobal: + return resourceType, "", nil + case ResourceDocument: + resourceID = normalizeDocumentPath(resourceID) + if resourceID == "" { + return "", "", fmt.Errorf("document resource id is required") + } + return resourceType, resourceID, nil + case ResourceFolder: + return resourceType, normalizeFolderPath(resourceID), nil + case ResourceCollection: + resourceID = normalizeCollectionID(resourceID) + if resourceID == "" { + return "", "", fmt.Errorf("collection resource id is required") + } + return resourceType, resourceID, nil + default: + return "", "", fmt.Errorf("invalid resource type") + } +} + +func normalizeDocumentPath(raw string) string { + clean := normalizeFolderPath(raw) + if clean == "" { + return "" + } + if !strings.HasSuffix(strings.ToLower(clean), ".md") { + clean += ".md" + } + return clean +} + +func normalizeFolderPath(raw string) string { + raw = strings.TrimSpace(strings.Trim(raw, "/")) + if raw == "" { + return "" + } + clean := path.Clean(strings.ReplaceAll(raw, "\\", "/")) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "" + } + return strings.Trim(clean, "/") +} + +func normalizeCollectionID(raw string) string { + return strings.Trim(strings.TrimSpace(raw), "#/") +} + +func folderMatchesForDocument(documentPath string) []string { + folder := path.Dir(documentPath) + if folder == "." { + return []string{""} + } + return folderMatches(folder) +} + +func folderMatches(folder string) []string { + folder = normalizeFolderPath(folder) + matches := []string{""} + if folder == "" { + return matches + } + parts := strings.Split(folder, "/") + for index := range parts { + matches = append(matches, strings.Join(parts[:index+1], "/")) + } + return matches +} + +func validPermission(permission Permission) bool { + switch permission { + case PermissionNone, PermissionRead, PermissionWrite, PermissionAdmin: + return true + default: + return false + } +} + +func maxPermission(left Permission, right Permission) Permission { + if permissionRank(right) > permissionRank(left) { + return right + } + return left +} + +func permissionRank(permission Permission) int { + switch permission { + case PermissionRead: + return 1 + case PermissionWrite: + return 2 + case PermissionAdmin: + return 3 + default: + return 0 + } +} + func (r *Repository) DeleteUser(ctx context.Context, userID string) error { result, err := r.db.ExecContext(ctx, `DELETE FROM users WHERE id = ?`, userID) if err != nil { diff --git a/apps/server/internal/auth/service.go b/apps/server/internal/auth/service.go index 173e991..e408d5b 100644 --- a/apps/server/internal/auth/service.go +++ b/apps/server/internal/auth/service.go @@ -409,6 +409,96 @@ func (s *Service) UpdateUserAccess(ctx context.Context, actor Principal, updates return users, nil } +func (s *Service) ListResourcePermissions(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string) ([]ResourcePermission, error) { + if actor.UserID == "" { + return nil, fmt.Errorf("authentication required") + } + if actor.Role != RoleAdmin { + permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil) + if err != nil { + return nil, err + } + if !PermissionAllows(permission, PermissionAdmin) { + return nil, fmt.Errorf("admin permission required") + } + } + return s.repo.ListResourcePermissions(ctx, resourceType, resourceID) +} + +func (s *Service) UpdateResourcePermissions(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string, updates []ResourcePermissionUpdate) ([]ResourcePermission, error) { + if actor.UserID == "" { + return nil, fmt.Errorf("authentication required") + } + if actor.Role != RoleAdmin { + permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil) + if err != nil { + return nil, err + } + if !PermissionAllows(permission, PermissionAdmin) { + return nil, fmt.Errorf("admin permission required") + } + } + normalized := make([]ResourcePermissionUpdate, 0, len(updates)) + seen := make(map[string]struct{}, len(updates)) + for _, update := range updates { + update.UserID = strings.TrimSpace(update.UserID) + if update.UserID == "" { + continue + } + if _, ok := seen[update.UserID]; ok { + return nil, fmt.Errorf("duplicate user permission") + } + seen[update.UserID] = struct{}{} + update.Permission = normalizePermission(update.Permission) + if !validPermission(update.Permission) { + return nil, fmt.Errorf("invalid permission") + } + normalized = append(normalized, update) + } + if err := s.repo.ReplaceResourcePermissions(ctx, resourceType, resourceID, actor.UserID, normalized); err != nil { + return nil, err + } + s.repo.Audit(ctx, actor.UserID, "auth.resource.permissions.update", string(resourceType), resourceID, "", "", map[string]any{ + "updates": len(normalized), + }) + return s.repo.ListResourcePermissions(ctx, resourceType, resourceID) +} + +func (s *Service) EnsureResourcePermission(ctx context.Context, actor Principal, resourceType ResourceType, resourceID string, update ResourcePermissionUpdate) error { + if actor.UserID == "" { + return fmt.Errorf("authentication required") + } + update.UserID = strings.TrimSpace(update.UserID) + if update.UserID == "" { + return fmt.Errorf("user id is required") + } + update.Permission = normalizePermission(update.Permission) + if !validPermission(update.Permission) || update.Permission == PermissionNone { + return fmt.Errorf("invalid permission") + } + if update.UserID != actor.UserID && actor.Role != RoleAdmin { + permission, err := s.repo.EffectiveResourcePermission(ctx, actor, resourceType, resourceID, nil) + if err != nil { + return err + } + if !PermissionAllows(permission, PermissionAdmin) { + return fmt.Errorf("admin permission required") + } + } + if err := s.repo.EnsureResourcePermission(ctx, resourceType, resourceID, actor.UserID, update); err != nil { + return err + } + s.repo.Audit(ctx, actor.UserID, "auth.resource.permissions.ensure", string(resourceType), resourceID, "", "", map[string]any{ + "userID": update.UserID, + "permission": update.Permission, + }) + return nil +} + +func (s *Service) EffectiveResourcePermission(ctx context.Context, principal Principal, resourceType ResourceType, resourceID string, collectionIDs []string) (Permission, error) { + return s.repo.EffectiveResourcePermission(ctx, principal, resourceType, resourceID, collectionIDs) +} + func (s *Service) SendPasswordReset(ctx context.Context, actor Principal, userID string) error { if !Allows(actor, ScopeAdmin) { return fmt.Errorf("admin role required") @@ -715,6 +805,23 @@ func RoleAllows(role Role, required Scope) bool { } } +func PermissionAllows(actual Permission, required Permission) bool { + return permissionRank(actual) >= permissionRank(required) +} + +func normalizePermission(permission Permission) Permission { + switch Permission(strings.ToLower(strings.TrimSpace(string(permission)))) { + case "view": + return PermissionRead + case "edit": + return PermissionWrite + case PermissionRead, PermissionWrite, PermissionAdmin, PermissionNone: + return Permission(strings.ToLower(strings.TrimSpace(string(permission)))) + default: + return Permission(strings.ToLower(strings.TrimSpace(string(permission)))) + } +} + func Allows(principal Principal, required Scope) bool { if principal.UserID == "" { return false diff --git a/apps/server/internal/auth/service_test.go b/apps/server/internal/auth/service_test.go index 9d019d1..0e15a78 100644 --- a/apps/server/internal/auth/service_test.go +++ b/apps/server/internal/auth/service_test.go @@ -343,3 +343,82 @@ func TestInitialSetupCanDisablePublicRegistration(t *testing.T) { t.Fatalf("public signup role = %s, want viewer", viewer.Role) } } + +func TestResourcePermissionsArePrivateByDefault(t *testing.T) { + service := setupAuthTestService(t) + ctx := context.Background() + admin := setupInitialAdmin(t, service, true) + viewer, err := service.repo.UpsertUser(ctx, User{ + Email: "viewer@example.com", + DisplayName: "Viewer", + Role: RoleViewer, + }) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + + viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour)) + permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "private/page.md", nil) + if err != nil { + t.Fatalf("EffectiveResourcePermission() error = %v", err) + } + if permission != PermissionNone { + t.Fatalf("viewer permission = %q, want no access", permission) + } + + adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour)) + permission, err = service.EffectiveResourcePermission(ctx, adminPrincipal, ResourceDocument, "private/page.md", nil) + if err != nil { + t.Fatalf("EffectiveResourcePermission() admin error = %v", err) + } + if permission != PermissionAdmin { + t.Fatalf("admin permission = %q, want admin", permission) + } +} + +func TestResourcePermissionsUseMostPermissiveGrant(t *testing.T) { + service := setupAuthTestService(t) + ctx := context.Background() + admin := setupInitialAdmin(t, service, true) + viewer, err := service.repo.UpsertUser(ctx, User{ + Email: "viewer@example.com", + DisplayName: "Viewer", + Role: RoleViewer, + }) + if err != nil { + t.Fatalf("create viewer: %v", err) + } + adminPrincipal := principalFromUser(admin, "session", "sess:admin", "", nil, time.Now().Add(time.Hour)) + viewerPrincipal := principalFromUser(viewer, "session", "sess:viewer", "", nil, time.Now().Add(time.Hour)) + + if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceDocument, "shared/page.md", []ResourcePermissionUpdate{ + {UserID: viewer.ID, Permission: PermissionRead}, + }); err != nil { + t.Fatalf("grant document read: %v", err) + } + if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceFolder, "shared", []ResourcePermissionUpdate{ + {UserID: viewer.ID, Permission: PermissionWrite}, + }); err != nil { + t.Fatalf("grant folder write: %v", err) + } + permission, err := service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "shared/page.md", nil) + if err != nil { + t.Fatalf("EffectiveResourcePermission() folder error = %v", err) + } + if permission != PermissionWrite { + t.Fatalf("folder/document combined permission = %q, want write", permission) + } + + if _, err := service.UpdateResourcePermissions(ctx, adminPrincipal, ResourceCollection, "team", []ResourcePermissionUpdate{ + {UserID: viewer.ID, Permission: PermissionAdmin}, + }); err != nil { + t.Fatalf("grant collection admin: %v", err) + } + permission, err = service.EffectiveResourcePermission(ctx, viewerPrincipal, ResourceDocument, "other/page.md", []string{"team"}) + if err != nil { + t.Fatalf("EffectiveResourcePermission() collection error = %v", err) + } + if permission != PermissionAdmin { + t.Fatalf("collection permission = %q, want admin", permission) + } +} diff --git a/apps/server/internal/auth/types.go b/apps/server/internal/auth/types.go index 82f9847..af517d8 100644 --- a/apps/server/internal/auth/types.go +++ b/apps/server/internal/auth/types.go @@ -24,6 +24,24 @@ const ( ScopeAdmin Scope = "admin" ) +type Permission string + +const ( + PermissionNone Permission = "" + PermissionRead Permission = "read" + PermissionWrite Permission = "write" + PermissionAdmin Permission = "admin" +) + +type ResourceType string + +const ( + ResourceGlobal ResourceType = "global" + ResourceCollection ResourceType = "collection" + ResourceDocument ResourceType = "document" + ResourceFolder ResourceType = "folder" +) + type Principal struct { UserID string `json:"userId"` Email string `json:"email"` @@ -54,6 +72,23 @@ type UserAccessUpdate struct { Disabled bool `json:"disabled"` } +type ResourcePermission struct { + ID string `json:"id"` + UserID string `json:"userId"` + UserEmail string `json:"userEmail,omitempty"` + UserName string `json:"userName,omitempty"` + ResourceType ResourceType `json:"resourceType"` + ResourceID string `json:"resourceId"` + Permission Permission `json:"permission"` + GrantedBy string `json:"grantedBy,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +type ResourcePermissionUpdate struct { + UserID string `json:"userId"` + Permission Permission `json:"permission"` +} + type PasswordResetToken struct { ID string UserID string diff --git a/apps/server/internal/collaboration/service.go b/apps/server/internal/collaboration/service.go index a3a66fa..614ba9d 100644 --- a/apps/server/internal/collaboration/service.go +++ b/apps/server/internal/collaboration/service.go @@ -22,10 +22,10 @@ func (n *NoOpEmailSender) Send(ctx context.Context, to []string, subject, body s } type Service struct { - repo *Repository - email EmailSender - hub *realtime.Hub - logger *slog.Logger + repo *Repository + email EmailSender + hub *realtime.Hub + logger *slog.Logger } func NewService(repo *Repository, email EmailSender, hub *realtime.Hub, logger *slog.Logger) *Service { @@ -94,6 +94,10 @@ func (s *Service) ListCommentsByAnchor(ctx context.Context, documentID string, a return s.repo.ListCommentsByAnchor(ctx, documentID, anchorHash) } +func (s *Service) GetComment(ctx context.Context, commentID string) (*Comment, error) { + return s.repo.GetComment(ctx, commentID) +} + func (s *Service) ResolveComment(ctx context.Context, principal auth.Principal, commentID string) error { if principal.UserID == "" { return fmt.Errorf("authentication required") @@ -149,9 +153,9 @@ func (s *Service) WatchDocument(ctx context.Context, principal auth.Principal, d return fmt.Errorf("authentication required") } return s.repo.AddWatcher(ctx, Watcher{ - UserID: principal.UserID, + UserID: principal.UserID, DocumentID: &documentID, - CreatedAt: time.Now().UTC(), + CreatedAt: time.Now().UTC(), }) } @@ -167,9 +171,9 @@ func (s *Service) WatchFolder(ctx context.Context, principal auth.Principal, fol return fmt.Errorf("authentication required") } return s.repo.AddWatcher(ctx, Watcher{ - UserID: principal.UserID, + UserID: principal.UserID, FolderPath: &folderPath, - CreatedAt: time.Now().UTC(), + CreatedAt: time.Now().UTC(), }) } diff --git a/apps/server/internal/database/migrations/000015_resource_folders.down.sql b/apps/server/internal/database/migrations/000015_resource_folders.down.sql new file mode 100644 index 0000000..a1070d4 --- /dev/null +++ b/apps/server/internal/database/migrations/000015_resource_folders.down.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS permissions_previous ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document')), + resource_id TEXT, + permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')), + granted_by TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL +); + +INSERT INTO permissions_previous (id, user_id, resource_type, resource_id, permission, granted_by, created_at) +SELECT id, user_id, resource_type, resource_id, permission, granted_by, created_at +FROM permissions +WHERE resource_type IN ('global', 'collection', 'document'); + +DROP TABLE permissions; +ALTER TABLE permissions_previous RENAME TO permissions; + +CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id); diff --git a/apps/server/internal/database/migrations/000015_resource_folders.up.sql b/apps/server/internal/database/migrations/000015_resource_folders.up.sql new file mode 100644 index 0000000..2207c97 --- /dev/null +++ b/apps/server/internal/database/migrations/000015_resource_folders.up.sql @@ -0,0 +1,21 @@ +CREATE TABLE IF NOT EXISTS permissions_next ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + resource_type TEXT NOT NULL CHECK(resource_type IN ('global', 'collection', 'document', 'folder')), + resource_id TEXT, + permission TEXT NOT NULL CHECK(permission IN ('read', 'write', 'admin')), + granted_by TEXT, + created_at TEXT NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY(granted_by) REFERENCES users(id) ON DELETE SET NULL +); + +INSERT INTO permissions_next (id, user_id, resource_type, resource_id, permission, granted_by, created_at) +SELECT id, user_id, resource_type, resource_id, permission, granted_by, created_at +FROM permissions; + +DROP TABLE permissions; +ALTER TABLE permissions_next RENAME TO permissions; + +CREATE INDEX IF NOT EXISTS idx_permissions_user ON permissions(user_id); +CREATE INDEX IF NOT EXISTS idx_permissions_resource ON permissions(resource_type, resource_id); diff --git a/apps/server/internal/docs/repository.go b/apps/server/internal/docs/repository.go index c09f42d..f1257ab 100644 --- a/apps/server/internal/docs/repository.go +++ b/apps/server/internal/docs/repository.go @@ -109,6 +109,38 @@ func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, pat return r.getDocumentByPath(ctx, path, true) } +func (r *Repository) GetDocumentByID(ctx context.Context, id string) (*DocumentRecord, error) { + var ( + record DocumentRecord + tagsJSON string + created string + updated string + archived sql.NullString + ) + err := r.db.QueryRowContext(ctx, ` + SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at + FROM documents + WHERE id = ? AND archived_at IS NULL + `, id).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived) + if err != nil { + return nil, err + } + if err := parseDocumentTimes(&record, created, updated); err != nil { + return nil, err + } + if archived.Valid && archived.String != "" { + t, err := time.Parse(time.RFC3339, archived.String) + if err != nil { + return nil, fmt.Errorf("parse document archived_at: %w", err) + } + record.ArchivedAt = &t + } + if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil { + return nil, fmt.Errorf("unmarshal document tags: %w", err) + } + return &record, nil +} + func (r *Repository) getDocumentByPath(ctx context.Context, path string, includeArchived bool) (*DocumentRecord, error) { var ( record DocumentRecord @@ -416,6 +448,32 @@ func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]Searc return results, rows.Err() } +func (r *Repository) SearchDocumentsByTag(ctx context.Context, tag string) ([]SearchResult, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT d.path, d.title + FROM documents d + WHERE EXISTS ( + SELECT 1 FROM json_each(d.tags) WHERE json_each.value = ? + ) AND d.archived_at IS NULL + ORDER BY d.path + `, tag) + if err != nil { + return nil, fmt.Errorf("search documents by tag: %w", err) + } + defer rows.Close() + + var results []SearchResult + for rows.Next() { + var result SearchResult + if err := rows.Scan(&result.Path, &result.Title); err != nil { + return nil, fmt.Errorf("scan tag search result: %w", err) + } + results = append(results, result) + } + + return results, rows.Err() +} + func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error { if _, err := r.db.ExecContext(ctx, ` UPDATE document_search SET content = ? diff --git a/apps/server/internal/httpserver/api_handlers_test.go b/apps/server/internal/httpserver/api_handlers_test.go index cacc44b..f927e43 100644 --- a/apps/server/internal/httpserver/api_handlers_test.go +++ b/apps/server/internal/httpserver/api_handlers_test.go @@ -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) } diff --git a/apps/server/internal/httpserver/collab_handlers.go b/apps/server/internal/httpserver/collab_handlers.go index 0766c3b..0dbaa84 100644 --- a/apps/server/internal/httpserver/collab_handlers.go +++ b/apps/server/internal/httpserver/collab_handlers.go @@ -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()}) diff --git a/apps/server/internal/httpserver/handlers.go b/apps/server/internal/httpserver/handlers.go index 7bc18d4..a2e4e6b 100644 --- a/apps/server/internal/httpserver/handlers.go +++ b/apps/server/internal/httpserver/handlers.go @@ -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) diff --git a/apps/server/internal/httpserver/handlers_test.go b/apps/server/internal/httpserver/handlers_test.go index 1dc6878..7b6b6dc 100644 --- a/apps/server/internal/httpserver/handlers_test.go +++ b/apps/server/internal/httpserver/handlers_test.go @@ -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 } diff --git a/apps/server/internal/httpserver/middleware.go b/apps/server/internal/httpserver/middleware.go index 164f395..ffb116f 100644 --- a/apps/server/internal/httpserver/middleware.go +++ b/apps/server/internal/httpserver/middleware.go @@ -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: diff --git a/apps/server/internal/httpserver/permissions_handlers.go b/apps/server/internal/httpserver/permissions_handlers.go new file mode 100644 index 0000000..acbc645 --- /dev/null +++ b/apps/server/internal/httpserver/permissions_handlers.go @@ -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) +} diff --git a/apps/server/internal/httpserver/server.go b/apps/server/internal/httpserver/server.go index 1841ce1..1d9bce0 100644 --- a/apps/server/internal/httpserver/server.go +++ b/apps/server/internal/httpserver/server.go @@ -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) diff --git a/apps/server/internal/httpserver/static/comments.js b/apps/server/internal/httpserver/static/comments.js index e8cfd37..b548c5c 100644 --- a/apps/server/internal/httpserver/static/comments.js +++ b/apps/server/internal/httpserver/static/comments.js @@ -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 = ` + + + Add comment + `; 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 = ` -
- ${escapeHtml(c.authorName || 'Anonymous')} - -
+
${escapeHtml(c.authorName || 'Anonymous')}
${escapeHtml(c.content)}
`; 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 = '

No comments yet.

'; + 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 = ` +
${escapeHtml(c.authorName)}
+
${escapeHtml(c.content)}
+ `; + 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(); } -})(); +})(); \ No newline at end of file diff --git a/apps/server/internal/httpserver/static/permissions.js b/apps/server/internal/httpserver/static/permissions.js new file mode 100644 index 0000000..742f43f --- /dev/null +++ b/apps/server/internal/httpserver/static/permissions.js @@ -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; + }); + }); +})(); diff --git a/apps/server/internal/httpserver/static/realtime.js b/apps/server/internal/httpserver/static/realtime.js index 6cb4d87..307b3a7 100644 --- a/apps/server/internal/httpserver/static/realtime.js +++ b/apps/server/internal/httpserver/static/realtime.js @@ -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); + }); + }); + }); + } })(); diff --git a/apps/server/internal/httpserver/static/site.css b/apps/server/internal/httpserver/static/site.css index 960e56c..bcf97c3 100644 --- a/apps/server/internal/httpserver/static/site.css +++ b/apps/server/internal/httpserver/static/site.css @@ -426,6 +426,7 @@ code { color: white; padding: 0.85rem 1.75rem; min-height: 3rem; + font-family: inherit; font-size: 1.05rem; font-weight: 600; box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent); @@ -546,7 +547,7 @@ code { min-height: 2.75rem; border: 3px solid var(--accent); border-radius: var(--radius-sm); - background: transparent; + background: color-mix(in srgb, var(--accent) 8%, transparent); color: var(--accent); font: inherit; font-size: 1rem; @@ -557,7 +558,7 @@ code { } .btn-cta:hover { - background: var(--accent-soft); + background: color-mix(in srgb, var(--accent) 16%, transparent); } .btn-cta:active { @@ -1089,7 +1090,201 @@ code { /* Document view: sidebar gets reasonable fixed proportion */ .workspace-shell--document { - grid-template-columns: minmax(260px, 0.38fr) minmax(0, 1fr); + grid-template-columns: minmax(260px, 0.38fr) minmax(0, 1fr) 1.25rem; +} + +.workspace-shell--document.comments-visible { + grid-template-columns: minmax(220px, 0.30fr) minmax(0, 1fr) minmax(280px, 24rem); +} + +.document-comments-aside { + display: flex; + flex-direction: row; + height: 100%; + max-height: 100%; + overflow: hidden; + background: var(--panel); +} + +/* Scrollbar-style strip */ +.document-comments-strip { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 1.25rem; + flex-shrink: 0; + border-left: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 94%, var(--text)); +} + +.document-comments-strip__toggle { + display: flex; + align-items: center; + justify-content: center; + width: 1rem; + height: 1rem; + margin-top: 0.35rem; + padding: 0; + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + opacity: 0.6; + transition: opacity 0.15s, color 0.15s; + flex-shrink: 0; +} + +.document-comments-strip__toggle:hover { + opacity: 1; + color: var(--accent); +} + +.document-comments-strip__toggle svg { + width: 0.75rem; + height: 0.75rem; +} + +/* Track area for comment markers */ +.document-comments-strip__track { + position: relative; + flex: 1; + width: 100%; + margin-top: 0.25rem; +} + +/* Comment markers on the strip */ +.document-comments-strip__marker { + position: absolute; + left: 50%; + transform: translateX(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 0.5rem; + height: 0.5rem; + padding: 0; + border: 0; + border-radius: 50%; + background: var(--accent); + color: white; + cursor: pointer; + opacity: 0.7; + transition: opacity 0.15s, transform 0.15s, width 0.15s, height 0.15s; +} + +.document-comments-strip__marker:hover, +.document-comments-strip__marker.is-active { + opacity: 1; + transform: translateX(-50%) scale(1.3); +} + +/* Panel */ +.document-comments-panel { + display: none; + flex-direction: column; + flex: 1; + min-width: 0; + border-left: 1px solid var(--border); + background: var(--panel); +} + +.comments-visible .document-comments-panel { + display: flex; +} + +.document-comments-panel__list { + flex: 1 1 auto; + overflow-y: auto; + padding: 0.5rem 0.75rem; +} + +/* Bidirectional hover highlighting */ +.comments-aside-item { + margin-bottom: 0.5rem; + padding: 0.6rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel-strong); + transition: border-color 0.15s, background 0.15s; +} + +.comments-aside-item.is-highlighted, +.comments-aside-item:hover { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 4%, var(--panel-strong)); +} + +.comments-aside-item__target { + margin: -0.6rem -0.6rem 0.4rem; + padding: 0.4rem 0.6rem; + border-bottom: 1px solid var(--border); + border-radius: var(--radius-sm) var(--radius-sm) 0 0; + background: var(--accent-soft); + font-size: 0.78rem; + color: var(--muted); + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + transition: background 0.15s; +} + +.comments-aside-item:hover .comments-aside-item__target, +.comments-aside-item.is-highlighted .comments-aside-item__target { + background: color-mix(in srgb, var(--accent) 12%, var(--accent-soft)); + color: var(--accent); +} + +.comments-aside-item__meta { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.15rem; + font-size: 0.72rem; + color: var(--muted); +} + +.comments-aside-item__body { + font-size: 0.85rem; + line-height: 1.4; +} + +.comments-aside-item__comment { + padding: 0.35rem 0; + border-bottom: 1px solid var(--border); +} + +.comments-aside-item__comment:last-child { + border-bottom: 0; +} + +.document-comments-aside__empty { + margin: 0; + padding: 1rem 0; + color: var(--muted); + font-size: 0.9rem; + text-align: center; +} + +/* Paragraph highlight when connected to sidebar */ +.commentable.is-highlighted { + background: var(--accent-soft); + transition: background 0.15s; +} + +@media (min-width: 769px) { + .comments-visible .comment-list { + display: none; + } +} + +/* Mobile-only elements hidden on desktop by default */ +.document-mobile-bar, +.mobile-drawer-backdrop, +.mobile-drawer-close, +.document-comments-sheet { + display: none; } /* Empty state: browser capped at 36% max */ @@ -1169,6 +1364,55 @@ code { text-overflow: ellipsis; } +.miller-column h2.has-actions { + display: flex; + align-items: center; + justify-content: space-between; +} + +.miller-column-title-wrap { + display: inline-flex; + align-items: center; + gap: 0.35rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.miller-column-actions { + display: inline-flex; + align-items: center; + gap: 0.1rem; + flex-shrink: 0; + margin-left: 0.5rem; +} + +.miller-column-actions button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0; + border: 0; + border-radius: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, color 0.15s, background 0.15s; +} + +.miller-column:hover .miller-column-actions button, +.miller-column-actions button:focus { + opacity: 1; +} + +.miller-column-actions button:hover { + color: var(--accent); + background: var(--accent-soft); +} + /* Middle column headers - show just first couple chars */ .miller-column:not(:first-child):not(:last-child) h2 { padding: 0.7rem 0.3rem; @@ -1335,6 +1579,15 @@ code { overflow-y: auto; } +.tag-preview { + max-width: 800px; + padding: 2rem; + border: 1px solid var(--border); + border-left: 0; + border-radius: 0; + background: var(--panel); +} + .eyebrow { margin: 0 0 0.8rem; color: var(--accent); @@ -1708,6 +1961,7 @@ code { .document-actions { display: flex; + flex-wrap: wrap; gap: 0.5rem; align-items: center; } @@ -1720,10 +1974,15 @@ code { padding: 0 0.9rem; border: 1px solid var(--border); color: var(--text); + font: inherit; text-decoration: none; background: var(--panel); } +.document-edit-link:hover { + background: color-mix(in srgb, var(--accent) 8%, transparent); +} + .document-archive-btn { display: inline-flex; align-items: center; @@ -2001,12 +2260,20 @@ code { /* Adaptive breakpoints */ @media (max-width: 1024px) { .workspace-shell, - .workspace-shell--document, .workspace-shell--empty { grid-template-columns: minmax(240px, 0.45fr) minmax(0, 1fr); gap: 0; } + .workspace-shell--document { + grid-template-columns: minmax(240px, 0.45fr) minmax(0, 1fr) 1.25rem; + gap: 0; + } + + .workspace-shell--document.comments-visible { + grid-template-columns: minmax(180px, 0.30fr) minmax(0, 1fr) 16rem; + } + .document-shell { padding: 1.25rem 1.5rem; } @@ -2018,10 +2285,10 @@ code { } .site-header__inner { - flex-direction: column; - align-items: flex-start; - justify-content: center; - gap: 0.5rem; + flex-direction: row; + align-items: center; + justify-content: space-between; + gap: 0.75rem; padding: 0.75rem 1rem; min-height: auto; } @@ -2038,11 +2305,150 @@ code { gap: 0; } + .workspace-shell--document.comments-visible { + grid-template-columns: 1fr; + } + + /* Mobile shell layout */ + .document-shell { + display: flex; + flex-direction: column; + padding: 0; + min-height: auto; + max-height: none; + border-left: 1px solid var(--border); + border-top: 0; + } + + /* Mobile top bar */ + .document-mobile-bar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + padding: 0.6rem 0.9rem; + border-bottom: 1px solid var(--border); + background: var(--panel); + } + + .document-mobile-bar__title { + flex: 1 1 auto; + min-width: 0; + font-weight: 600; + font-size: 0.95rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + text-align: center; + } + + .document-mobile-bar__btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.25rem; + height: 2.25rem; + padding: 0; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel-strong); + color: var(--text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; + } + + .document-mobile-bar__btn:hover { + background: color-mix(in srgb, var(--accent) 8%, var(--panel-strong)); + border-color: var(--accent); + color: var(--accent); + } + + .document-mobile-bar__btn svg { + width: 1.1rem; + height: 1.1rem; + } + + /* Breadcrumbs below mobile bar */ + .document-shell .breadcrumbs { + padding: 0.5rem 0.9rem; + border-bottom: 1px solid var(--border); + background: var(--panel); + font-size: 0.85rem; + } + + /* Backdrop for drawers */ + .mobile-drawer-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 180; + background: rgba(0, 0, 0, 0.35); + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; + } + + body.drawer-open .mobile-drawer-backdrop { + opacity: 1; + visibility: visible; + } + + /* Drawer close button */ + .mobile-drawer-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + padding: 0; + border: 0; + background: transparent; + color: var(--muted); + cursor: pointer; + transition: color 0.15s; + } + + .mobile-drawer-close:hover { + color: var(--accent); + } + + .mobile-drawer-close svg { + width: 1.1rem; + height: 1.1rem; + } + + .mobile-drawer-close--inside { + position: absolute; + top: 0.6rem; + right: 0.6rem; + z-index: 5; + } + + /* Left file picker drawer */ .miller-browser { - min-height: 16rem; - max-height: 50vh; + position: fixed; + left: 0; + top: 0; + bottom: 0; + width: min(82vw, 20rem); + min-height: auto; + max-height: none; + z-index: 200; border-right: 1px solid var(--border); border-bottom: 0; + border-radius: 0; + background: var(--panel); + transform: translateX(-100%); + transition: transform 0.25s ease; + } + + .miller-browser.is-open { + transform: translateX(0); + } + + .miller-browser .mobile-drawer-close--inside { + top: 0.5rem; + right: 0.5rem; } .miller-column, @@ -2082,12 +2488,49 @@ code { display: inline; } - .document-shell { - padding: 1.25rem; - min-height: auto; - max-height: none; - border-left: 1px solid var(--border); - border-top: 0; + /* Top metadata drawer */ + .document-meta { + position: fixed; + left: 0; + right: 0; + top: 0; + z-index: 200; + max-height: 70vh; + overflow-y: auto; + padding: 2.2rem 1rem 1rem; + border-bottom: 1px solid var(--border); + border-radius: 0 0 var(--radius) var(--radius); + background: var(--panel); + transform: translateY(-110%); + transition: transform 0.25s ease; + box-shadow: 0 12px 40px rgba(24, 32, 42, 0.12); + } + + .document-meta.is-open { + transform: translateY(0); + } + + .document-meta .mobile-drawer-close--inside { + top: 0.55rem; + right: 0.55rem; + } + + .document-meta__header { + align-items: flex-start; + flex-direction: column; + gap: 0.75rem; + } + + .document-actions { + width: 100%; + } + + .document-actions .document-edit-link, + .document-actions .document-archive-btn { + flex: 1 1 auto; + justify-content: center; + min-height: 2.2rem; + font-size: 0.9rem; } .meta-grid { @@ -2095,9 +2538,101 @@ code { } .document-meta-panel summary, - .document-meta__header { - align-items: flex-start; + .document-meta-panel summary::after { + display: none; + } + + .document-meta-panel { + display: block; + margin-top: 0.75rem; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel-strong); + } + + .document-meta-panel .meta-grid { + display: grid; + gap: 0.5rem; + } + + /* Document body padding */ + .document-shell .markdown-body { + padding: 1rem; + padding-bottom: calc(1rem + 64px); + } + + /* Comments bottom sheet */ + .document-comments-aside { + display: flex; + position: fixed; + left: 0; + right: 0; + bottom: 0; + height: 80vh; + max-height: none; + z-index: 200; flex-direction: column; + background: var(--panel); + border-top: 1px solid var(--border); + border-radius: 12px 12px 0 0; + transform: translateY(calc(100% - 48px)); + transition: transform 0.25s ease; + box-shadow: 0 -4px 24px rgba(24, 32, 42, 0.08); + } + + .document-comments-aside.is-open { + transform: translateY(0); + } + + .document-comments-aside .document-comments-strip, + .document-comments-aside .document-comments-panel { + display: none; + } + + .document-comments-sheet { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-height: 0; + } + + .document-comments-sheet__handle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + flex-shrink: 0; + height: 48px; + padding: 0 0.6rem; + border-bottom: 1px solid var(--border); + } + + .document-comments-sheet__toggle { + display: inline-flex; + align-items: center; + gap: 0.4rem; + padding: 0.35rem 0.6rem; + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: var(--text); + font: inherit; + font-size: 0.9rem; + cursor: pointer; + } + + .document-comments-sheet__toggle svg { + width: 1rem; + height: 1rem; + color: var(--accent); + } + + .document-comments-sheet__panel { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + padding: 0.5rem 0.75rem; } } @@ -2340,8 +2875,8 @@ code { position: absolute; right: -1.5rem; top: 0.25rem; - width: 1.2rem; - height: 1.2rem; + width: 1.28rem; + height: 1.28rem; display: flex; align-items: center; justify-content: center; @@ -2350,17 +2885,89 @@ code { border-radius: 50%; background: var(--panel-strong); color: var(--accent); - font-size: 0.8rem; - font-weight: 700; + opacity: 0.82; + overflow: visible; cursor: pointer; + transition: opacity 0.15s ease, transform 0.15s ease, background 0.15s ease, color 0.15s ease; +} + +.comment-anchor-btn svg { + width: 0.78rem; + height: 0.78rem; +} + +.comment-anchor-btn--has-comments { + background: var(--accent); + border-color: color-mix(in srgb, var(--accent) 55%, black); + color: white; + opacity: 1; +} + +.comment-anchor-btn--has-comments:hover, +.comment-anchor-btn--has-comments:focus-visible { + background: color-mix(in srgb, var(--accent) 88%, black); +} + +.comment-anchor-count { + position: absolute; + top: -0.3rem; + right: -0.4rem; + min-width: 1rem; + height: 1rem; + padding: 0 0.24rem; + border: 1px solid var(--panel-strong); + border-radius: 999px; + background: var(--text); + color: var(--panel-strong); + font-size: 0.62rem; + line-height: 1; + font-weight: 700; + display: inline-flex; + align-items: center; + justify-content: center; + box-shadow: var(--shadow); +} + +.comment-anchor-count[hidden] { + display: none; +} + +.comment-anchor-tooltip { + position: absolute; + top: -2rem; + right: -0.25rem; + z-index: 2; + padding: 0.2rem 0.45rem; + border: 1px solid var(--border); + border-radius: 999px; + background: var(--panel-strong); + color: var(--text); + font-size: 0.72rem; + line-height: 1; + white-space: nowrap; + box-shadow: var(--shadow); opacity: 0; - transition: opacity 0.15s ease; + transform: translateY(0.2rem); + pointer-events: none; + transition: opacity 0.15s ease, transform 0.15s ease; } .commentable:hover .comment-anchor-btn { opacity: 1; } +.comment-anchor-btn:hover, +.comment-anchor-btn:focus-visible { + opacity: 1; + transform: translateY(-1px); +} + +.comment-anchor-btn:hover .comment-anchor-tooltip, +.comment-anchor-btn:focus-visible .comment-anchor-tooltip { + opacity: 1; + transform: translateY(0); +} + .comment-list { margin-top: 0.5rem; padding: 0.5rem; @@ -2369,6 +2976,10 @@ code { background: var(--panel); } +.comments-hidden .comment-list { + display: none; +} + .comment-item { padding: 0.5rem 0; border-bottom: 1px solid var(--border); @@ -2395,3 +3006,263 @@ code { font-size: 0.9rem; line-height: 1.5; } + +.comment-popup { + position: absolute; + left: 50%; + top: calc(100% + 0.4rem); + transform: translateX(-50%) translateY(0.25rem); + z-index: 100; + width: min(22rem, calc(100vw - 2rem)); + max-height: 18rem; + padding: 0.6rem 0.75rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel-strong); + box-shadow: var(--shadow); + opacity: 0; + pointer-events: none; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease, transform 0.2s ease; +} + +.comment-popup--visible { + opacity: 1; + pointer-events: auto; + visibility: visible; + transform: translateX(-50%) translateY(0); +} + +.comment-popup__title { + margin-bottom: 0.4rem; + padding-bottom: 0.4rem; + border-bottom: 1px solid var(--border); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--muted); +} + +.comment-popup__list { + max-height: 14rem; + overflow-y: auto; +} + +.comment-popup__item { + padding: 0.45rem 0; + border-bottom: 1px solid var(--border); +} + +.comment-popup__item:last-child { + border-bottom: 0; +} + +.comment-popup__meta { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.2rem; + font-size: 0.78rem; + color: var(--muted); +} + +.comment-popup__meta strong { + color: var(--text); +} + +.comment-popup__body { + font-size: 0.88rem; + line-height: 1.45; + color: var(--text); +} + +.permissions-shell { + width: min(100%, 1120px); + margin: 0 auto; + padding: clamp(1rem, 3vw, 2rem); +} + +.permissions-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + margin-bottom: 1rem; +} + +.permissions-header h1 { + margin: 0; + font-size: clamp(1.5rem, 3vw, 2.1rem); +} + +.permissions-form { + display: grid; + gap: 1rem; +} + +.permissions-panel { + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel); + box-shadow: var(--shadow); +} + +.permissions-panel__header { + display: flex; + align-items: end; + justify-content: space-between; + gap: 1rem; + padding: 1rem 1.1rem; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-strong) 62%, transparent); +} + +.permissions-panel__header h2 { + margin: 0.15rem 0 0; + font-size: 1.1rem; +} + +.permission-bulk-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-end; + gap: 0.55rem; +} + +.permission-select-all { + display: inline-flex; + align-items: center; + gap: 0.45rem; + color: var(--muted); + font-size: 0.9rem; +} + +.permission-select-all input, +.permission-user-check { + width: 1rem; + height: 1rem; + accent-color: var(--accent); +} + +.permission-bulk-actions select, +.permission-user-row select { + min-height: 2.35rem; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--panel-strong); + color: var(--text); + font: inherit; +} + +.permission-bulk-actions select { + min-width: 9rem; + padding: 0.5rem 0.65rem; +} + +.permission-apply { + min-height: 2.35rem; + padding: 0.45rem 0.8rem; +} + +.permission-user-list { + display: grid; +} + +.permission-user-row { + display: grid; + grid-template-columns: auto minmax(14rem, 1.4fr) minmax(13rem, 1fr) minmax(9rem, 12rem); + gap: 0.85rem; + align-items: center; + padding: 0.85rem 1.1rem; + border-bottom: 1px solid var(--border); + background: transparent; +} + +.permission-user-row:hover { + background: color-mix(in srgb, var(--accent) 5%, transparent); +} + +.permission-user-row:last-child { + border-bottom: 0; +} + +.permission-user-main, +.permission-grant-meta { + display: grid; + min-width: 0; + gap: 0.25rem; +} + +.permission-user-name { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 0.5rem; +} + +.permission-user-name em { + color: var(--accent); + font: 700 0.68rem/1.2 ui-monospace, SFMono-Regular, monospace; + font-style: normal; + letter-spacing: 0; + text-transform: uppercase; +} + +.permission-user-email, +.permission-user-meta, +.permission-grant-meta small { + overflow: hidden; + color: var(--muted); + font-size: 0.88rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.permission-grant-meta strong { + font-size: 0.94rem; +} + +.permission-select-wrap { + min-width: 0; +} + +.permission-user-row select { + width: 100%; + padding: 0.5rem 0.65rem; +} + +.permissions-footer { + display: flex; + justify-content: flex-end; + padding: 1rem 1.1rem; + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-strong) 50%, transparent); +} + +@media (max-width: 620px) { + .permissions-header { + display: grid; + } + + .permissions-panel__header, + .permission-user-row { + grid-template-columns: 1fr; + } + + .permissions-panel__header { + align-items: start; + } + + .permission-bulk-actions { + justify-content: stretch; + } + + .permission-bulk-actions select, + .permission-apply, + .permissions-footer .btn-primary { + width: 100%; + } +} diff --git a/apps/server/internal/httpserver/static/sw.js b/apps/server/internal/httpserver/static/sw.js index 09881ed..f4358c0 100644 --- a/apps/server/internal/httpserver/static/sw.js +++ b/apps/server/internal/httpserver/static/sw.js @@ -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; diff --git a/apps/server/internal/httpserver/static/tags.js b/apps/server/internal/httpserver/static/tags.js new file mode 100644 index 0000000..b6a74cf --- /dev/null +++ b/apps/server/internal/httpserver/static/tags.js @@ -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 ( + '
  • ' + + escapeHtml(name) + + "
  • " + ); + }) + .join(""); + + browser.innerHTML = + '

    #' + + escapeHtml(tag) + + "

    "; + } + + if (preview) { + preview.outerHTML = + '

    Tag

    #' + + escapeHtml(tag) + + '

    ' + + tagged.length + + " document" + + (tagged.length === 1 ? "" : "s") + + " found

    "; + } + }); + + function escapeHtml(str) { + if (!str) return ""; + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + } +})(); diff --git a/apps/server/internal/httpserver/sync_handlers.go b/apps/server/internal/httpserver/sync_handlers.go index 68b4e92..124ce8b 100644 --- a/apps/server/internal/httpserver/sync_handlers.go +++ b/apps/server/internal/httpserver/sync_handlers.go @@ -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 } +} diff --git a/apps/server/internal/httpserver/templates/base.gohtml b/apps/server/internal/httpserver/templates/base.gohtml index dea3cf5..8888fbe 100644 --- a/apps/server/internal/httpserver/templates/base.gohtml +++ b/apps/server/internal/httpserver/templates/base.gohtml @@ -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 @@ + + diff --git a/apps/server/internal/httpserver/templates/document.gohtml b/apps/server/internal/httpserver/templates/document.gohtml index 9b229c6..61c56cb 100644 --- a/apps/server/internal/httpserver/templates/document.gohtml +++ b/apps/server/internal/httpserver/templates/document.gohtml @@ -2,8 +2,18 @@ {{ define "document_content" }}
    + {{ template "browser" .Browser }} -
    +
    +
    + + + +
    -
    +
    +

    {{ .Title }}

    - {{ if .CanWrite }}
    - Edit - + {{ if .CanWrite }} + Edit + {{ if .CanAdmin }} + Permissions + {{ end }} + + {{ end }}
    - {{ end }}
    @@ -61,18 +77,74 @@ {{ .HTML }}
    +
    {{ end }} {{ define "browser" }} -