This commit is contained in:
Leon Bösche
2026-01-09 17:01:41 +01:00
parent 6a0c5780fd
commit e9df8f7d9f
10 changed files with 772 additions and 24 deletions

View File

@@ -78,6 +78,18 @@ type Activity struct {
Timestamp time.Time
}
type File struct {
ID uuid.UUID
OrgID *uuid.UUID
UserID *uuid.UUID
Name string
Path string
Type string
Size int64
LastModified time.Time
CreatedAt time.Time
}
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
@@ -233,6 +245,140 @@ func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership,
return memberships, rows.Err()
}
// GetOrgFiles returns files for a given organization (top-level folder listing)
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 100
}
offset := (page - 1) * pageSize
// Basic search and pagination. Returns files under the given path (including nested).
rows, err := db.QueryContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
FROM files
WHERE org_id = $1 AND path != $2 AND path LIKE $2 || '/%'
AND ($4 = '' OR name ILIKE '%' || $4 || '%')
ORDER BY name
LIMIT $5 OFFSET $6
`, orgID, path, path, q, pageSize, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
return files, rows.Err()
}
// GetUserFiles returns files for a user's personal workspace at a given path
func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 100
}
offset := (page - 1) * pageSize
rows, err := db.QueryContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
FROM files
WHERE user_id = $1 AND path != $2 AND path LIKE $2 || '/%'
AND ($4 = '' OR name ILIKE '%' || $4 || '%')
ORDER BY name
LIMIT $5 OFFSET $6
`, userID, path, path, q, pageSize, offset)
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
return files, rows.Err()
}
// CreateFile inserts a file or folder record. orgID or userID may be nil.
func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, name, path, fileType string, size int64) (*File, error) {
var f File
var orgIDVal interface{}
var userIDVal interface{}
if orgID != nil {
orgIDVal = *orgID
} else {
orgIDVal = nil
}
if userID != nil {
userIDVal = *userID
} else {
userIDVal = nil
}
err := db.QueryRowContext(ctx, `
INSERT INTO files (org_id, user_id, name, path, type, size)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
`, orgIDVal, userIDVal, name, path, fileType, size).Scan(&f.ID, new(sql.NullString), new(sql.NullString), &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt)
if err != nil {
return nil, err
}
return &f, nil
}
// DeleteFileByPath removes a file or folder matching path for a given org or user
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
var res sql.Result
var err error
if orgID != nil {
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path)
} else if userID != nil {
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path)
} else {
return nil
}
if err != nil {
return err
}
_, _ = res.RowsAffected()
return nil
}
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, role string) error {
_, err := db.ExecContext(ctx, `
UPDATE memberships

View File

@@ -2,9 +2,13 @@ package http
import (
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"fmt"
"go.b0esche.cloud/backend/internal/audit"
"go.b0esche.cloud/backend/internal/auth"
@@ -64,6 +68,22 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
r.Route("/", func(r chi.Router) {
r.Use(middleware.Auth(jwtManager, db))
// User-scoped routes (personal workspace)
r.Get("/user/files", func(w http.ResponseWriter, req *http.Request) {
userFilesHandler(w, req, db)
})
// Create / delete in user workspace
r.Post("/user/files", func(w http.ResponseWriter, req *http.Request) {
createUserFileHandler(w, req, db, auditLogger)
})
r.Delete("/user/files", func(w http.ResponseWriter, req *http.Request) {
deleteUserFileHandler(w, req, db, auditLogger)
})
// POST wrapper for delete
r.Post("/user/files/delete", func(w http.ResponseWriter, req *http.Request) {
deleteUserFilePostHandler(w, req, db, auditLogger)
})
// Org routes
r.Get("/orgs", func(w http.ResponseWriter, req *http.Request) {
listOrgsHandler(w, req, db, jwtManager)
@@ -78,7 +98,21 @@ func NewRouter(cfg *config.Config, db *database.DB, jwtManager *jwt.Manager, aut
// File routes
r.With(middleware.Permission(db, auditLogger, permission.FileRead)).Get("/files", func(w http.ResponseWriter, req *http.Request) {
listFilesHandler(w, req)
listFilesHandler(w, req, db)
})
// Create file/folder in org workspace
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files", func(w http.ResponseWriter, req *http.Request) {
createOrgFileHandler(w, req, db, auditLogger)
})
// Also accept POST delete for clients that cannot send DELETE with body
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Post("/files/delete", func(w http.ResponseWriter, req *http.Request) {
deleteOrgFilePostHandler(w, req, db, auditLogger)
})
// Delete file/folder in org workspace (body: {"path":"/path"})
r.With(middleware.Permission(db, auditLogger, permission.FileWrite)).Delete("/files", func(w http.ResponseWriter, req *http.Request) {
deleteOrgFileHandler(w, req, db, auditLogger)
})
r.Route("/files/{fileId}", func(r chi.Router) {
r.With(middleware.Permission(db, auditLogger, permission.DocumentView)).Get("/view", func(w http.ResponseWriter, req *http.Request) {
@@ -223,21 +257,45 @@ func createOrgHandler(w http.ResponseWriter, r *http.Request, db *database.DB, a
json.NewEncoder(w).Encode(org)
}
func listFilesHandler(w http.ResponseWriter, r *http.Request) {
// Mock files
files := []struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Size int `json:"size"`
LastModified string `json:"lastModified"`
}{
{"test.pdf", "/test.pdf", "file", 1234, "2023-01-01T00:00:00Z"},
{"folder", "/folder", "folder", 0, "2023-01-01T00:00:00Z"},
func listFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
// Org ID is provided by middleware.Org
orgID := r.Context().Value("org").(uuid.UUID)
// Query params: path, q (search), page, pageSize
path := r.URL.Query().Get("path")
if path == "" {
path = "/"
}
q := r.URL.Query().Get("q")
page := 1
pageSize := 100
if p := r.URL.Query().Get("page"); p != "" {
fmt.Sscanf(p, "%d", &page)
}
if ps := r.URL.Query().Get("pageSize"); ps != "" {
fmt.Sscanf(ps, "%d", &pageSize)
}
files, err := db.GetOrgFiles(r.Context(), orgID, path, q, page, pageSize)
if err != nil {
errors.LogError(r, err, "Failed to get org files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Convert to a JSON-friendly shape expected by frontend
out := make([]map[string]interface{}, 0, len(files))
for _, f := range files {
out = append(out, map[string]interface{}{
"name": f.Name,
"path": f.Path,
"type": f.Type,
"size": f.Size,
"lastModified": f.LastModified.UTC().Format(time.RFC3339),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(files)
json.NewEncoder(w).Encode(out)
}
func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
@@ -735,3 +793,327 @@ func passwordLoginHandler(w http.ResponseWriter, r *http.Request, db *database.D
"user": user,
})
}
// userFilesHandler returns files for the authenticated user's personal workspace.
func userFilesHandler(w http.ResponseWriter, r *http.Request, db *database.DB) {
userIDStr, ok := r.Context().Value("user").(string)
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, err := uuid.Parse(userIDStr)
if err != nil {
errors.LogError(r, err, "Invalid user id in context")
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
path := r.URL.Query().Get("path")
if path == "" {
path = "/"
}
q := r.URL.Query().Get("q")
page := 1
pageSize := 100
if p := r.URL.Query().Get("page"); p != "" {
fmt.Sscanf(p, "%d", &page)
}
if ps := r.URL.Query().Get("pageSize"); ps != "" {
fmt.Sscanf(ps, "%d", &pageSize)
}
files, err := db.GetUserFiles(r.Context(), userID, path, q, page, pageSize)
if err != nil {
errors.LogError(r, err, "Failed to get user files")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
out := make([]map[string]interface{}, 0, len(files))
for _, f := range files {
out = append(out, map[string]interface{}{
"name": f.Name,
"path": f.Path,
"type": f.Type,
"size": f.Size,
"lastModified": f.LastModified.UTC().Format(time.RFC3339),
})
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(out)
}
// createOrgFileHandler creates a file or folder record for an org workspace.
func createOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value("org").(uuid.UUID)
userIDStr, _ := r.Context().Value("user").(string)
userID, _ := uuid.Parse(userIDStr)
// Support multipart uploads (field "file") or JSON metadata for folders
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
// Handle file upload
if err := r.ParseMultipartForm(32 << 20); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
return
}
parentPath := r.FormValue("path")
if parentPath == "" {
parentPath = "/"
}
file, header, err := r.FormFile("file")
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
return
}
defer file.Close()
// Save to disk under data/uploads/orgs/<orgId>/<parentPath>
baseDir := filepath.Join("data", "uploads", "orgs", orgID.String())
targetDir := filepath.Join(baseDir, parentPath)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
errors.LogError(r, err, "Failed to create target dir")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
outPath := filepath.Join(targetDir, header.Filename)
outFile, err := os.Create(outPath)
if err != nil {
errors.LogError(r, err, "Failed to create file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
defer outFile.Close()
written, err := io.Copy(outFile, file)
if err != nil {
errors.LogError(r, err, "Failed to write file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
// Store metadata in DB; store path relative to workspace root
storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename))
if !strings.HasPrefix(storedPath, "/") {
storedPath = "/" + storedPath
}
f, err := db.CreateFile(r.Context(), &orgID, &userID, header.Filename, storedPath, "file", written)
if err != nil {
errors.LogError(r, err, "Failed to create org file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "upload_file",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
return
}
var req struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // file|folder
Size int64 `json:"size"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
f, err := db.CreateFile(r.Context(), &orgID, &userID, req.Name, req.Path, req.Type, req.Size)
if err != nil {
errors.LogError(r, err, "Failed to create org file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "create_file",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
}
// deleteOrgFileHandler deletes a file/folder in org workspace by path
func deleteOrgFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
orgID := r.Context().Value("org").(uuid.UUID)
userIDStr, _ := r.Context().Value("user").(string)
userID, _ := uuid.Parse(userIDStr)
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if err := db.DeleteFileByPath(r.Context(), &orgID, nil, req.Path); err != nil {
errors.LogError(r, err, "Failed to delete org file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
OrgID: &orgID,
Action: "delete_file",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}
// Also accept POST /orgs/{orgId}/files/delete for clients that cannot send DELETE with body
func deleteOrgFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
deleteOrgFileHandler(w, r, db, auditLogger)
}
// createUserFileHandler creates a file or folder record for the authenticated user's personal workspace.
func createUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr, ok := r.Context().Value("user").(string)
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
// Support multipart uploads for file content or JSON for folders
contentType := r.Header.Get("Content-Type")
if strings.HasPrefix(contentType, "multipart/form-data") {
if err := r.ParseMultipartForm(32 << 20); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad multipart request", http.StatusBadRequest)
return
}
parentPath := r.FormValue("path")
if parentPath == "" {
parentPath = "/"
}
file, header, err := r.FormFile("file")
if err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Missing file", http.StatusBadRequest)
return
}
defer file.Close()
baseDir := filepath.Join("data", "uploads", "users", userID.String())
targetDir := filepath.Join(baseDir, parentPath)
if err := os.MkdirAll(targetDir, 0o755); err != nil {
errors.LogError(r, err, "Failed to create target dir")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
outPath := filepath.Join(targetDir, header.Filename)
outFile, err := os.Create(outPath)
if err != nil {
errors.LogError(r, err, "Failed to create file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
defer outFile.Close()
written, err := io.Copy(outFile, file)
if err != nil {
errors.LogError(r, err, "Failed to write file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
storedPath := filepath.ToSlash(filepath.Join(parentPath, header.Filename))
if !strings.HasPrefix(storedPath, "/") {
storedPath = "/" + storedPath
}
f, err := db.CreateFile(r.Context(), nil, &userID, header.Filename, storedPath, "file", written)
if err != nil {
errors.LogError(r, err, "Failed to create user file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "upload_user_file",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
return
}
var req struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"` // file|folder
Size int64 `json:"size"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
f, err := db.CreateFile(r.Context(), nil, &userID, req.Name, req.Path, req.Type, req.Size)
if err != nil {
errors.LogError(r, err, "Failed to create user file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "create_user_file",
Success: true,
})
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{"id": f.ID})
}
// Also accept POST /user/files/delete
func deleteUserFilePostHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
deleteUserFileHandler(w, r, db, auditLogger)
}
// deleteUserFileHandler deletes a file/folder in user's personal workspace by path
func deleteUserFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, auditLogger *audit.Logger) {
userIDStr, ok := r.Context().Value("user").(string)
if !ok || userIDStr == "" {
errors.WriteError(w, errors.CodeUnauthenticated, "Unauthorized", http.StatusUnauthorized)
return
}
userID, _ := uuid.Parse(userIDStr)
var req struct {
Path string `json:"path"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
errors.WriteError(w, errors.CodeInvalidArgument, "Bad request", http.StatusBadRequest)
return
}
if err := db.DeleteFileByPath(r.Context(), nil, &userID, req.Path); err != nil {
errors.LogError(r, err, "Failed to delete user file")
errors.WriteError(w, errors.CodeInternal, "Server error", http.StatusInternalServerError)
return
}
auditLogger.Log(r.Context(), audit.Entry{
UserID: &userID,
Action: "delete_user_file",
Success: true,
})
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"status":"ok"}`))
}

View File

@@ -0,0 +1,17 @@
-- Create files table for org and user workspaces
CREATE TABLE files (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
org_id UUID REFERENCES organizations(id),
user_id UUID REFERENCES users(id),
name TEXT NOT NULL,
path TEXT NOT NULL,
type TEXT NOT NULL,
size BIGINT DEFAULT 0,
last_modified TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE INDEX idx_files_org_id ON files(org_id);
CREATE INDEX idx_files_user_id ON files(user_id);
CREATE INDEX idx_files_path ON files(path);