ops design and app
add more comments feature, more tags feature, and other frontend updates
This commit is contained in:
BIN
apps/server/cairnquire
Executable file
BIN
apps/server/cairnquire
Executable file
Binary file not shown.
@@ -169,3 +169,7 @@ func hasScope(scopes []Scope, required Scope) bool {
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func HasScope(scopes []Scope, required Scope) bool {
|
||||
return hasScope(scopes, required)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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 = ?
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
400
apps/server/internal/httpserver/permissions_handlers.go
Normal file
400
apps/server/internal/httpserver/permissions_handlers.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = `
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
||||
<path d="M12 8v4"></path>
|
||||
<path d="M10 10h4"></path>
|
||||
</svg>
|
||||
<span class="comment-anchor-count" aria-hidden="true" hidden></span>
|
||||
<span class="sr-only">Add comment</span>
|
||||
`;
|
||||
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 = `
|
||||
<div class="comment-meta">
|
||||
<strong>${escapeHtml(c.authorName || 'Anonymous')}</strong>
|
||||
<time>${formatDate(c.createdAt)}</time>
|
||||
</div>
|
||||
<div class="comment-meta"><strong>${escapeHtml(c.authorName || 'Anonymous')}</strong><time>${formatDate(c.createdAt)}</time></div>
|
||||
<div class="comment-body">${escapeHtml(c.content)}</div>
|
||||
`;
|
||||
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 = '<p class="document-comments-aside__empty">No comments yet.</p>';
|
||||
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 = `
|
||||
<div class="comments-aside-item__meta"><strong>${escapeHtml(c.authorName)}</strong><time>${escapeHtml(c.createdAt)}</time></div>
|
||||
<div class="comments-aside-item__body">${escapeHtml(c.content)}</div>
|
||||
`;
|
||||
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();
|
||||
}
|
||||
})();
|
||||
})();
|
||||
36
apps/server/internal/httpserver/static/permissions.js
Normal file
36
apps/server/internal/httpserver/static/permissions.js
Normal file
@@ -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;
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
|
||||
81
apps/server/internal/httpserver/static/tags.js
Normal file
81
apps/server/internal/httpserver/static/tags.js
Normal file
@@ -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 (
|
||||
'<li><a href="/docs/' +
|
||||
escapeHtml(path) +
|
||||
'" title="' +
|
||||
escapeHtml(name) +
|
||||
'"><span class="browser-item-label"><svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path><path d="M14 2v4a2 2 0 0 0 2 2h4"></path></svg><span class="browser-item-name">' +
|
||||
escapeHtml(name) +
|
||||
"</span></span></a></li>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
|
||||
browser.innerHTML =
|
||||
'<section class="miller-column miller-column--root" aria-label="#' +
|
||||
escapeHtml(tag) +
|
||||
'"><h2><span class="miller-column-title-wrap"><span class="miller-column-title-text">#' +
|
||||
escapeHtml(tag) +
|
||||
"</span></span></h2><ul>" +
|
||||
itemsHtml +
|
||||
"</ul></section>";
|
||||
}
|
||||
|
||||
if (preview) {
|
||||
preview.outerHTML =
|
||||
'<section class="tag-preview"><p class="eyebrow">Tag</p><h1>#' +
|
||||
escapeHtml(tag) +
|
||||
'</h1><p class="lede">' +
|
||||
tagged.length +
|
||||
" document" +
|
||||
(tagged.length === 1 ? "" : "s") +
|
||||
" found</p></section>";
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
if (!str) return "";
|
||||
return str
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
})();
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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 @@
|
||||
<script src="/static/render.js" defer></script>
|
||||
<script src="/static/comments.js" defer></script>
|
||||
<script src="/static/archive.js" defer></script>
|
||||
<script src="/static/permissions.js" defer></script>
|
||||
<script src="/static/tags.js" defer></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
|
||||
@@ -2,8 +2,18 @@
|
||||
|
||||
{{ define "document_content" }}
|
||||
<section class="workspace-shell workspace-shell--document">
|
||||
<div class="mobile-drawer-backdrop" data-mobile-drawer-backdrop aria-hidden="true"></div>
|
||||
{{ template "browser" .Browser }}
|
||||
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}">
|
||||
<article class="document-shell" data-document-path="{{ .Path }}" data-document-hash="{{ .Hash }}" data-can-comment="{{ .CanComment }}">
|
||||
<div class="document-mobile-bar">
|
||||
<button class="document-mobile-bar__btn document-mobile-bar__btn--picker" type="button" data-toggle-picker aria-label="Open file picker" aria-expanded="false">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"></line><line x1="4" y1="12" x2="20" y2="12"></line><line x1="4" y1="18" x2="20" y2="18"></line></svg>
|
||||
</button>
|
||||
<span class="document-mobile-bar__title" aria-hidden="true">{{ .Title }}</span>
|
||||
<button class="document-mobile-bar__btn document-mobile-bar__btn--meta" type="button" data-toggle-meta aria-label="Open document info" aria-expanded="false">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>
|
||||
</button>
|
||||
</div>
|
||||
<nav class="breadcrumbs" aria-label="Breadcrumb">
|
||||
<ol>
|
||||
{{ $lastIdx := sub (len .Breadcrumbs) 1 }}
|
||||
@@ -18,15 +28,21 @@
|
||||
{{ end }}
|
||||
</ol>
|
||||
</nav>
|
||||
<div class="document-meta">
|
||||
<div class="document-meta" data-meta-drawer>
|
||||
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-meta aria-label="Close document info">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
<div class="document-meta__header">
|
||||
<h1>{{ .Title }}</h1>
|
||||
{{ if .CanWrite }}
|
||||
<div class="document-actions">
|
||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
|
||||
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
|
||||
{{ if .CanWrite }}
|
||||
<a class="document-edit-link" href="/docs/{{ trimMd .Path }}/edit">Edit</a>
|
||||
{{ if .CanAdmin }}
|
||||
<a class="document-edit-link" href="/permissions?resourceType=document&resourceId={{ .Path }}">Permissions</a>
|
||||
{{ end }}
|
||||
<button class="document-archive-btn" type="button" data-archive-path="{{ .Path }}" aria-label="Archive document">Archive</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
<details class="document-meta-panel">
|
||||
<summary>
|
||||
@@ -61,18 +77,74 @@
|
||||
{{ .HTML }}
|
||||
</div>
|
||||
</article>
|
||||
<aside class="document-comments-aside" data-comments-aside aria-label="Comments">
|
||||
<div class="document-comments-strip" data-comments-strip>
|
||||
<button
|
||||
class="document-comments-strip__toggle"
|
||||
type="button"
|
||||
data-comment-toggle
|
||||
aria-pressed="false"
|
||||
aria-label="Toggle comments panel"
|
||||
title="Toggle comments"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="document-comments-strip__track" data-comments-strip-track></div>
|
||||
</div>
|
||||
<div class="document-comments-panel">
|
||||
<div class="document-comments-panel__list" data-comments-aside-list></div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile bottom sheet -->
|
||||
<div class="document-comments-sheet" data-comments-sheet>
|
||||
<div class="document-comments-sheet__handle">
|
||||
<button class="document-comments-sheet__toggle" type="button" data-toggle-comments aria-expanded="false" aria-label="Toggle comments">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H8l-5 4V7a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"></path>
|
||||
</svg>
|
||||
<span>Comments</span>
|
||||
</button>
|
||||
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-comments aria-label="Close comments">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="document-comments-sheet__panel">
|
||||
<div class="document-comments-panel__list" data-comments-aside-list-mobile></div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
{{ end }}
|
||||
|
||||
{{ define "browser" }}
|
||||
<nav class="miller-browser" aria-label="Documents">
|
||||
<nav class="miller-browser" aria-label="Documents" data-picker-drawer>
|
||||
<button class="mobile-drawer-close mobile-drawer-close--inside" type="button" data-close-picker aria-label="Close file picker">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
|
||||
</button>
|
||||
{{ range .Columns }}
|
||||
<section class="miller-column {{ if eq .Title "Content" }}miller-column--root{{ end }}" aria-label="{{ .Title }}">
|
||||
<h2 title="{{ .Title }}">
|
||||
{{ if eq .Title "Content" }}
|
||||
{{ template "icon-library" }}
|
||||
<h2 title="{{ .Title }}" {{ if $.CanWrite }}class="has-actions"{{ end }}>
|
||||
<span class="miller-column-title-wrap">
|
||||
{{ if eq .Title "Content" }}
|
||||
{{ template "icon-library" }}
|
||||
{{ end }}
|
||||
<span class="miller-column-title-text">{{ .Title }}</span>
|
||||
</span>
|
||||
{{ if $.CanWrite }}
|
||||
<span class="miller-column-actions">
|
||||
<button type="button" class="miller-column-new-file" data-folder-path="{{ .Path }}" aria-label="New file in {{ .Title }}">
|
||||
{{ template "icon-file-plus" }}
|
||||
</button>
|
||||
<button type="button" class="miller-column-add-folder" data-folder-path="{{ .Path }}" aria-label="Add folder to {{ .Title }}">
|
||||
{{ template "icon-folder-plus" }}
|
||||
</button>
|
||||
</span>
|
||||
{{ end }}
|
||||
<span class="miller-column-title-text">{{ .Title }}</span>
|
||||
</h2>
|
||||
<ul>
|
||||
{{ range .Items }}
|
||||
@@ -117,6 +189,23 @@
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
{{ define "icon-folder-plus" }}
|
||||
<svg class="browser-icon browser-icon--folder" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.7-.9l-.8-1.2A2 2 0 0 0 7.9 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
|
||||
<line x1="12" y1="11" x2="12" y2="17"></line>
|
||||
<line x1="9" y1="14" x2="15" y2="14"></line>
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
{{ define "icon-file-plus" }}
|
||||
<svg class="browser-icon browser-icon--page" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"></path>
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4"></path>
|
||||
<line x1="12" y1="11" x2="12" y2="17"></line>
|
||||
<line x1="9" y1="14" x2="15" y2="14"></line>
|
||||
</svg>
|
||||
{{ end }}
|
||||
|
||||
{{ define "icon-chevron-right" }}
|
||||
<svg class="browser-item-chevron" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 18 6-6-6-6"></path>
|
||||
|
||||
71
apps/server/internal/httpserver/templates/permissions.gohtml
Normal file
71
apps/server/internal/httpserver/templates/permissions.gohtml
Normal file
@@ -0,0 +1,71 @@
|
||||
{{ define "permissions.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "permissions_content" }}
|
||||
<section class="permissions-shell">
|
||||
<header class="permissions-header">
|
||||
<div>
|
||||
<p class="eyebrow">Access control</p>
|
||||
<h1>{{ .Title }}</h1>
|
||||
</div>
|
||||
{{ if eq .ResourceType "document" }}
|
||||
<a class="account-action" href="/docs/{{ trimMd .ResourceID }}">Back to page</a>
|
||||
{{ end }}
|
||||
</header>
|
||||
|
||||
<form class="permissions-form" method="post" action="/permissions">
|
||||
<input type="hidden" name="resourceType" value="{{ .ResourceType }}" />
|
||||
<input type="hidden" name="resourceId" value="{{ .ResourceID }}" />
|
||||
<section class="permissions-panel">
|
||||
<header class="permissions-panel__header">
|
||||
<div>
|
||||
<p class="account-section__kicker">People</p>
|
||||
<h2>User permissions</h2>
|
||||
</div>
|
||||
<div class="permission-bulk-actions" data-permission-bulk>
|
||||
<label class="permission-select-all">
|
||||
<input type="checkbox" data-permission-select-all />
|
||||
<span>Select all</span>
|
||||
</label>
|
||||
<select data-permission-bulk-level aria-label="Access level for selected users">
|
||||
<option value="">No access</option>
|
||||
<option value="read">View</option>
|
||||
<option value="write">Edit</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
<button class="btn-secondary permission-apply" type="button" data-permission-apply>Apply</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="permission-user-list">
|
||||
{{ range .Users }}
|
||||
<label class="permission-user-row">
|
||||
<input class="permission-user-check" type="checkbox" data-permission-user />
|
||||
<span class="permission-user-main">
|
||||
<span class="permission-user-name">
|
||||
<strong>{{ .DisplayName }}</strong>
|
||||
<em>{{ .Role }}</em>
|
||||
</span>
|
||||
<span class="permission-user-email">{{ .Email }}</span>
|
||||
<span class="permission-user-meta">Created {{ .CreatedAt }} · Last seen {{ .LastSeenAt }}</span>
|
||||
</span>
|
||||
<span class="permission-grant-meta">
|
||||
<strong>{{ .PermissionText }}</strong>
|
||||
<small>{{ .GrantSummary }}</small>
|
||||
</span>
|
||||
<span class="permission-select-wrap">
|
||||
<select name="permission:{{ .ID }}" aria-label="Permission for {{ .DisplayName }}" data-permission-select>
|
||||
<option value="" {{ if eq .Permission "" }}selected{{ end }}>No access</option>
|
||||
<option value="read" {{ if eq .Permission "read" }}selected{{ end }}>View</option>
|
||||
<option value="write" {{ if eq .Permission "write" }}selected{{ end }}>Edit</option>
|
||||
<option value="admin" {{ if eq .Permission "admin" }}selected{{ end }}>Admin</option>
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
{{ end }}
|
||||
</div>
|
||||
<div class="permissions-footer">
|
||||
<button class="btn-primary" type="submit">Save permissions</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</section>
|
||||
{{ end }}
|
||||
12
apps/server/internal/httpserver/templates/tag.gohtml
Normal file
12
apps/server/internal/httpserver/templates/tag.gohtml
Normal file
@@ -0,0 +1,12 @@
|
||||
{{ define "tag.gohtml" }}{{ template "base" . }}{{ end }}
|
||||
|
||||
{{ define "tag_content" }}
|
||||
<section class="workspace-shell workspace-shell--empty">
|
||||
{{ template "browser" .Browser }}
|
||||
<section class="tag-preview">
|
||||
<p class="eyebrow">Tag</p>
|
||||
<h1>#{{ .Tag }}</h1>
|
||||
<p class="lede">{{ len .Results }} document{{ if ne (len .Results) 1 }}s{{ end }} found</p>
|
||||
</section>
|
||||
</section>
|
||||
{{ end }}
|
||||
@@ -31,6 +31,9 @@ type Service struct {
|
||||
sourceDir string
|
||||
}
|
||||
|
||||
type FileAccessFunc func(FileEntry) bool
|
||||
type PathAccessFunc func(string) bool
|
||||
|
||||
// NewService creates a new sync service.
|
||||
func NewService(repo *Repository, docService *docs.Service, contentStore *store.ContentStore, sourceDir string, logger *slog.Logger) *Service {
|
||||
return &Service{
|
||||
@@ -44,10 +47,15 @@ func NewService(repo *Repository, docService *docs.Service, contentStore *store.
|
||||
|
||||
// InitSync creates a new snapshot from the current server state.
|
||||
func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snapshot, error) {
|
||||
return s.InitSyncFiltered(ctx, deviceID, userID, nil)
|
||||
}
|
||||
|
||||
func (s *Service) InitSyncFiltered(ctx context.Context, deviceID, userID string, canRead FileAccessFunc) (*Snapshot, error) {
|
||||
files, err := s.buildSnapshotFromDisk(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build snapshot: %w", err)
|
||||
}
|
||||
files = filterFiles(files, canRead)
|
||||
|
||||
snap, err := s.repo.CreateSnapshot(ctx, deviceID, userID, files)
|
||||
if err != nil {
|
||||
@@ -60,6 +68,10 @@ func (s *Service) InitSync(ctx context.Context, deviceID, userID string) (*Snaps
|
||||
|
||||
// ApplyDelta processes client changes and computes server delta + conflicts.
|
||||
func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, clientDelta Delta) (*DeltaResult, error) {
|
||||
return s.ApplyDeltaFiltered(ctx, snapshotID, userID, clientDelta, nil, nil)
|
||||
}
|
||||
|
||||
func (s *Service) ApplyDeltaFiltered(ctx context.Context, snapshotID, userID string, clientDelta Delta, canRead FileAccessFunc, canWrite PathAccessFunc) (*DeltaResult, error) {
|
||||
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get snapshot: %w", err)
|
||||
@@ -67,6 +79,14 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
|
||||
if snap.UserID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
for _, clientChange := range clientDelta.Changes {
|
||||
if canWrite != nil && !canWrite(clientChange.Path) {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if clientChange.OldPath != "" && canWrite != nil && !canWrite(clientChange.OldPath) {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
}
|
||||
|
||||
serverFiles := make(map[string]FileEntry)
|
||||
for _, f := range snap.Files {
|
||||
@@ -91,6 +111,7 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build current snapshot: %w", err)
|
||||
}
|
||||
currentFiles = filterFiles(currentFiles, canRead)
|
||||
|
||||
currentMap := make(map[string]FileEntry)
|
||||
for _, f := range currentFiles {
|
||||
@@ -186,6 +207,7 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build post-delta snapshot: %w", err)
|
||||
}
|
||||
latestFiles = filterFiles(latestFiles, canRead)
|
||||
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, latestFiles)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create post-delta snapshot: %w", err)
|
||||
@@ -200,6 +222,10 @@ func (s *Service) ApplyDelta(ctx context.Context, snapshotID, userID string, cli
|
||||
|
||||
// ResolveConflicts applies resolved changes and creates a new snapshot.
|
||||
func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID string, resolutions []Resolution) (*Snapshot, error) {
|
||||
return s.ResolveConflictsFiltered(ctx, snapshotID, userID, resolutions, nil, nil)
|
||||
}
|
||||
|
||||
func (s *Service) ResolveConflictsFiltered(ctx context.Context, snapshotID, userID string, resolutions []Resolution, canRead FileAccessFunc, canWrite PathAccessFunc) (*Snapshot, error) {
|
||||
snap, err := s.repo.GetSnapshot(ctx, snapshotID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get snapshot: %w", err)
|
||||
@@ -207,6 +233,14 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
|
||||
if snap.UserID != userID {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
for _, resolution := range resolutions {
|
||||
if canWrite != nil && !canWrite(resolution.Path) {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
if resolution.NewPath != "" && canWrite != nil && !canWrite(resolution.NewPath) {
|
||||
return nil, ErrForbidden
|
||||
}
|
||||
}
|
||||
|
||||
// Get current server state
|
||||
if _, err := s.buildSnapshotFromDisk(ctx); err != nil {
|
||||
@@ -255,6 +289,7 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build resolved snapshot: %w", err)
|
||||
}
|
||||
files = filterFiles(files, canRead)
|
||||
|
||||
newSnap, err := s.repo.CreateSnapshot(ctx, snap.DeviceID, snap.UserID, files)
|
||||
if err != nil {
|
||||
@@ -265,6 +300,19 @@ func (s *Service) ResolveConflicts(ctx context.Context, snapshotID, userID strin
|
||||
return newSnap, nil
|
||||
}
|
||||
|
||||
func filterFiles(files []FileEntry, canRead FileAccessFunc) []FileEntry {
|
||||
if canRead == nil {
|
||||
return files
|
||||
}
|
||||
filtered := make([]FileEntry, 0, len(files))
|
||||
for _, file := range files {
|
||||
if canRead(file) {
|
||||
filtered = append(filtered, file)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// GetContent returns raw file content by hash.
|
||||
func (s *Service) GetContent(hash string) ([]byte, error) {
|
||||
if !isSHA256Hex(hash) {
|
||||
|
||||
Reference in New Issue
Block a user