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