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 = ` -
+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 = ` + +Tag
' + + tagged.length + + " document" + + (tagged.length === 1 ? "" : "s") + + " found