From 168f5eac83bedc39aa8e090e8ba62ec27f7ffd19 Mon Sep 17 00:00:00 2001 From: domrichardson <100129001+domrichardson@users.noreply.github.com> Date: Wed, 25 Mar 2026 11:27:15 +0000 Subject: [PATCH] feat: file explorer --- backend/cmd/server/main.go | 11 + backend/go.mod | 12 + backend/go.sum | 24 ++ backend/internal/application/dto/dto.go | 30 +- .../application/services/admin_service.go | 25 ++ .../application/services/file_service.go | 389 ++++++++++++++++++ backend/internal/domain/entities/auth.go | 13 +- .../interfaces/handlers/file_handler.go | 273 ++++++++++++ .../internal/interfaces/middleware/auth.go | 8 +- frontend/src/App.vue | 3 +- frontend/src/components/FileExplorer.vue | 331 +++++++++++++++ frontend/src/components/NoteEditor.vue | 69 +++- frontend/src/components/NoteViewer.vue | 21 +- frontend/src/pages/Admin.vue | 76 +++- frontend/src/stores/settingsStore.js | 3 + frontend/src/utils/markdown.js | 29 ++ 16 files changed, 1297 insertions(+), 20 deletions(-) create mode 100644 backend/internal/application/services/file_service.go create mode 100644 backend/internal/interfaces/handlers/file_handler.go create mode 100644 frontend/src/components/FileExplorer.vue create mode 100644 frontend/src/utils/markdown.js diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index 08722d7..b355431 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -123,6 +123,7 @@ func main() { db.CategoryRepo, db.FeatureFlagRepo, permissionService, + encryptor, ) if err := permissionService.EnsureAdminGroup(context.Background()); err != nil { @@ -147,6 +148,8 @@ func main() { adminHandler := handlers.NewAdminHandler(adminService) publicHandler := handlers.NewPublicHandler(spaceService, noteService) settingsHandler := handlers.NewSettingsHandler(authService) + fileService := services.NewFileService(db.FeatureFlagRepo, db.MembershipRepo, encryptor) + fileHandler := handlers.NewFileHandler(fileService) // Create router router := mux.NewRouter() @@ -210,6 +213,14 @@ func main() { api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}", categoryHandler.DeleteCategory).Methods("DELETE") api.HandleFunc("/spaces/{spaceId}/categories/{categoryId}/move", categoryHandler.MoveCategory).Methods("PATCH") + // File explorer endpoints (space-scoped) + api.HandleFunc("/spaces/{spaceId}/files/list", fileHandler.ListFiles).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.GetFile).Methods("GET") + api.HandleFunc("/spaces/{spaceId}/files/upload", fileHandler.UploadFile).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/files/folder", fileHandler.CreateFolder).Methods("POST") + api.HandleFunc("/spaces/{spaceId}/files/object", fileHandler.DeleteFile).Methods("DELETE") + api.HandleFunc("/spaces/{spaceId}/files/folder", fileHandler.DeleteFolder).Methods("DELETE") + // Admin endpoints admin := router.PathPrefix("/api/v1/admin").Subrouter() admin.Use(authMiddleware.Middleware) diff --git a/backend/go.mod b/backend/go.mod index c1c3f5b..bd37121 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,18 @@ require ( ) require ( + github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 // indirect + github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 // indirect + github.com/aws/smithy-go v1.24.2 // indirect github.com/klauspost/compress v1.17.6 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect github.com/xdg-go/scram v1.2.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7d455d3..149adc2 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,3 +1,27 @@ +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21 h1:SwGMTMLIlvDNyhMteQ6r8IJSBPlRdXX5d4idhIGbkXA= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.21/go.mod h1:UUxgWxofmOdAMuqEsSppbDtGKLfR04HGsD0HXzvhI1k= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12 h1:qtJZ70afD3ISKWnoX3xB0J2otEqu3LqicRcDBqsj0hQ= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.12/go.mod h1:v2pNpJbRNl4vEUWEh5ytQok0zACAKfdmKS51Hotc3pQ= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20 h1:siU1A6xjUZ2N8zjTHSXFhB9L/2OY8Dqs0xXiLjF30jA= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.20/go.mod h1:4TLZCmVJDM3FOu5P5TJP0zOlu9zWgDWU7aUxWbr+rcw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2 h1:MRNiP6nqa20aEl8fQ6PJpEq11b2d40b16sm4WD7QgMU= +github.com/aws/aws-sdk-go-v2/service/s3 v1.97.2/go.mod h1:FrNA56srbsr3WShiaelyWYEo70x80mXnVZ17ZZfbeqg= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw= diff --git a/backend/internal/application/dto/dto.go b/backend/internal/application/dto/dto.go index b76b7ce..481456d 100644 --- a/backend/internal/application/dto/dto.go +++ b/backend/internal/application/dto/dto.go @@ -59,16 +59,28 @@ type CreateAuthProviderRequest struct { // FeatureFlagsDTO represents app-wide feature flags in API responses. type FeatureFlagsDTO struct { - RegistrationEnabled bool `json:"registration_enabled"` - ProviderLoginEnabled bool `json:"provider_login_enabled"` - PublicSharingEnabled bool `json:"public_sharing_enabled"` + RegistrationEnabled bool `json:"registration_enabled"` + ProviderLoginEnabled bool `json:"provider_login_enabled"` + PublicSharingEnabled bool `json:"public_sharing_enabled"` + FileExplorerEnabled bool `json:"file_explorer_enabled"` + S3Endpoint string `json:"s3_endpoint,omitempty"` + S3Bucket string `json:"s3_bucket,omitempty"` + S3Region string `json:"s3_region,omitempty"` + S3AccessKey string `json:"s3_access_key,omitempty"` + S3SecretKeySet bool `json:"s3_secret_key_set"` } // UpdateFeatureFlagsRequest represents admin payload for feature flag updates. type UpdateFeatureFlagsRequest struct { - RegistrationEnabled bool `json:"registration_enabled"` - ProviderLoginEnabled bool `json:"provider_login_enabled"` - PublicSharingEnabled bool `json:"public_sharing_enabled"` + RegistrationEnabled bool `json:"registration_enabled"` + ProviderLoginEnabled bool `json:"provider_login_enabled"` + PublicSharingEnabled bool `json:"public_sharing_enabled"` + FileExplorerEnabled bool `json:"file_explorer_enabled"` + S3Endpoint string `json:"s3_endpoint"` + S3Bucket string `json:"s3_bucket"` + S3Region string `json:"s3_region"` + S3AccessKey string `json:"s3_access_key"` + S3SecretKey string `json:"s3_secret_key"` // empty = keep existing encrypted value } // UserDTO represents a user in API responses @@ -206,6 +218,12 @@ func NewFeatureFlagsDTO(flags *entities.FeatureFlags) *FeatureFlagsDTO { RegistrationEnabled: flags.RegistrationEnabled, ProviderLoginEnabled: flags.ProviderLoginEnabled, PublicSharingEnabled: flags.PublicSharingEnabled, + FileExplorerEnabled: flags.FileExplorerEnabled, + S3Endpoint: flags.S3Endpoint, + S3Bucket: flags.S3Bucket, + S3Region: flags.S3Region, + S3AccessKey: flags.S3AccessKey, + S3SecretKeySet: flags.S3SecretKey != "", } } diff --git a/backend/internal/application/services/admin_service.go b/backend/internal/application/services/admin_service.go index 9fd073f..c78b713 100644 --- a/backend/internal/application/services/admin_service.go +++ b/backend/internal/application/services/admin_service.go @@ -10,6 +10,7 @@ import ( "github.com/noteapp/backend/internal/application/dto" "github.com/noteapp/backend/internal/domain/entities" "github.com/noteapp/backend/internal/domain/repositories" + "github.com/noteapp/backend/internal/infrastructure/security" ) // AdminService handles admin-level operations @@ -22,6 +23,7 @@ type AdminService struct { categoryRepo repositories.CategoryRepository featureFlagRepo repositories.FeatureFlagRepository permissionService *PermissionService + encryptor *security.Encryptor } // NewAdminService creates a new AdminService @@ -34,6 +36,7 @@ func NewAdminService( categoryRepo repositories.CategoryRepository, featureFlagRepo repositories.FeatureFlagRepository, permissionService *PermissionService, + encryptor *security.Encryptor, ) *AdminService { return &AdminService{ userRepo: userRepo, @@ -44,6 +47,7 @@ func NewAdminService( categoryRepo: categoryRepo, featureFlagRepo: featureFlagRepo, permissionService: permissionService, + encryptor: encryptor, } } @@ -299,10 +303,31 @@ func (s *AdminService) UpdateFeatureFlags(ctx context.Context, req *dto.UpdateFe return nil, errors.New("feature flags are unavailable") } + // Load existing flags so we can preserve the encrypted S3 secret when not updated + existing, err := s.featureFlagRepo.GetFeatureFlags(ctx) + if err != nil { + existing = entities.NewDefaultFeatureFlags() + } + flags := &entities.FeatureFlags{ RegistrationEnabled: req.RegistrationEnabled, ProviderLoginEnabled: req.ProviderLoginEnabled, PublicSharingEnabled: req.PublicSharingEnabled, + FileExplorerEnabled: req.FileExplorerEnabled, + S3Endpoint: strings.TrimSpace(req.S3Endpoint), + S3Bucket: strings.TrimSpace(req.S3Bucket), + S3Region: strings.TrimSpace(req.S3Region), + S3AccessKey: strings.TrimSpace(req.S3AccessKey), + S3SecretKey: existing.S3SecretKey, // keep encrypted secret by default + } + + // Only re-encrypt if a new secret was supplied + if s.encryptor != nil && strings.TrimSpace(req.S3SecretKey) != "" { + encrypted, err := s.encryptor.Encrypt(strings.TrimSpace(req.S3SecretKey)) + if err != nil { + return nil, err + } + flags.S3SecretKey = encrypted } if err := s.featureFlagRepo.UpdateFeatureFlags(ctx, flags); err != nil { diff --git a/backend/internal/application/services/file_service.go b/backend/internal/application/services/file_service.go new file mode 100644 index 0000000..288dd66 --- /dev/null +++ b/backend/internal/application/services/file_service.go @@ -0,0 +1,389 @@ +package services + +import ( + "bytes" + "context" + "errors" + "io" + "path" + "strings" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/aws/aws-sdk-go-v2/service/s3/types" + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/noteapp/backend/internal/domain/repositories" + "github.com/noteapp/backend/internal/infrastructure/security" +) + +// S3Object represents a file or folder entry with key relative to the space root. +type S3Object struct { + Key string `json:"key"` + Size int64 `json:"size"` + LastModified string `json:"last_modified"` + IsFolder bool `json:"is_folder"` +} + +// FileService handles S3 file operations scoped to individual spaces. +type FileService struct { + featureFlagRepo repositories.FeatureFlagRepository + membershipRepo repositories.MembershipRepository + encryptor *security.Encryptor +} + +// NewFileService creates a new FileService. +func NewFileService( + featureFlagRepo repositories.FeatureFlagRepository, + membershipRepo repositories.MembershipRepository, + encryptor *security.Encryptor, +) *FileService { + return &FileService{ + featureFlagRepo: featureFlagRepo, + membershipRepo: membershipRepo, + encryptor: encryptor, + } +} + +type s3Config struct { + client *s3.Client + bucket string +} + +// buildS3Config loads feature flags, decrypts credentials, and returns an S3 client + bucket name. +func (s *FileService) buildS3Config(ctx context.Context) (*s3Config, error) { + flags, err := s.featureFlagRepo.GetFeatureFlags(ctx) + if err != nil { + return nil, err + } + if !flags.FileExplorerEnabled { + return nil, errors.New("file explorer is disabled") + } + if flags.S3Endpoint == "" || flags.S3Bucket == "" { + return nil, errors.New("S3 is not configured") + } + + secretKey := "" + if flags.S3SecretKey != "" && s.encryptor != nil { + secretKey, err = s.encryptor.Decrypt(flags.S3SecretKey) + if err != nil { + return nil, errors.New("failed to decrypt S3 credentials") + } + } + + region := flags.S3Region + if region == "" { + region = "us-east-1" + } + + cfg := aws.Config{ + Region: region, + Credentials: credentials.NewStaticCredentialsProvider(flags.S3AccessKey, secretKey, ""), + } + + client := s3.NewFromConfig(cfg, func(o *s3.Options) { + o.BaseEndpoint = aws.String(flags.S3Endpoint) + o.UsePathStyle = true + }) + + return &s3Config{client: client, bucket: flags.S3Bucket}, nil +} + +// validateAccess ensures file explorer is enabled and the user is a member of the space. +// Returns a ready S3 config on success. +func (s *FileService) validateAccess(ctx context.Context, userIDHex, spaceIDHex string) (*s3Config, error) { + cfg, err := s.buildS3Config(ctx) + if err != nil { + return nil, err + } + + userID, err := bson.ObjectIDFromHex(userIDHex) + if err != nil { + return nil, errors.New("access denied") + } + spaceID, err := bson.ObjectIDFromHex(spaceIDHex) + if err != nil { + return nil, errors.New("access denied") + } + + if _, err := s.membershipRepo.GetUserMembership(ctx, userID, spaceID); err != nil { + return nil, errors.New("access denied") + } + + return cfg, nil +} + +// spaceBase returns the S3 key prefix for a space: "spaces//". +func spaceBase(spaceIDHex string) string { + return "spaces/" + spaceIDHex + "/" +} + +// resolveRelKey sanitises a relative key and returns the full S3 key, +// rejecting anything that would escape the space prefix. +func resolveRelKey(spaceIDHex, relKey string) (string, error) { + relKey = strings.TrimLeft(strings.TrimSpace(relKey), "/") + cleaned := path.Clean(relKey) + if cleaned == "." || cleaned == "" { + return "", errors.New("key is empty") + } + if strings.Contains(cleaned, "..") { + return "", errors.New("invalid key") + } + base := spaceBase(spaceIDHex) + full := base + cleaned + if !strings.HasPrefix(full, base) { + return "", errors.New("invalid key: outside space boundary") + } + return full, nil +} + +// resolveRelPrefix sanitises a relative folder prefix and returns the full S3 prefix. +// An empty relPrefix maps to the space root folder. +func resolveRelPrefix(spaceIDHex, relPrefix string) (string, error) { + base := spaceBase(spaceIDHex) + relPrefix = strings.TrimLeft(strings.TrimSpace(relPrefix), "/") + if relPrefix == "" { + return base, nil + } + cleaned := path.Clean(relPrefix) + if cleaned == "." { + return base, nil + } + if strings.Contains(cleaned, "..") { + return "", errors.New("invalid prefix") + } + full := base + cleaned + "/" + if !strings.HasPrefix(full, base) { + return "", errors.New("invalid prefix: outside space boundary") + } + return full, nil +} + +// ListObjects returns objects and virtual folders directly under relPrefix within the space. +// Returned keys are relative to the space root (no "spaces//" prefix). +func (s *FileService) ListObjects(ctx context.Context, userIDHex, spaceIDHex, relPrefix string) ([]*S3Object, error) { + cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex) + if err != nil { + return nil, err + } + + fullPrefix, err := resolveRelPrefix(spaceIDHex, relPrefix) + if err != nil { + return nil, err + } + + base := spaceBase(spaceIDHex) + result, err := cfg.client.ListObjectsV2(ctx, &s3.ListObjectsV2Input{ + Bucket: aws.String(cfg.bucket), + Prefix: aws.String(fullPrefix), + Delimiter: aws.String("/"), + }) + if err != nil { + return nil, err + } + + var objects []*S3Object + + for _, cp := range result.CommonPrefixes { + if cp.Prefix != nil { + objects = append(objects, &S3Object{ + Key: strings.TrimPrefix(*cp.Prefix, base), + IsFolder: true, + }) + } + } + + for _, obj := range result.Contents { + if obj.Key == nil || *obj.Key == fullPrefix { + continue + } + // Hide virtual .keep placeholder files used for folder creation + if path.Base(*obj.Key) == ".keep" { + continue + } + size := int64(0) + if obj.Size != nil { + size = *obj.Size + } + lastMod := "" + if obj.LastModified != nil { + lastMod = obj.LastModified.Format(time.RFC3339) + } + objects = append(objects, &S3Object{ + Key: strings.TrimPrefix(*obj.Key, base), + Size: size, + LastModified: lastMod, + }) + } + + return objects, nil +} + +// GetObjectContent streams an S3 object, enforcing space boundary. +// relKey is relative to the space root. +func (s *FileService) GetObjectContent(ctx context.Context, userIDHex, spaceIDHex, relKey string) (io.ReadCloser, string, error) { + cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex) + if err != nil { + return nil, "", err + } + + fullKey, err := resolveRelKey(spaceIDHex, relKey) + if err != nil { + return nil, "", err + } + + result, err := cfg.client.GetObject(ctx, &s3.GetObjectInput{ + Bucket: aws.String(cfg.bucket), + Key: aws.String(fullKey), + }) + if err != nil { + return nil, "", err + } + + contentType := "application/octet-stream" + if result.ContentType != nil { + contentType = *result.ContentType + } + + return result.Body, contentType, nil +} + +// UploadObject stores a file at relKey within the space. +func (s *FileService) UploadObject(ctx context.Context, userIDHex, spaceIDHex, relKey, contentType string, body io.Reader, size int64) error { + cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex) + if err != nil { + return err + } + + fullKey, err := resolveRelKey(spaceIDHex, relKey) + if err != nil { + return err + } + + if contentType == "" { + contentType = "application/octet-stream" + } + + input := &s3.PutObjectInput{ + Bucket: aws.String(cfg.bucket), + Key: aws.String(fullKey), + Body: body, + ContentType: aws.String(contentType), + } + if size > 0 { + input.ContentLength = aws.Int64(size) + } + + _, err = cfg.client.PutObject(ctx, input) + return err +} + +// CreateFolder creates a virtual folder by uploading a zero-byte .keep placeholder. +func (s *FileService) CreateFolder(ctx context.Context, userIDHex, spaceIDHex, relPath string) error { + cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex) + if err != nil { + return err + } + + base := spaceBase(spaceIDHex) + relPath = strings.Trim(relPath, "/") + cleaned := path.Clean(relPath) + if cleaned == "." || cleaned == "" || strings.Contains(cleaned, "..") { + return errors.New("invalid folder path") + } + fullKey := base + cleaned + "/.keep" + if !strings.HasPrefix(fullKey, base) { + return errors.New("invalid folder path: outside space boundary") + } + + zero := int64(0) + _, err = cfg.client.PutObject(ctx, &s3.PutObjectInput{ + Bucket: aws.String(cfg.bucket), + Key: aws.String(fullKey), + Body: bytes.NewReader(nil), + ContentType: aws.String("application/octet-stream"), + ContentLength: aws.Int64(zero), + }) + return err +} + +// DeleteObject removes a single object within the space. +func (s *FileService) DeleteObject(ctx context.Context, userIDHex, spaceIDHex, relKey string) error { + cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex) + if err != nil { + return err + } + + fullKey, err := resolveRelKey(spaceIDHex, relKey) + if err != nil { + return err + } + + _, err = cfg.client.DeleteObject(ctx, &s3.DeleteObjectInput{ + Bucket: aws.String(cfg.bucket), + Key: aws.String(fullKey), + }) + return err +} + +// DeleteFolder recursively deletes all objects under relPrefix within the space. +func (s *FileService) DeleteFolder(ctx context.Context, userIDHex, spaceIDHex, relPrefix string) error { + cfg, err := s.validateAccess(ctx, userIDHex, spaceIDHex) + if err != nil { + return err + } + + fullPrefix, err := resolveRelPrefix(spaceIDHex, relPrefix) + if err != nil { + return err + } + + // Safety net: refuse to delete the entire space root + if fullPrefix == spaceBase(spaceIDHex) { + return errors.New("cannot delete the space root folder") + } + + paginator := s3.NewListObjectsV2Paginator(cfg.client, &s3.ListObjectsV2Input{ + Bucket: aws.String(cfg.bucket), + Prefix: aws.String(fullPrefix), + }) + + var toDelete []types.ObjectIdentifier + for paginator.HasMorePages() { + page, err := paginator.NextPage(ctx) + if err != nil { + return err + } + for _, obj := range page.Contents { + if obj.Key != nil { + toDelete = append(toDelete, types.ObjectIdentifier{Key: obj.Key}) + } + } + } + + if len(toDelete) == 0 { + return nil + } + + // Delete in batches of 1000 (S3 limit per DeleteObjects call) + for i := 0; i < len(toDelete); i += 1000 { + end := i + 1000 + if end > len(toDelete) { + end = len(toDelete) + } + _, err := cfg.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{ + Bucket: aws.String(cfg.bucket), + Delete: &types.Delete{ + Objects: toDelete[i:end], + Quiet: aws.Bool(true), + }, + }) + if err != nil { + return err + } + } + + return nil +} diff --git a/backend/internal/domain/entities/auth.go b/backend/internal/domain/entities/auth.go index 9832e4e..f7db29e 100644 --- a/backend/internal/domain/entities/auth.go +++ b/backend/internal/domain/entities/auth.go @@ -36,9 +36,15 @@ type LoginAttempt struct { // FeatureFlags controls app-wide behavior toggles. type FeatureFlags struct { - RegistrationEnabled bool `bson:"registration_enabled"` - ProviderLoginEnabled bool `bson:"provider_login_enabled"` - PublicSharingEnabled bool `bson:"public_sharing_enabled"` + RegistrationEnabled bool `bson:"registration_enabled"` + ProviderLoginEnabled bool `bson:"provider_login_enabled"` + PublicSharingEnabled bool `bson:"public_sharing_enabled"` + FileExplorerEnabled bool `bson:"file_explorer_enabled"` + S3Endpoint string `bson:"s3_endpoint,omitempty"` + S3Bucket string `bson:"s3_bucket,omitempty"` + S3Region string `bson:"s3_region,omitempty"` + S3AccessKey string `bson:"s3_access_key,omitempty"` + S3SecretKey string `bson:"s3_secret_key,omitempty"` // AES-256-GCM encrypted } // NewDefaultFeatureFlags returns safe defaults for a new deployment. @@ -47,5 +53,6 @@ func NewDefaultFeatureFlags() *FeatureFlags { RegistrationEnabled: true, ProviderLoginEnabled: true, PublicSharingEnabled: true, + FileExplorerEnabled: false, } } diff --git a/backend/internal/interfaces/handlers/file_handler.go b/backend/internal/interfaces/handlers/file_handler.go new file mode 100644 index 0000000..8250abd --- /dev/null +++ b/backend/internal/interfaces/handlers/file_handler.go @@ -0,0 +1,273 @@ +package handlers + +import ( + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "path" + "strings" + + "github.com/gorilla/mux" + "github.com/noteapp/backend/internal/application/services" + "github.com/noteapp/backend/internal/interfaces/middleware" +) + +const maxUploadSize = 100 << 20 // 100 MB + +// FileHandler exposes S3 file explorer endpoints scoped to spaces. +type FileHandler struct { + fileService *services.FileService +} + +// NewFileHandler creates a new FileHandler. +func NewFileHandler(fileService *services.FileService) *FileHandler { + return &FileHandler{fileService: fileService} +} + +// extractContext extracts and validates spaceId (URL) and userId (JWT context). +func (h *FileHandler) extractContext(r *http.Request) (spaceID, userID string, err error) { + spaceID = mux.Vars(r)["spaceId"] + if spaceID == "" { + return "", "", fmt.Errorf("missing spaceId") + } + userID, err = middleware.GetUserIDFromContext(r.Context()) + return +} + +// cleanKey sanitises a user-supplied relative key (strips leading slash, resolves .). +func cleanKey(raw string) string { + k := strings.TrimLeft(strings.TrimSpace(raw), "/") + if c := path.Clean(k); c != "." { + return c + } + return "" +} + +// cleanPrefix sanitises a user-supplied relative prefix. +func cleanPrefix(raw string) string { + p := strings.TrimLeft(strings.TrimSpace(raw), "/") + if c := path.Clean(p); c != "." { + return c + } + return "" +} + +// respondError maps service errors to appropriate HTTP status codes. +func respondError(w http.ResponseWriter, err error) { + msg := err.Error() + switch { + case strings.Contains(msg, "access denied"), strings.Contains(msg, "disabled"): + http.Error(w, msg, http.StatusForbidden) + default: + http.Error(w, msg, http.StatusBadRequest) + } +} + +// ListFiles handles GET /api/v1/spaces/{spaceId}/files/list?prefix= +func (h *FileHandler) ListFiles(w http.ResponseWriter, r *http.Request) { + spaceID, userID, err := h.extractContext(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + relPrefix := cleanPrefix(r.URL.Query().Get("prefix")) + objects, err := h.fileService.ListObjects(r.Context(), userID, spaceID, relPrefix) + if err != nil { + respondError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "objects": objects, + "prefix": relPrefix, + }) +} + +// GetFile handles GET /api/v1/spaces/{spaceId}/files/object?key= +// Also accepts ?token= as a fallback auth mechanism so markdown images render in-browser. +func (h *FileHandler) GetFile(w http.ResponseWriter, r *http.Request) { + spaceID, userID, err := h.extractContext(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + relKey := cleanKey(r.URL.Query().Get("key")) + if relKey == "" { + http.Error(w, "key is required", http.StatusBadRequest) + return + } + + body, contentType, err := h.fileService.GetObjectContent(r.Context(), userID, spaceID, relKey) + if err != nil { + if strings.Contains(err.Error(), "access denied") { + http.Error(w, "access denied", http.StatusForbidden) + return + } + http.Error(w, "file not found", http.StatusNotFound) + return + } + defer body.Close() + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Cache-Control", "private, max-age=3600") + io.Copy(w, body) //nolint:errcheck +} + +// UploadFile handles POST /api/v1/spaces/{spaceId}/files/upload (multipart/form-data) +// Form fields: +// - path: optional relative folder within the space (e.g. "docs/2024") +// - files: one or more file uploads +func (h *FileHandler) UploadFile(w http.ResponseWriter, r *http.Request) { + spaceID, userID, err := h.extractContext(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + if err := r.ParseMultipartForm(maxUploadSize); err != nil { + http.Error(w, "request too large", http.StatusRequestEntityTooLarge) + return + } + + relFolder := cleanPrefix(r.FormValue("path")) + fileHeaders := r.MultipartForm.File["files"] + if len(fileHeaders) == 0 { + http.Error(w, "no files provided", http.StatusBadRequest) + return + } + + var uploaded []string + for _, fh := range fileHeaders { + filename := path.Base(fh.Filename) + if filename == "." || filename == "" { + continue + } + + var relKey string + if relFolder != "" { + relKey = relFolder + "/" + filename + } else { + relKey = filename + } + + // Detect content-type from header then extension + ct := fh.Header.Get("Content-Type") + if ct == "" || ct == "application/octet-stream" { + if ext := path.Ext(filename); ext != "" { + if t := mime.TypeByExtension(ext); t != "" { + ct = t + } + } + } + if ct == "" { + ct = "application/octet-stream" + } + + f, err := fh.Open() + if err != nil { + http.Error(w, "failed to read uploaded file", http.StatusInternalServerError) + return + } + uploadErr := h.fileService.UploadObject(r.Context(), userID, spaceID, relKey, ct, f, fh.Size) + f.Close() + if uploadErr != nil { + respondError(w, uploadErr) + return + } + uploaded = append(uploaded, relKey) + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]interface{}{"uploaded": uploaded}) +} + +// CreateFolder handles POST /api/v1/spaces/{spaceId}/files/folder +// JSON body: {"path": "new-folder-name"} +func (h *FileHandler) CreateFolder(w http.ResponseWriter, r *http.Request) { + spaceID, userID, err := h.extractContext(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var body struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + relPath := cleanPrefix(body.Path) + if relPath == "" { + http.Error(w, "path is required", http.StatusBadRequest) + return + } + + if err := h.fileService.CreateFolder(r.Context(), userID, spaceID, relPath); err != nil { + respondError(w, err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + json.NewEncoder(w).Encode(map[string]string{"path": relPath}) +} + +// DeleteFile handles DELETE /api/v1/spaces/{spaceId}/files/object?key= +func (h *FileHandler) DeleteFile(w http.ResponseWriter, r *http.Request) { + spaceID, userID, err := h.extractContext(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + relKey := cleanKey(r.URL.Query().Get("key")) + if relKey == "" { + http.Error(w, "key is required", http.StatusBadRequest) + return + } + + if err := h.fileService.DeleteObject(r.Context(), userID, spaceID, relKey); err != nil { + if strings.Contains(err.Error(), "access denied") { + http.Error(w, "access denied", http.StatusForbidden) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// DeleteFolder handles DELETE /api/v1/spaces/{spaceId}/files/folder?prefix= +func (h *FileHandler) DeleteFolder(w http.ResponseWriter, r *http.Request) { + spaceID, userID, err := h.extractContext(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + relPrefix := cleanPrefix(r.URL.Query().Get("prefix")) + if relPrefix == "" { + http.Error(w, "prefix is required", http.StatusBadRequest) + return + } + + if err := h.fileService.DeleteFolder(r.Context(), userID, spaceID, relPrefix); err != nil { + if strings.Contains(err.Error(), "access denied") { + http.Error(w, "access denied", http.StatusForbidden) + return + } + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusNoContent) +} diff --git a/backend/internal/interfaces/middleware/auth.go b/backend/internal/interfaces/middleware/auth.go index 93e22c0..8cd9b77 100644 --- a/backend/internal/interfaces/middleware/auth.go +++ b/backend/internal/interfaces/middleware/auth.go @@ -41,8 +41,14 @@ func (m *AuthMiddleware) Middleware(next http.Handler) http.Handler { return } - // Extract token from Authorization header + // Extract token from Authorization header. + // For GET /files/object, also accept ?token= so markdown images render in-browser. authHeader := r.Header.Get("Authorization") + if authHeader == "" && r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/files/object") { + if tok := r.URL.Query().Get("token"); tok != "" { + authHeader = "Bearer " + tok + } + } if authHeader == "" { http.Error(w, "Missing authorization header", http.StatusUnauthorized) return diff --git a/frontend/src/App.vue b/frontend/src/App.vue index ff0abb8..a57f819 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -174,11 +174,12 @@ :note="selectedNote" :category-options="categoryOptions" :can-delete="canDeleteNotes" + :space-id="currentSpace?.id" @save="updateNote" @delete="deleteNote" @cancel="cancelEditingNote" /> - + +
+ +
+ + + +
+ + + +
+
+ + +
+ + + +
+ + +
+
+
+
+
+ {{ uploadProgress }}% +
+
+ + + + + +
Loading...
+
+ + Drop files here or click Upload +
+ + +
+
+ + {{ displayName(obj) }} + {{ formatSize(obj.size) }} + +
+
+ + + +
+ + + + + diff --git a/frontend/src/components/NoteEditor.vue b/frontend/src/components/NoteEditor.vue index f628f00..009b4a8 100644 --- a/frontend/src/components/NoteEditor.vue +++ b/frontend/src/components/NoteEditor.vue @@ -4,6 +4,16 @@ + {{ saveStatusLabel }} @@ -16,15 +26,19 @@
-
- +
+
-
+
+ +
+ +
@@ -73,10 +87,13 @@