refactor: account model overhaul

This commit is contained in:
2026-01-01 18:29:52 +00:00
parent ad7d7c6a1b
commit 88492dd876
49 changed files with 1559 additions and 573 deletions

View File

@@ -7,30 +7,37 @@ import (
"github.com/uptrace/bun"
)
// Account represents a storage account with quota information
// @Description Storage account with usage and quota details
type Role string
const (
RoleAdmin Role = "admin"
RoleMember Role = "member"
)
type Status string
const (
StatusInvited Status = "invited"
StatusActive Status = "active"
StatusSuspended Status = "suspended"
)
// Account represents a user's identity within an organization (principal / membership).
type Account struct {
bun.BaseModel `bun:"accounts" swaggerignore:"true"`
// Unique account identifier
ID uuid.UUID `bun:",pk,type:uuid" json:"id" example:"550e8400-e29b-41d4-a716-446655440000"`
ID uuid.UUID `bun:",pk,type:uuid" json:"id"`
OrgID uuid.UUID `bun:"org_id,notnull,type:uuid" json:"orgId"`
UserID uuid.UUID `bun:"user_id,notnull,type:uuid" json:"userId"`
// ID of the user who owns this account
UserID uuid.UUID `bun:"user_id,notnull,type:uuid" json:"userId" example:"550e8400-e29b-41d4-a716-446655440001"`
Role Role `bun:"role,notnull" json:"role" example:"member"`
Status Status `bun:"status,notnull" json:"status" example:"active"`
// Current storage usage in bytes
StorageUsageBytes int64 `bun:"storage_usage_bytes,notnull" json:"storageUsageBytes" example:"1073741824"`
// Maximum storage quota in bytes
StorageQuotaBytes int64 `bun:"storage_quota_bytes,notnull" json:"storageQuotaBytes" example:"10737418240"`
// When the account was created (ISO 8601)
CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"createdAt" example:"2024-12-13T15:04:05Z"`
// When the account was last updated (ISO 8601)
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"updatedAt" example:"2024-12-13T16:30:00Z"`
CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"createdAt"`
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"updatedAt"`
}
func newAccountID() (uuid.UUID, error) {
return uuid.NewV7()
}

View File

@@ -3,113 +3,33 @@ package account
import (
"errors"
"github.com/get-drexa/drexa/internal/auth"
"github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/reqctx"
"github.com/get-drexa/drexa/internal/user"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type HTTPHandler struct {
accountService *Service
authService *auth.Service
vfs *virtualfs.VirtualFS
db *bun.DB
authMiddleware fiber.Handler
cookieConfig auth.CookieConfig
accountService *Service
db *bun.DB
authMiddleware fiber.Handler
}
// registerAccountRequest represents a new account registration
// @Description Request to create a new account and user
type registerAccountRequest struct {
// Email address for the new account
Email string `json:"email" example:"newuser@example.com"`
// Password for the new account (min 8 characters)
Password string `json:"password" example:"securepassword123"`
// Display name for the user
DisplayName string `json:"displayName" example:"Jane Doe"`
// How to deliver tokens: "cookie" (set HTTP-only cookies) or "body" (include in response)
TokenDelivery string `json:"tokenDelivery" example:"body" enums:"cookie,body"`
func NewHTTPHandler(accountService *Service, db *bun.DB, authMiddleware fiber.Handler) *HTTPHandler {
return &HTTPHandler{
accountService: accountService,
db: db,
authMiddleware: authMiddleware,
}
}
// registerAccountResponse represents a successful registration
// @Description Response after successful account registration
type registerAccountResponse struct {
// The created account
Account *Account `json:"account"`
// The created user
User *user.User `json:"user"`
// JWT access token for immediate authentication (only included when tokenDelivery is "body")
AccessToken string `json:"accessToken,omitempty" example:"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI1NTBlODQwMC1lMjliLTQxZDQtYTcxNi00NDY2NTU0NDAwMDAifQ.signature"`
// Base64 URL encoded refresh token (only included when tokenDelivery is "body")
RefreshToken string `json:"refreshToken,omitempty" example:"dR4nD0mUu1DkZXlCeXRlc0FuZFJhbmRvbURhdGFIZXJlMTIzNDU2Nzg5MGFi"`
}
func NewHTTPHandler(accountService *Service, authService *auth.Service, vfs *virtualfs.VirtualFS, db *bun.DB, authMiddleware fiber.Handler, cookieConfig auth.CookieConfig) *HTTPHandler {
return &HTTPHandler{accountService: accountService, authService: authService, db: db, authMiddleware: authMiddleware, cookieConfig: cookieConfig}
}
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) *ScopedRouter {
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
api.Get("/accounts", h.authMiddleware, h.listAccounts)
api.Post("/accounts", h.registerAccount)
account := api.Group("/accounts/:accountID")
account.Use(h.authMiddleware)
account.Use(h.accountMiddleware)
account.Get("/", h.getAccount)
return &ScopedRouter{virtualfs.ScopedRouter{account}}
api.Get("/accounts/:accountID", h.authMiddleware, h.getAccount)
}
func (h *HTTPHandler) accountMiddleware(c *fiber.Ctx) error {
u := reqctx.AuthenticatedUser(c).(*user.User)
accountID, err := uuid.Parse(c.Params("accountID"))
if err != nil {
return c.SendStatus(fiber.StatusNotFound)
}
account, err := h.accountService.AccountByID(c.Context(), h.db, u.ID, accountID)
if err != nil {
if errors.Is(err, ErrAccountNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
root, err := h.vfs.FindRootDirectory(c.Context(), h.db, account.ID)
if err != nil {
return httperr.Internal(err)
}
scope := &virtualfs.Scope{
AccountID: account.ID,
RootNodeID: root.ID,
AllowedOps: virtualfs.AllAllowedOps,
AllowedNodes: nil,
ActorKind: virtualfs.ScopeActorAccount,
ActorID: u.ID,
}
reqctx.SetVFSAccessScope(c, scope)
reqctx.SetCurrentAccount(c, account)
return c.Next()
}
// listAccounts lists all accounts for the authenticated user
// @Summary List accounts
// @Description Retrieve all accounts for the authenticated user
// @Tags accounts
// @Produce json
// @Security BearerAuth
// @Success 200 {array} Account "List of accounts for the authenticated user"
// @Failure 401 {string} string "Not authenticated"
// @Router /accounts [get]
func (h *HTTPHandler) listAccounts(c *fiber.Ctx) error {
u := reqctx.AuthenticatedUser(c).(*user.User)
accounts, err := h.accountService.ListAccounts(c.Context(), h.db, u.ID)
@@ -119,91 +39,19 @@ func (h *HTTPHandler) listAccounts(c *fiber.Ctx) error {
return c.JSON(accounts)
}
// getAccount retrieves account information
// @Summary Get account
// @Description Retrieve account details including storage usage and quota
// @Tags accounts
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Success 200 {object} Account "Account details"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Account not found"
// @Router /accounts/{accountID} [get]
func (h *HTTPHandler) getAccount(c *fiber.Ctx) error {
account, ok := reqctx.CurrentAccount(c).(*Account)
if !ok || account == nil {
u := reqctx.AuthenticatedUser(c).(*user.User)
accountID, err := uuid.Parse(c.Params("accountID"))
if err != nil {
return c.SendStatus(fiber.StatusNotFound)
}
return c.JSON(account)
}
// registerAccount creates a new account and user
// @Summary Register new account
// @Description Create a new user account with email and password. Returns the account, user, and authentication tokens. Tokens can be delivered via HTTP-only cookies or in the response body based on the tokenDelivery field.
// @Tags accounts
// @Accept json
// @Produce json
// @Param request body registerAccountRequest true "Registration details"
// @Success 200 {object} registerAccountResponse "Account created successfully"
// @Failure 400 {string} string "Invalid request body or token delivery method"
// @Failure 409 {string} string "Email already registered"
// @Router /accounts [post]
func (h *HTTPHandler) registerAccount(c *fiber.Ctx) error {
req := new(registerAccountRequest)
if err := c.BodyParser(req); err != nil {
return c.SendStatus(fiber.StatusBadRequest)
}
tx, err := h.db.BeginTx(c.Context(), nil)
acc, err := h.accountService.AccountByID(c.Context(), h.db, u.ID, accountID)
if err != nil {
return httperr.Internal(err)
}
defer tx.Rollback()
acc, u, err := h.accountService.Register(c.Context(), tx, RegisterOptions{
Email: req.Email,
Password: req.Password,
DisplayName: req.DisplayName,
})
if err != nil {
var ae *user.AlreadyExistsError
if errors.As(err, &ae) {
return c.SendStatus(fiber.StatusConflict)
}
if errors.Is(err, ErrAccountAlreadyExists) {
return c.SendStatus(fiber.StatusConflict)
if errors.Is(err, ErrAccountNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
result, err := h.authService.GrantForUser(c.Context(), tx, u)
if err != nil {
return httperr.Internal(err)
}
err = tx.Commit()
if err != nil {
return httperr.Internal(err)
}
switch req.TokenDelivery {
default:
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid token delivery method"})
case auth.TokenDeliveryCookie:
auth.SetAuthCookies(c, result.AccessToken, result.RefreshToken, h.cookieConfig)
return c.JSON(registerAccountResponse{
Account: acc,
User: u,
})
case auth.TokenDeliveryBody:
return c.JSON(registerAccountResponse{
Account: acc,
User: u,
AccessToken: result.AccessToken,
RefreshToken: result.RefreshToken,
})
}
return c.JSON(acc)
}

View File

@@ -1,21 +0,0 @@
package account
import "github.com/get-drexa/drexa/internal/virtualfs"
// ScopedRouter is a router with auth + account middleware applied.
// Routes registered on this router have access to:
// - The authenticated user via reqctx.AuthenticatedUser()
// - The current account via reqctx.CurrentAccount()
// - The VFS scope via reqctx.VFSAccessScope()
//
// This embeds virtualfs.ScopedRouter, so it can be passed to functions
// that only require VFS scope by calling VFSRouter().
type ScopedRouter struct {
virtualfs.ScopedRouter
}
// VFSRouter returns the embedded virtualfs.ScopedRouter for use with
// functions that only require VFS scope access.
func (r *ScopedRouter) VFSRouter() *virtualfs.ScopedRouter {
return &r.ScopedRouter
}

View File

@@ -6,88 +6,38 @@ import (
"errors"
"github.com/get-drexa/drexa/internal/database"
"github.com/get-drexa/drexa/internal/password"
"github.com/get-drexa/drexa/internal/user"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type Service struct {
userService user.Service
vfs *virtualfs.VirtualFS
type Service struct{}
func NewService() *Service {
return &Service{}
}
type RegisterOptions struct {
Email string
Password string
DisplayName string
}
type CreateAccountOptions struct {
OrganizationID uuid.UUID
QuotaBytes int64
}
func NewService(userService *user.Service, vfs *virtualfs.VirtualFS) *Service {
return &Service{
userService: *userService,
vfs: vfs,
}
}
func (s *Service) Register(ctx context.Context, db bun.IDB, opts RegisterOptions) (*Account, *user.User, error) {
hashed, err := password.HashString(opts.Password)
if err != nil {
return nil, nil, err
}
u, err := s.userService.RegisterUser(ctx, db, user.UserRegistrationOptions{
Email: opts.Email,
Password: hashed,
DisplayName: opts.DisplayName,
})
if err != nil {
return nil, nil, err
}
acc, err := s.CreateAccount(ctx, db, u.ID, CreateAccountOptions{
// TODO: make quota configurable
QuotaBytes: 1024 * 1024 * 1024, // 1GB
})
if err != nil {
return nil, nil, err
}
_, err = s.vfs.CreateRootDirectory(ctx, db, acc.ID)
if err != nil {
return nil, nil, err
}
return acc, u, nil
}
func (s *Service) CreateAccount(ctx context.Context, db bun.IDB, userID uuid.UUID, opts CreateAccountOptions) (*Account, error) {
func (s *Service) CreateAccount(ctx context.Context, db bun.IDB, orgID uuid.UUID, userID uuid.UUID, role Role, status Status) (*Account, error) {
id, err := newAccountID()
if err != nil {
return nil, err
}
account := &Account{
ID: id,
UserID: userID,
StorageQuotaBytes: opts.QuotaBytes,
acc := &Account{
ID: id,
OrgID: orgID,
UserID: userID,
Role: role,
Status: status,
}
_, err = db.NewInsert().Model(account).Returning("*").Exec(ctx)
_, err = db.NewInsert().Model(acc).Returning("*").Exec(ctx)
if err != nil {
if database.IsUniqueViolation(err) {
return nil, ErrAccountAlreadyExists
}
return nil, err
}
return account, nil
return acc, nil
}
func (s *Service) ListAccounts(ctx context.Context, db bun.IDB, userID uuid.UUID) ([]*Account, error) {
@@ -102,26 +52,29 @@ func (s *Service) ListAccounts(ctx context.Context, db bun.IDB, userID uuid.UUID
return accounts, nil
}
func (s *Service) AccountByUserID(ctx context.Context, db bun.IDB, userID uuid.UUID) (*Account, error) {
var account Account
err := db.NewSelect().Model(&account).Where("user_id = ?", userID).Scan(ctx)
func (s *Service) AccountByID(ctx context.Context, db bun.IDB, userID uuid.UUID, id uuid.UUID) (*Account, error) {
var acc Account
err := db.NewSelect().Model(&acc).Where("user_id = ?", userID).Where("id = ?", id).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
return nil, err
}
return &account, nil
return &acc, nil
}
func (s *Service) AccountByID(ctx context.Context, db bun.IDB, userID uuid.UUID, id uuid.UUID) (*Account, error) {
var account Account
err := db.NewSelect().Model(&account).Where("user_id = ?", userID).Where("id = ?", id).Scan(ctx)
func (s *Service) FindUserAccountInOrg(ctx context.Context, db bun.IDB, orgID uuid.UUID, userID uuid.UUID) (*Account, error) {
var acc Account
err := db.NewSelect().Model(&acc).
Where("org_id = ?", orgID).
Where("user_id = ?", userID).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrAccountNotFound
}
return nil, err
}
return &account, nil
return &acc, nil
}

View File

@@ -147,14 +147,14 @@ func includeParam(c *fiber.Ctx) []string {
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param request body createDirectoryRequest true "Directory details"
// @Param include query string false "Include additional fields" Enums(path)
// @Success 200 {object} DirectoryInfo "Created directory"
// @Failure 400 {object} map[string]string "Parent not found or not a directory"
// @Failure 401 {string} string "Not authenticated"
// @Failure 409 {object} map[string]string "Directory already exists"
// @Router /accounts/{accountID}/directories [post]
// @Router /drives/{driveID}/directories [post]
func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
scope, ok := scopeFromCtx(c)
if !ok {
@@ -230,13 +230,13 @@ func (h *HTTPHandler) createDirectory(c *fiber.Ctx) error {
// @Tags directories
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param directoryID path string true "Directory ID"
// @Param include query string false "Include additional fields" Enums(path)
// @Success 200 {object} DirectoryInfo "Directory metadata"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found"
// @Router /accounts/{accountID}/directories/{directoryID} [get]
// @Router /drives/{driveID}/directories/{directoryID} [get]
func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c)
scope, ok := scopeFromCtx(c)
@@ -274,7 +274,7 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
// @Tags directories
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param directoryID path string true "Directory ID (use 'root' for the root directory)"
// @Param orderBy query string false "Sort field: name, createdAt, or updatedAt" Enums(name,createdAt,updatedAt)
// @Param dir query string false "Sort direction: asc or desc" Enums(asc,desc)
@@ -284,7 +284,7 @@ func (h *HTTPHandler) fetchDirectory(c *fiber.Ctx) error {
// @Failure 400 {object} map[string]string "Invalid limit or cursor"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found"
// @Router /accounts/{accountID}/directories/{directoryID}/content [get]
// @Router /drives/{driveID}/directories/{directoryID}/content [get]
func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c)
scope, ok := scopeFromCtx(c)
@@ -405,14 +405,14 @@ func (h *HTTPHandler) listDirectory(c *fiber.Ctx) error {
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param directoryID path string true "Directory ID"
// @Param request body patchDirectoryRequest true "Directory update"
// @Success 200 {object} DirectoryInfo "Updated directory metadata"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found"
// @Router /accounts/{accountID}/directories/{directoryID} [patch]
// @Router /drives/{driveID}/directories/{directoryID} [patch]
func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c)
scope, ok := scopeFromCtx(c)
@@ -464,14 +464,14 @@ func (h *HTTPHandler) patchDirectory(c *fiber.Ctx) error {
// @Description Delete a directory permanently or move it to trash. Deleting a directory also affects all its contents.
// @Tags directories
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param directoryID path string true "Directory ID"
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 200 {object} DirectoryInfo "Trashed directory info (when trash=true)"
// @Success 204 {string} string "Directory deleted"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found"
// @Router /accounts/{accountID}/directories/{directoryID} [delete]
// @Router /drives/{driveID}/directories/{directoryID} [delete]
func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c)
scope, ok := scopeFromCtx(c)
@@ -524,14 +524,14 @@ func (h *HTTPHandler) deleteDirectory(c *fiber.Ctx) error {
// @Description Delete multiple directories permanently or move them to trash. Deleting directories also affects all their contents. All items must be directories.
// @Tags directories
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param id query string true "Comma-separated list of directory IDs to delete" example:"kRp2XYTq9A55,xYz123AbC456"
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 200 {array} DirectoryInfo "Trashed directories (when trash=true)"
// @Success 204 {string} string "Directories deleted"
// @Failure 400 {object} map[string]string "All items must be directories"
// @Failure 401 {string} string "Not authenticated"
// @Router /accounts/{accountID}/directories [delete]
// @Router /drives/{driveID}/directories [delete]
func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
scope, ok := scopeFromCtx(c)
if !ok {
@@ -619,14 +619,14 @@ func (h *HTTPHandler) deleteDirectories(c *fiber.Ctx) error {
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param directoryID path string true "Target directory ID"
// @Param request body postDirectoryContentRequest true "Items to move"
// @Success 200 {object} moveItemsToDirectoryResponse "Move operation results with moved, conflict, and error states"
// @Failure 400 {object} map[string]string "Invalid request or items not in same directory"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {object} map[string]string "One or more items not found"
// @Router /accounts/{accountID}/directories/{directoryID}/content [post]
// @Router /drives/{driveID}/directories/{directoryID}/content [post]
func (h *HTTPHandler) moveItemsToDirectory(c *fiber.Ctx) error {
scope, ok := scopeFromCtx(c)
if !ok {
@@ -769,19 +769,19 @@ func decodeListChildrenCursor(s string) (*decodedListChildrenCursor, error) {
// @Description Get all share links that include this directory
// @Tags directories
// @Produce json
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param directoryID path string true "Directory ID"
// @Success 200 {array} sharing.Share "Array of shares"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Directory not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/directories/{directoryID}/shares [get]
// @Router /drives/{driveID}/directories/{directoryID}/shares [get]
func (h *HTTPHandler) listDirectoryShares(c *fiber.Ctx) error {
node := mustCurrentDirectoryNode(c)
includesExpired := c.Query("includesExpired") == "true"
shares, err := h.sharingService.ListShares(c.Context(), h.db, node.AccountID, sharing.ListSharesOptions{
shares, err := h.sharingService.ListShares(c.Context(), h.db, node.DriveID, sharing.ListSharesOptions{
Items: []*virtualfs.Node{node},
IncludesExpired: includesExpired,
})

View File

@@ -64,12 +64,12 @@ func (h *HTTPHandler) currentFileMiddleware(c *fiber.Ctx) error {
// @Tags files
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param fileID path string true "File ID"
// @Success 200 {object} FileInfo "File metadata"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "File not found"
// @Router /accounts/{accountID}/files/{fileID} [get]
// @Router /drives/{driveID}/files/{fileID} [get]
func (h *HTTPHandler) fetchFile(c *fiber.Ctx) error {
node := mustCurrentFileNode(c)
i := FileInfo{
@@ -91,13 +91,13 @@ func (h *HTTPHandler) fetchFile(c *fiber.Ctx) error {
// @Tags files
// @Produce application/octet-stream
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param fileID path string true "File ID"
// @Success 200 {file} binary "File content stream"
// @Success 307 {string} string "Redirect to download URL"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "File not found"
// @Router /accounts/{accountID}/files/{fileID}/content [get]
// @Router /drives/{driveID}/files/{fileID}/content [get]
func (h *HTTPHandler) downloadFile(c *fiber.Ctx) error {
node := mustCurrentFileNode(c)
scope, ok := scopeFromCtx(c)
@@ -140,14 +140,14 @@ func (h *HTTPHandler) downloadFile(c *fiber.Ctx) error {
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param fileID path string true "File ID"
// @Param request body patchFileRequest true "File update"
// @Success 200 {object} FileInfo "Updated file metadata"
// @Failure 400 {object} map[string]string "Invalid request"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "File not found"
// @Router /accounts/{accountID}/files/{fileID} [patch]
// @Router /drives/{driveID}/files/{fileID} [patch]
func (h *HTTPHandler) patchFile(c *fiber.Ctx) error {
node := mustCurrentFileNode(c)
scope, ok := scopeFromCtx(c)
@@ -201,14 +201,14 @@ func (h *HTTPHandler) patchFile(c *fiber.Ctx) error {
// @Tags files
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param fileID path string true "File ID"
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 200 {object} FileInfo "Trashed file info (when trash=true)"
// @Success 204 {string} string "Permanently deleted (when trash=false)"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "File not found"
// @Router /accounts/{accountID}/files/{fileID} [delete]
// @Router /drives/{driveID}/files/{fileID} [delete]
func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
node := mustCurrentFileNode(c)
scope, ok := scopeFromCtx(c)
@@ -264,14 +264,14 @@ func (h *HTTPHandler) deleteFile(c *fiber.Ctx) error {
// @Description Delete multiple files permanently or move them to trash. All items must be files.
// @Tags files
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param id query string true "Comma-separated list of file IDs to delete" example:"mElnUNCm8F22,kRp2XYTq9A55"
// @Param trash query bool false "Move to trash instead of permanent delete" default(false)
// @Success 200 {array} FileInfo "Trashed files (when trash=true)"
// @Success 204 {string} string "Files deleted"
// @Failure 400 {object} map[string]string "All items must be files"
// @Failure 401 {string} string "Not authenticated"
// @Router /accounts/{accountID}/files [delete]
// @Router /drives/{driveID}/files [delete]
func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
scope, ok := scopeFromCtx(c)
if !ok {
@@ -352,19 +352,19 @@ func (h *HTTPHandler) deleteFiles(c *fiber.Ctx) error {
// @Description Get all share links that include this file
// @Tags files
// @Produce json
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param fileID path string true "File ID"
// @Success 200 {array} sharing.Share "Array of shares"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "File not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/files/{fileID}/shares [get]
// @Router /drives/{driveID}/files/{fileID}/shares [get]
func (h *HTTPHandler) listFileShares(c *fiber.Ctx) error {
node := mustCurrentFileNode(c)
includesExpired := c.Query("includesExpired") == "true"
shares, err := h.sharingService.ListShares(c.Context(), h.db, node.AccountID, sharing.ListSharesOptions{
shares, err := h.sharingService.ListShares(c.Context(), h.db, node.DriveID, sharing.ListSharesOptions{
Items: []*virtualfs.Node{node},
IncludesExpired: includesExpired,
})

View File

@@ -13,16 +13,48 @@ CREATE TABLE IF NOT EXISTS users (
CREATE INDEX idx_users_email ON users(email);
CREATE TABLE IF NOT EXISTS organizations (
id UUID PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('personal', 'team')),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_organizations_kind ON organizations(kind);
-- Accounts represent a user's identity within an organization (membership / principal).
CREATE TABLE IF NOT EXISTS accounts (
id UUID PRIMARY KEY,
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('invited', 'active', 'suspended')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_accounts_org_user_id ON accounts(org_id, user_id);
CREATE INDEX idx_accounts_user_id ON accounts(user_id);
CREATE INDEX idx_accounts_org_id ON accounts(org_id);
-- Drives are the storage tenants; VFS is partitioned by drive_id.
CREATE TABLE IF NOT EXISTS drives (
id UUID PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE,
org_id UUID NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
owner_account_id UUID REFERENCES accounts(id) ON DELETE SET NULL, -- NULL = shared/org-owned drive
name TEXT NOT NULL,
storage_usage_bytes BIGINT NOT NULL DEFAULT 0,
storage_quota_bytes BIGINT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_accounts_user_id ON accounts(user_id);
CREATE INDEX idx_drives_org_id ON drives(org_id);
CREATE INDEX idx_drives_owner_account_id ON drives(owner_account_id) WHERE owner_account_id IS NOT NULL;
CREATE UNIQUE INDEX idx_drives_org_owner_account_id ON drives(org_id, owner_account_id) WHERE owner_account_id IS NOT NULL;
CREATE INDEX idx_drives_public_id ON drives(public_id);
CREATE TABLE IF NOT EXISTS grants (
id UUID PRIMARY KEY,
@@ -49,7 +81,7 @@ CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
CREATE TABLE IF NOT EXISTS vfs_nodes (
id UUID PRIMARY KEY,
public_id TEXT NOT NULL UNIQUE, -- opaque ID for external API (no timestamp leak)
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
drive_id UUID NOT NULL REFERENCES drives(id) ON DELETE CASCADE,
parent_id UUID REFERENCES vfs_nodes(id) ON DELETE CASCADE, -- NULL = root directory
kind TEXT NOT NULL CHECK (kind IN ('file', 'directory')),
status TEXT NOT NULL DEFAULT 'ready' CHECK (status IN ('pending', 'ready')),
@@ -64,23 +96,25 @@ CREATE TABLE IF NOT EXISTS vfs_nodes (
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
deleted_at TIMESTAMPTZ, -- soft delete for trash
-- No duplicate names in same parent (per account, excluding deleted)
CONSTRAINT unique_node_name UNIQUE NULLS NOT DISTINCT (account_id, parent_id, name, deleted_at)
-- No duplicate names in same parent (per drive, excluding deleted)
CONSTRAINT unique_node_name UNIQUE NULLS NOT DISTINCT (drive_id, parent_id, name, deleted_at)
);
CREATE INDEX idx_vfs_nodes_account_id ON vfs_nodes(account_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_vfs_nodes_drive_id ON vfs_nodes(drive_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_vfs_nodes_parent_id ON vfs_nodes(parent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_vfs_nodes_account_parent ON vfs_nodes(account_id, parent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_vfs_nodes_kind ON vfs_nodes(account_id, kind) WHERE deleted_at IS NULL;
CREATE INDEX idx_vfs_nodes_deleted ON vfs_nodes(account_id, deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_vfs_nodes_drive_parent ON vfs_nodes(drive_id, parent_id) WHERE deleted_at IS NULL;
CREATE INDEX idx_vfs_nodes_kind ON vfs_nodes(drive_id, kind) WHERE deleted_at IS NULL;
CREATE INDEX idx_vfs_nodes_deleted ON vfs_nodes(drive_id, deleted_at) WHERE deleted_at IS NOT NULL;
CREATE INDEX idx_vfs_nodes_public_id ON vfs_nodes(public_id);
CREATE UNIQUE INDEX idx_vfs_nodes_account_root ON vfs_nodes(account_id) WHERE parent_id IS NULL; -- one root per account
CREATE UNIQUE INDEX idx_vfs_nodes_drive_root ON vfs_nodes(drive_id) WHERE parent_id IS NULL; -- one root per drive
CREATE INDEX idx_vfs_nodes_pending ON vfs_nodes(created_at) WHERE status = 'pending'; -- for cleanup job
CREATE TABLE IF NOT EXISTS node_shares (
id UUID PRIMARY KEY,
-- the account that owns the share
account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
-- storage tenant that owns the shared content
drive_id UUID NOT NULL REFERENCES drives(id) ON DELETE CASCADE,
-- principal that created/managed the share record
created_by_account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE RESTRICT,
public_id TEXT NOT NULL UNIQUE, -- opaque ID for external API (no timestamp leak)
-- parent directory of the items in this share
shared_directory_id UUID NOT NULL REFERENCES vfs_nodes(id) ON DELETE CASCADE,
@@ -93,6 +127,8 @@ CREATE TABLE IF NOT EXISTS node_shares (
CREATE INDEX idx_node_shares_public_id ON node_shares(public_id);
CREATE INDEX idx_node_shares_shared_directory_id ON node_shares(shared_directory_id);
CREATE INDEX idx_node_shares_expires_at ON node_shares(expires_at) WHERE expires_at IS NOT NULL;
CREATE INDEX idx_node_shares_drive_id ON node_shares(drive_id);
CREATE INDEX idx_node_shares_created_by_account_id ON node_shares(created_by_account_id);
CREATE TABLE IF NOT EXISTS share_permissions (
id UUID PRIMARY KEY,
@@ -139,6 +175,15 @@ $$ LANGUAGE plpgsql;
CREATE TRIGGER update_users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_organizations_updated_at BEFORE UPDATE ON organizations
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON accounts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_drives_updated_at BEFORE UPDATE ON drives
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_vfs_nodes_updated_at BEFORE UPDATE ON vfs_nodes
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
@@ -151,8 +196,5 @@ CREATE TRIGGER update_share_permissions_updated_at BEFORE UPDATE ON share_permis
CREATE TRIGGER update_share_items_updated_at BEFORE UPDATE ON share_items
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_accounts_updated_at BEFORE UPDATE ON accounts
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER update_grants_updated_at BEFORE UPDATE ON grants
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

View File

@@ -0,0 +1,221 @@
//go:build integration
package drexa
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/get-drexa/drexa/internal/database"
"github.com/gofiber/fiber/v2"
"github.com/testcontainers/testcontainers-go/modules/postgres"
)
func TestRegistrationFlow(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
pg, err := runPostgres(ctx)
if err != nil {
t.Skipf("postgres testcontainer unavailable (docker not running/configured?): %v", err)
}
t.Cleanup(func() { _ = pg.Terminate(ctx) })
postgresURL, err := pg.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("postgres connection string: %v", err)
}
blobRoot, err := os.MkdirTemp("", "drexa-blobs-*")
if err != nil {
t.Fatalf("temp blob dir: %v", err)
}
t.Cleanup(func() { _ = os.RemoveAll(blobRoot) })
s, err := NewServer(Config{
Server: ServerConfig{Port: 8080},
Database: DatabaseConfig{
PostgresURL: postgresURL,
},
JWT: JWTConfig{
Issuer: "drexa-test",
Audience: "drexa-test",
SecretKey: []byte("drexa-test-secret"),
},
Storage: StorageConfig{
Mode: StorageModeFlat,
Backend: StorageBackendFS,
RootPath: blobRoot,
},
})
if err != nil {
t.Fatalf("NewServer: %v", err)
}
if err := database.RunMigrations(ctx, s.db); err != nil {
t.Fatalf("RunMigrations: %v", err)
}
type registerResponse struct {
Account struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
UserID string `json:"userId"`
Role string `json:"role"`
Status string `json:"status"`
} `json:"account"`
User struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
} `json:"user"`
Drive struct {
ID string `json:"id"`
} `json:"drive"`
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
}
registerBody := map[string]any{
"email": "alice@example.com",
"password": "password123",
"displayName": "Alice",
"tokenDelivery": "body",
}
var reg registerResponse
doJSON(t, s.app, http.MethodPost, "/api/accounts", "", registerBody, http.StatusOK, &reg)
if reg.AccessToken == "" {
t.Fatalf("expected access token in registration response")
}
if reg.User.Email != "alice@example.com" {
t.Fatalf("unexpected registered user email: %q", reg.User.Email)
}
if reg.Account.ID == "" || reg.Drive.ID == "" {
t.Fatalf("expected account.id and drive.id to be set")
}
t.Run("users/me", func(t *testing.T) {
var me struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
Email string `json:"email"`
}
doJSON(t, s.app, http.MethodGet, "/api/users/me", reg.AccessToken, nil, http.StatusOK, &me)
if me.ID != reg.User.ID {
t.Fatalf("unexpected user id: got %q want %q", me.ID, reg.User.ID)
}
if me.Email != reg.User.Email {
t.Fatalf("unexpected user email: got %q want %q", me.Email, reg.User.Email)
}
})
t.Run("accounts/:id", func(t *testing.T) {
var got struct {
ID string `json:"id"`
OrgID string `json:"orgId"`
UserID string `json:"userId"`
Role string `json:"role"`
Status string `json:"status"`
}
doJSON(t, s.app, http.MethodGet, fmt.Sprintf("/api/accounts/%s", reg.Account.ID), reg.AccessToken, nil, http.StatusOK, &got)
if got.ID != reg.Account.ID {
t.Fatalf("unexpected account id: got %q want %q", got.ID, reg.Account.ID)
}
if got.UserID != reg.User.ID {
t.Fatalf("unexpected account userId: got %q want %q", got.UserID, reg.User.ID)
}
})
t.Run("root directory empty", func(t *testing.T) {
var resp struct {
Items []any `json:"items"`
}
doJSON(
t,
s.app,
http.MethodGet,
fmt.Sprintf("/api/drives/%s/directories/root/content?limit=100", reg.Drive.ID),
reg.AccessToken,
nil,
http.StatusOK,
&resp,
)
if len(resp.Items) != 0 {
t.Fatalf("expected empty root directory, got %d items", len(resp.Items))
}
})
}
func runPostgres(ctx context.Context) (_ *postgres.PostgresContainer, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("testcontainers panic: %v", r)
}
}()
return postgres.Run(
ctx,
"postgres:16-alpine",
postgres.WithDatabase("drexa"),
postgres.WithUsername("drexa"),
postgres.WithPassword("drexa"),
postgres.BasicWaitStrategies(),
)
}
func doJSON(
t *testing.T,
app *fiber.App,
method string,
path string,
accessToken string,
body any,
wantStatus int,
out any,
) {
t.Helper()
var reqBody *bytes.Reader
if body == nil {
reqBody = bytes.NewReader(nil)
} else {
b, err := json.Marshal(body)
if err != nil {
t.Fatalf("json marshal: %v", err)
}
reqBody = bytes.NewReader(b)
}
req := httptest.NewRequest(method, path, reqBody)
req.Header.Set("Content-Type", "application/json")
if accessToken != "" {
req.Header.Set("Authorization", "Bearer "+accessToken)
}
res, err := app.Test(req, 10_000)
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer res.Body.Close()
if res.StatusCode != wantStatus {
b, _ := io.ReadAll(res.Body)
t.Fatalf("%s %s: status %d want %d body=%s", method, path, res.StatusCode, wantStatus, string(b))
}
if out == nil {
return
}
if err := json.NewDecoder(res.Body).Decode(out); err != nil {
t.Fatalf("%s %s: decode response: %v", method, path, err)
}
}

View File

@@ -10,7 +10,10 @@ import (
"github.com/get-drexa/drexa/internal/blob"
"github.com/get-drexa/drexa/internal/catalog"
"github.com/get-drexa/drexa/internal/database"
"github.com/get-drexa/drexa/internal/drive"
"github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/organization"
"github.com/get-drexa/drexa/internal/registration"
"github.com/get-drexa/drexa/internal/sharing"
"github.com/get-drexa/drexa/internal/upload"
"github.com/get-drexa/drexa/internal/user"
@@ -100,13 +103,16 @@ func NewServer(c Config) (*Server, error) {
}
userService := user.NewService()
organizationService := organization.NewService()
accountService := account.NewService()
driveService := drive.NewService()
authService := auth.NewService(userService, auth.TokenConfig{
Issuer: c.JWT.Issuer,
Audience: c.JWT.Audience,
SecretKey: c.JWT.SecretKey,
})
uploadService := upload.NewService(vfs, blobStore)
accountService := account.NewService(userService, vfs)
registrationService := registration.NewService(userService, organizationService, accountService, driveService, vfs)
cookieConfig := auth.CookieConfig{
Domain: c.Cookie.Domain,
@@ -119,15 +125,18 @@ func NewServer(c Config) (*Server, error) {
auth.NewHTTPHandler(authService, db, cookieConfig).RegisterRoutes(api)
user.NewHTTPHandler(userService, db, authMiddleware).RegisterRoutes(api)
accountRouter := account.NewHTTPHandler(accountService, authService, vfs, db, authMiddleware, cookieConfig).RegisterRoutes(api)
upload.NewHTTPHandler(uploadService, db).RegisterRoutes(accountRouter.VFSRouter())
account.NewHTTPHandler(accountService, db, authMiddleware).RegisterRoutes(api)
registration.NewHTTPHandler(registrationService, authService, db, cookieConfig).RegisterRoutes(api)
shareHTTP := sharing.NewHTTPHandler(sharingService, accountService, vfs, db, optionalAuthMiddleware)
driveRouter := drive.NewHTTPHandler(driveService, accountService, vfs, db, authMiddleware).RegisterRoutes(api)
upload.NewHTTPHandler(uploadService, db).RegisterRoutes(driveRouter)
shareHTTP := sharing.NewHTTPHandler(sharingService, accountService, driveService, vfs, db, optionalAuthMiddleware)
shareRoutes := shareHTTP.RegisterShareConsumeRoutes(api)
shareHTTP.RegisterShareManagementRoutes(accountRouter)
shareHTTP.RegisterShareManagementRoutes(driveRouter)
catalogHTTP := catalog.NewHTTPHandler(sharingService, vfs, db)
catalogHTTP.RegisterRoutes(accountRouter.VFSRouter())
catalogHTTP.RegisterRoutes(driveRouter)
catalogHTTP.RegisterRoutes(shareRoutes)
s := &Server{

View File

@@ -0,0 +1,29 @@
package drive
import (
"time"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type Drive struct {
bun.BaseModel `bun:"drives" swaggerignore:"true"`
ID uuid.UUID `bun:",pk,type:uuid" json:"id"`
PublicID string `bun:"public_id,notnull" json:"publicId"`
OrgID uuid.UUID `bun:"org_id,notnull,type:uuid" json:"orgId"`
Name string `bun:"name,notnull" json:"name"`
OwnerAccountID *uuid.UUID `bun:"owner_account_id,type:uuid" json:"ownerAccountId,omitempty"`
StorageUsageBytes int64 `bun:"storage_usage_bytes,notnull" json:"storageUsageBytes"`
StorageQuotaBytes int64 `bun:"storage_quota_bytes,notnull" json:"storageQuotaBytes"`
CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"createdAt"`
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"updatedAt"`
}
func newDriveID() (uuid.UUID, error) {
return uuid.NewV7()
}

View File

@@ -0,0 +1,9 @@
package drive
import "errors"
var (
ErrDriveNotFound = errors.New("drive not found")
ErrDriveForbidden = errors.New("drive forbidden")
ErrDriveNotAllowed = errors.New("drive not allowed")
)

View File

@@ -0,0 +1,114 @@
package drive
import (
"errors"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/reqctx"
"github.com/get-drexa/drexa/internal/user"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type HTTPHandler struct {
driveService *Service
accountService *account.Service
vfs *virtualfs.VirtualFS
db *bun.DB
authMiddleware fiber.Handler
}
func NewHTTPHandler(driveService *Service, accountService *account.Service, vfs *virtualfs.VirtualFS, db *bun.DB, authMiddleware fiber.Handler) *HTTPHandler {
return &HTTPHandler{
driveService: driveService,
accountService: accountService,
vfs: vfs,
db: db,
authMiddleware: authMiddleware,
}
}
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) *virtualfs.ScopedRouter {
api.Get("/drives", h.authMiddleware, h.listDrives)
drive := api.Group("/drives/:driveID")
drive.Use(h.authMiddleware)
drive.Use(h.driveMiddleware)
drive.Get("/", h.getDrive)
return &virtualfs.ScopedRouter{Router: drive}
}
func (h *HTTPHandler) listDrives(c *fiber.Ctx) error {
u := reqctx.AuthenticatedUser(c).(*user.User)
drives, err := h.driveService.ListDrivesForUser(c.Context(), h.db, u.ID)
if err != nil {
return httperr.Internal(err)
}
return c.JSON(drives)
}
func (h *HTTPHandler) getDrive(c *fiber.Ctx) error {
drive, ok := reqctx.CurrentDrive(c).(*Drive)
if !ok || drive == nil {
return c.SendStatus(fiber.StatusNotFound)
}
return c.JSON(drive)
}
func (h *HTTPHandler) driveMiddleware(c *fiber.Ctx) error {
u := reqctx.AuthenticatedUser(c).(*user.User)
driveID, err := uuid.Parse(c.Params("driveID"))
if err != nil {
return c.SendStatus(fiber.StatusNotFound)
}
drive, err := h.driveService.DriveByID(c.Context(), h.db, driveID)
if err != nil {
if errors.Is(err, ErrDriveNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
acc, err := h.accountService.FindUserAccountInOrg(c.Context(), h.db, drive.OrgID, u.ID)
if err != nil {
if errors.Is(err, account.ErrAccountNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
if acc.Status != account.StatusActive {
return c.SendStatus(fiber.StatusNotFound)
}
if !h.driveService.CanAccessDrive(drive, acc.OrgID, acc.ID) {
return c.SendStatus(fiber.StatusNotFound)
}
root, err := h.vfs.FindRootDirectory(c.Context(), h.db, drive.ID)
if err != nil {
return httperr.Internal(err)
}
scope := &virtualfs.Scope{
DriveID: drive.ID,
RootNodeID: root.ID,
AllowedOps: virtualfs.AllAllowedOps,
AllowedNodes: nil,
ActorKind: virtualfs.ScopeActorAccount,
ActorID: acc.ID,
}
reqctx.SetCurrentDrive(c, drive)
reqctx.SetCurrentAccount(c, acc)
reqctx.SetVFSAccessScope(c, scope)
return c.Next()
}

View File

@@ -0,0 +1,130 @@
package drive
import (
"context"
"crypto/rand"
"database/sql"
"encoding/binary"
"errors"
"github.com/get-drexa/drexa/internal/account"
"github.com/google/uuid"
"github.com/sqids/sqids-go"
"github.com/uptrace/bun"
)
type Service struct{}
type CreateDriveOptions struct {
OrgID uuid.UUID
OwnerAccountID *uuid.UUID
Name string
QuotaBytes int64
}
func NewService() *Service {
return &Service{}
}
func (s *Service) CreateDrive(ctx context.Context, db bun.IDB, opts CreateDriveOptions) (*Drive, error) {
publicID, err := generatePublicID()
if err != nil {
return nil, err
}
id, err := newDriveID()
if err != nil {
return nil, err
}
drive := &Drive{
ID: id,
PublicID: publicID,
OrgID: opts.OrgID,
OwnerAccountID: opts.OwnerAccountID,
Name: opts.Name,
StorageQuotaBytes: opts.QuotaBytes,
}
_, err = db.NewInsert().Model(drive).Returning("*").Exec(ctx)
if err != nil {
return nil, err
}
return drive, nil
}
func (s *Service) DriveByID(ctx context.Context, db bun.IDB, id uuid.UUID) (*Drive, error) {
var drive Drive
err := db.NewSelect().Model(&drive).Where("id = ?", id).Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrDriveNotFound
}
return nil, err
}
return &drive, nil
}
// ListAccessibleDrives returns drives a principal account can access:
// - personal drives: owner_account_id = account.ID
// - shared drives: owner_account_id IS NULL (future)
func (s *Service) ListAccessibleDrives(ctx context.Context, db bun.IDB, orgID uuid.UUID, accountID uuid.UUID) ([]*Drive, error) {
var drives []*Drive
err := db.NewSelect().Model(&drives).
Where("org_id = ?", orgID).
Where("owner_account_id IS NULL OR owner_account_id = ?", accountID).
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return make([]*Drive, 0), nil
}
return nil, err
}
return drives, nil
}
// ListDrivesForUser returns all drives the user can access across orgs.
func (s *Service) ListDrivesForUser(ctx context.Context, db bun.IDB, userID uuid.UUID) ([]*Drive, error) {
var drives []*Drive
err := db.NewSelect().Model(&drives).
Join("JOIN accounts a ON a.org_id = drives.org_id").
Where("a.user_id = ?", userID).
Where("a.status = ?", account.StatusActive).
Where("drives.owner_account_id IS NULL OR drives.owner_account_id = a.id").
Scan(ctx)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return make([]*Drive, 0), nil
}
return nil, err
}
return drives, nil
}
func (s *Service) CanAccessDrive(drive *Drive, orgID uuid.UUID, accountID uuid.UUID) bool {
if drive == nil {
return false
}
if drive.OrgID != orgID {
return false
}
if drive.OwnerAccountID == nil {
return true
}
return *drive.OwnerAccountID == accountID
}
func generatePublicID() (string, error) {
sqid, err := sqids.New()
if err != nil {
return "", err
}
var b [8]byte
_, err = rand.Read(b[:])
if err != nil {
return "", err
}
n := binary.BigEndian.Uint64(b[:])
return sqid.Encode([]uint64{n})
}

View File

@@ -0,0 +1,31 @@
package organization
import (
"time"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type Kind string
const (
KindPersonal Kind = "personal"
KindTeam Kind = "team"
)
type Organization struct {
bun.BaseModel `bun:"organizations" swaggerignore:"true"`
ID uuid.UUID `bun:",pk,type:uuid" json:"id"`
Kind Kind `bun:"kind,notnull" json:"kind" example:"personal"`
Name string `bun:"name,notnull" json:"name" example:"Personal"`
CreatedAt time.Time `bun:"created_at,notnull,nullzero" json:"createdAt"`
UpdatedAt time.Time `bun:"updated_at,notnull,nullzero" json:"updatedAt"`
}
func newOrganizationID() (uuid.UUID, error) {
return uuid.NewV7()
}

View File

@@ -0,0 +1,44 @@
package organization
import (
"context"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type Service struct{}
func NewService() *Service {
return &Service{}
}
func (s *Service) CreatePersonalOrganization(ctx context.Context, db bun.IDB, name string) (*Organization, error) {
id, err := newOrganizationID()
if err != nil {
return nil, err
}
org := &Organization{
ID: id,
Kind: KindPersonal,
Name: name,
}
_, err = db.NewInsert().Model(org).Returning("*").Exec(ctx)
if err != nil {
return nil, err
}
return org, nil
}
func (s *Service) OrganizationByID(ctx context.Context, db bun.IDB, id uuid.UUID) (*Organization, error) {
var org Organization
err := db.NewSelect().Model(&org).Where("id = ?", id).Scan(ctx)
if err != nil {
return nil, err
}
return &org, nil
}

View File

@@ -0,0 +1,107 @@
package registration
import (
"errors"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/auth"
"github.com/get-drexa/drexa/internal/drive"
"github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/user"
"github.com/gofiber/fiber/v2"
"github.com/uptrace/bun"
)
type HTTPHandler struct {
service *Service
authService *auth.Service
db *bun.DB
cookieConfig auth.CookieConfig
}
type registerAccountRequest struct {
Email string `json:"email"`
Password string `json:"password"`
DisplayName string `json:"displayName"`
TokenDelivery string `json:"tokenDelivery" enums:"cookie,body"`
}
type registerAccountResponse struct {
Account *account.Account `json:"account"`
User *user.User `json:"user"`
Drive *drive.Drive `json:"drive"`
AccessToken string `json:"accessToken,omitempty"`
RefreshToken string `json:"refreshToken,omitempty"`
}
func NewHTTPHandler(service *Service, authService *auth.Service, db *bun.DB, cookieConfig auth.CookieConfig) *HTTPHandler {
return &HTTPHandler{
service: service,
authService: authService,
db: db,
cookieConfig: cookieConfig,
}
}
func (h *HTTPHandler) RegisterRoutes(api fiber.Router) {
api.Post("/accounts", h.registerAccount)
}
func (h *HTTPHandler) registerAccount(c *fiber.Ctx) error {
req := new(registerAccountRequest)
if err := c.BodyParser(req); err != nil {
return c.SendStatus(fiber.StatusBadRequest)
}
tx, err := h.db.BeginTx(c.Context(), nil)
if err != nil {
return httperr.Internal(err)
}
defer tx.Rollback()
result, err := h.service.Register(c.Context(), tx, RegisterOptions{
Email: req.Email,
Password: req.Password,
DisplayName: req.DisplayName,
})
if err != nil {
var ae *user.AlreadyExistsError
if errors.As(err, &ae) {
return c.SendStatus(fiber.StatusConflict)
}
if errors.Is(err, account.ErrAccountAlreadyExists) {
return c.SendStatus(fiber.StatusConflict)
}
return httperr.Internal(err)
}
grant, err := h.authService.GrantForUser(c.Context(), tx, result.User)
if err != nil {
return httperr.Internal(err)
}
if err := tx.Commit(); err != nil {
return httperr.Internal(err)
}
resp := registerAccountResponse{
Account: result.Account,
User: result.User,
Drive: result.Drive,
}
switch req.TokenDelivery {
default:
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid token delivery method"})
case auth.TokenDeliveryCookie:
auth.SetAuthCookies(c, grant.AccessToken, grant.RefreshToken, h.cookieConfig)
return c.JSON(resp)
case auth.TokenDeliveryBody:
resp.AccessToken = grant.AccessToken
resp.RefreshToken = grant.RefreshToken
return c.JSON(resp)
}
}

View File

@@ -0,0 +1,90 @@
package registration
import (
"context"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/organization"
"github.com/get-drexa/drexa/internal/password"
"github.com/get-drexa/drexa/internal/user"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/get-drexa/drexa/internal/drive"
"github.com/uptrace/bun"
)
type Service struct {
userService user.Service
organizationService organization.Service
accountService account.Service
driveService drive.Service
vfs *virtualfs.VirtualFS
}
type RegisterOptions struct {
Email string
Password string
DisplayName string
}
type RegisterResult struct {
Account *account.Account
User *user.User
Drive *drive.Drive
}
func NewService(userService *user.Service, organizationService *organization.Service, accountService *account.Service, driveService *drive.Service, vfs *virtualfs.VirtualFS) *Service {
return &Service{
userService: *userService,
organizationService: *organizationService,
accountService: *accountService,
driveService: *driveService,
vfs: vfs,
}
}
func (s *Service) Register(ctx context.Context, db bun.IDB, opts RegisterOptions) (*RegisterResult, error) {
hashed, err := password.HashString(opts.Password)
if err != nil {
return nil, err
}
u, err := s.userService.RegisterUser(ctx, db, user.UserRegistrationOptions{
Email: opts.Email,
Password: hashed,
DisplayName: opts.DisplayName,
})
if err != nil {
return nil, err
}
org, err := s.organizationService.CreatePersonalOrganization(ctx, db, "Personal")
if err != nil {
return nil, err
}
acc, err := s.accountService.CreateAccount(ctx, db, org.ID, u.ID, account.RoleAdmin, account.StatusActive)
if err != nil {
return nil, err
}
drv, err := s.driveService.CreateDrive(ctx, db, drive.CreateDriveOptions{
OrgID: org.ID,
OwnerAccountID: &acc.ID,
Name: "My Drive",
QuotaBytes: 1024 * 1024 * 1024, // 1GB
})
if err != nil {
return nil, err
}
_, err = s.vfs.CreateRootDirectory(ctx, db, drv.ID)
if err != nil {
return nil, err
}
return &RegisterResult{
Account: acc,
User: u,
Drive: drv,
}, nil
}

View File

@@ -9,6 +9,7 @@ import (
const authenticatedUserKey = "authenticatedUser"
const vfsAccessScope = "vfsAccessScope"
const currentAccountKey = "currentAccount"
const currentDriveKey = "currentDrive"
var ErrUnauthenticatedRequest = errors.New("unauthenticated request")
@@ -29,6 +30,11 @@ func SetCurrentAccount(c *fiber.Ctx, account any) {
c.Locals(currentAccountKey, account)
}
// SetCurrentDrive sets the current drive in the fiber context.
func SetCurrentDrive(c *fiber.Ctx, drive any) {
c.Locals(currentDriveKey, drive)
}
// SetVFSAccessScope sets the VFS access scope in the fiber context.
func SetVFSAccessScope(c *fiber.Ctx, scope any) {
c.Locals(vfsAccessScope, scope)
@@ -39,6 +45,11 @@ func CurrentAccount(c *fiber.Ctx) any {
return c.Locals(currentAccountKey)
}
// CurrentDrive returns the current drive from the given fiber context.
func CurrentDrive(c *fiber.Ctx) any {
return c.Locals(currentDriveKey)
}
// VFSAccessScope returns the VFS access scope from the given fiber context.
func VFSAccessScope(c *fiber.Ctx) any {
return c.Locals(vfsAccessScope)

View File

@@ -5,21 +5,22 @@ import (
"time"
"github.com/get-drexa/drexa/internal/account"
"github.com/get-drexa/drexa/internal/drive"
"github.com/get-drexa/drexa/internal/httperr"
"github.com/get-drexa/drexa/internal/nullable"
"github.com/get-drexa/drexa/internal/reqctx"
"github.com/get-drexa/drexa/internal/user"
"github.com/get-drexa/drexa/internal/virtualfs"
"github.com/gofiber/fiber/v2"
"github.com/google/uuid"
"github.com/uptrace/bun"
)
type HTTPHandler struct {
sharingService *Service
accountService *account.Service
vfs *virtualfs.VirtualFS
db *bun.DB
sharingService *Service
accountService *account.Service
driveService *drive.Service
vfs *virtualfs.VirtualFS
db *bun.DB
optionalAuthMiddleware fiber.Handler
}
@@ -39,12 +40,13 @@ type patchShareRequest struct {
ExpiresAt nullable.Time `json:"expiresAt" example:"2025-01-15T00:00:00Z"`
}
func NewHTTPHandler(sharingService *Service, accountService *account.Service, vfs *virtualfs.VirtualFS, db *bun.DB, optionalAuthMiddleware fiber.Handler) *HTTPHandler {
func NewHTTPHandler(sharingService *Service, accountService *account.Service, driveService *drive.Service, vfs *virtualfs.VirtualFS, db *bun.DB, optionalAuthMiddleware fiber.Handler) *HTTPHandler {
return &HTTPHandler{
sharingService: sharingService,
accountService: accountService,
vfs: vfs,
db: db,
sharingService: sharingService,
accountService: accountService,
driveService: driveService,
vfs: vfs,
db: db,
optionalAuthMiddleware: optionalAuthMiddleware,
}
}
@@ -57,7 +59,7 @@ func (h *HTTPHandler) RegisterShareConsumeRoutes(r fiber.Router) *virtualfs.Scop
return &virtualfs.ScopedRouter{Router: g}
}
func (h *HTTPHandler) RegisterShareManagementRoutes(api *account.ScopedRouter) {
func (h *HTTPHandler) RegisterShareManagementRoutes(api *virtualfs.ScopedRouter) {
g := api.Group("/shares")
g.Post("/", h.createShare)
g.Get("/:shareID", h.getShare)
@@ -76,33 +78,23 @@ func (h *HTTPHandler) shareMiddleware(c *fiber.Ctx) error {
return httperr.Internal(err)
}
// a share can be public or shared to specific accounts
// if latter, the accountId query param is expected and the route should be authenticated
// then the correct account is found using the authenticated user and the accountId query param
// finally, the account scope is resolved for the share
// otherwise, consumerAccount will be nil to attempt to resolve a public scope for the share
var consumerAccount *account.Account
qAccountID := c.Query("accountId")
if qAccountID != "" {
consumerAccountID, err := uuid.Parse(qAccountID)
u, _ := reqctx.AuthenticatedUser(c).(*user.User)
if u != nil {
drive, err := h.driveService.DriveByID(c.Context(), h.db, share.DriveID)
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": "invalid account ID",
})
return httperr.Internal(err)
}
u, _ := reqctx.AuthenticatedUser(c).(*user.User)
if u == nil {
return c.SendStatus(fiber.StatusUnauthorized)
}
consumerAccount, err = h.accountService.AccountByID(c.Context(), h.db, u.ID, consumerAccountID)
consumerAccount, err = h.accountService.FindUserAccountInOrg(c.Context(), h.db, drive.OrgID, u.ID)
if err != nil {
if errors.Is(err, account.ErrAccountNotFound) {
return c.SendStatus(fiber.StatusNotFound)
consumerAccount = nil
} else {
return httperr.Internal(err)
}
return httperr.Internal(err)
} else if consumerAccount.Status != account.StatusActive {
consumerAccount = nil
}
}
@@ -132,19 +124,28 @@ func (h *HTTPHandler) shareMiddleware(c *fiber.Ctx) error {
// @Tags shares
// @Accept json
// @Produce json
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param shareID path string true "Share ID"
// @Success 200 {object} Share "Share details"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Share not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/shares/{shareID} [get]
// @Router /drives/{driveID}/shares/{shareID} [get]
func (h *HTTPHandler) getShare(c *fiber.Ctx) error {
shareID := c.Params("shareID")
share, err := h.sharingService.FindShareByPublicID(c.Context(), h.db, shareID)
if err != nil {
if errors.Is(err, ErrShareNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
drive, _ := reqctx.CurrentDrive(c).(*drive.Drive)
if drive == nil || share.DriveID != drive.ID {
return c.SendStatus(fiber.StatusNotFound)
}
return c.JSON(share)
}
@@ -154,14 +155,14 @@ func (h *HTTPHandler) getShare(c *fiber.Ctx) error {
// @Tags shares
// @Accept json
// @Produce json
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param request body createShareRequest true "Share details"
// @Success 200 {object} Share "Created share"
// @Failure 400 {object} map[string]string "Invalid request, items not in same directory, or root directory cannot be shared"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {object} map[string]string "One or more items not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/shares [post]
// @Router /drives/{driveID}/shares [post]
func (h *HTTPHandler) createShare(c *fiber.Ctx) error {
scope, ok := scopeFromCtx(c)
if !ok {
@@ -173,6 +174,11 @@ func (h *HTTPHandler) createShare(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusUnauthorized)
}
drive, _ := reqctx.CurrentDrive(c).(*drive.Drive)
if drive == nil {
return c.SendStatus(fiber.StatusUnauthorized)
}
var req createShareRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
@@ -207,7 +213,7 @@ func (h *HTTPHandler) createShare(c *fiber.Ctx) error {
opts.ExpiresAt = *req.ExpiresAt
}
share, err := h.sharingService.CreateShare(c.Context(), tx, acc.ID, opts)
share, err := h.sharingService.CreateShare(c.Context(), tx, drive.ID, acc.ID, opts)
if err != nil {
if errors.Is(err, ErrNotSameParent) {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "items must be in the same directory"})
@@ -232,7 +238,7 @@ func (h *HTTPHandler) createShare(c *fiber.Ctx) error {
// @Tags shares
// @Accept json
// @Produce json
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param shareID path string true "Share ID"
// @Param request body patchShareRequest true "Share details"
// @Success 200 {object} Share "Updated share"
@@ -240,7 +246,7 @@ func (h *HTTPHandler) createShare(c *fiber.Ctx) error {
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Share not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/shares/{shareID} [patch]
// @Router /drives/{driveID}/shares/{shareID} [patch]
func (h *HTTPHandler) updateShare(c *fiber.Ctx) error {
shareID := c.Params("shareID")
@@ -252,6 +258,16 @@ func (h *HTTPHandler) updateShare(c *fiber.Ctx) error {
return httperr.Internal(err)
}
drive, _ := reqctx.CurrentDrive(c).(*drive.Drive)
if drive == nil || share.DriveID != drive.ID {
return c.SendStatus(fiber.StatusNotFound)
}
acc, _ := reqctx.CurrentAccount(c).(*account.Account)
if acc == nil || (acc.Role != account.RoleAdmin && share.CreatedByAccountID != acc.ID) {
return c.SendStatus(fiber.StatusNotFound)
}
var req patchShareRequest
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
@@ -291,16 +307,34 @@ func (h *HTTPHandler) updateShare(c *fiber.Ctx) error {
// @Summary Delete share
// @Description Delete a share link, revoking access for all users
// @Tags shares
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param shareID path string true "Share ID"
// @Success 204 {string} string "Share deleted"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Share not found"
// @Security BearerAuth
// @Router /accounts/{accountID}/shares/{shareID} [delete]
// @Router /drives/{driveID}/shares/{shareID} [delete]
func (h *HTTPHandler) deleteShare(c *fiber.Ctx) error {
shareID := c.Params("shareID")
share, err := h.sharingService.FindShareByPublicID(c.Context(), h.db, shareID)
if err != nil {
if errors.Is(err, ErrShareNotFound) {
return c.SendStatus(fiber.StatusNotFound)
}
return httperr.Internal(err)
}
drive, _ := reqctx.CurrentDrive(c).(*drive.Drive)
if drive == nil || share.DriveID != drive.ID {
return c.SendStatus(fiber.StatusNotFound)
}
acc, _ := reqctx.CurrentAccount(c).(*account.Account)
if acc == nil || (acc.Role != account.RoleAdmin && share.CreatedByAccountID != acc.ID) {
return c.SendStatus(fiber.StatusNotFound)
}
tx, err := h.db.BeginTx(c.Context(), nil)
if err != nil {
return httperr.Internal(err)

View File

@@ -59,7 +59,7 @@ func NewService(vfs *virtualfs.VirtualFS) (*Service, error) {
// CreateShare creates a share record for its allowed items.
// A share is a partial share of a directory: the share root is always the common parent directory of all items.
func (s *Service) CreateShare(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts CreateShareOptions) (*Share, error) {
func (s *Service) CreateShare(ctx context.Context, db bun.IDB, driveID uuid.UUID, createdByAccountID uuid.UUID, opts CreateShareOptions) (*Share, error) {
if len(opts.Items) == 0 {
return nil, ErrShareNoItems
}
@@ -87,12 +87,13 @@ func (s *Service) CreateShare(ctx context.Context, db bun.IDB, accountID uuid.UU
now := time.Now()
sh := &Share{
ID: id,
AccountID: accountID,
PublicID: pid,
SharedDirectoryID: sharedDirectoryID,
CreatedAt: now,
UpdatedAt: now,
ID: id,
DriveID: driveID,
CreatedByAccountID: createdByAccountID,
PublicID: pid,
SharedDirectoryID: sharedDirectoryID,
CreatedAt: now,
UpdatedAt: now,
}
if !opts.ExpiresAt.IsZero() {
@@ -165,11 +166,11 @@ func (s *Service) FindShareByPublicID(ctx context.Context, db bun.IDB, publicID
return sh, nil
}
func (s *Service) ListShares(ctx context.Context, db bun.IDB, accountID uuid.UUID, opts ListSharesOptions) ([]Share, error) {
func (s *Service) ListShares(ctx context.Context, db bun.IDB, driveID uuid.UUID, opts ListSharesOptions) ([]Share, error) {
var shares []Share
q := db.NewSelect().Model(&shares).
Where("account_id = ?", accountID)
Where("drive_id = ?", driveID)
if !opts.IncludesExpired {
q = q.Where("expires_at IS NULL OR expires_at > NOW()")
@@ -260,7 +261,7 @@ func (s *Service) ResolveScopeForShare(ctx context.Context, db bun.IDB, consumer
}
scope := &virtualfs.Scope{
AccountID: share.AccountID,
DriveID: share.DriveID,
RootNodeID: share.SharedDirectoryID,
}

View File

@@ -12,8 +12,9 @@ import (
type Share struct {
bun.BaseModel `bun:"node_shares"`
ID uuid.UUID `bun:",pk,type:uuid" json:"-"`
AccountID uuid.UUID `bun:"account_id,notnull,type:uuid" json:"-"`
ID uuid.UUID `bun:",pk,type:uuid" json:"-"`
DriveID uuid.UUID `bun:"drive_id,notnull,type:uuid" json:"-"`
CreatedByAccountID uuid.UUID `bun:"created_by_account_id,notnull,type:uuid" json:"-"`
// Unique share identifier (public ID)
PublicID string `bun:"public_id,notnull" json:"id" example:"kRp2XYTq9A55"`
SharedDirectoryID uuid.UUID `bun:"shared_directory_id,notnull,type:uuid" json:"-"`

View File

@@ -51,14 +51,14 @@ func (h *HTTPHandler) RegisterRoutes(api *virtualfs.ScopedRouter) {
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param request body createUploadRequest true "Upload details"
// @Success 200 {object} Upload "Upload session created"
// @Failure 400 {object} map[string]string "Parent is not a directory"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Parent directory not found"
// @Failure 409 {object} map[string]string "File with this name already exists"
// @Router /accounts/{accountID}/uploads [post]
// @Router /drives/{driveID}/uploads [post]
func (h *HTTPHandler) Create(c *fiber.Ctx) error {
scopeAny := reqctx.VFSAccessScope(c)
scope, ok := scopeAny.(*virtualfs.Scope)
@@ -107,13 +107,13 @@ func (h *HTTPHandler) Create(c *fiber.Ctx) error {
// @Tags uploads
// @Accept application/octet-stream
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param uploadID path string true "Upload session ID"
// @Param file body []byte true "File content (binary)"
// @Success 204 {string} string "Content received successfully"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Upload session not found"
// @Router /accounts/{accountID}/uploads/{uploadID}/content [put]
// @Router /drives/{driveID}/uploads/{uploadID}/content [put]
func (h *HTTPHandler) ReceiveContent(c *fiber.Ctx) error {
scopeAny := reqctx.VFSAccessScope(c)
scope, ok := scopeAny.(*virtualfs.Scope)
@@ -148,14 +148,14 @@ func (h *HTTPHandler) ReceiveContent(c *fiber.Ctx) error {
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param accountID path string true "Account ID" format(uuid)
// @Param driveID path string true "Drive ID" format(uuid)
// @Param uploadID path string true "Upload session ID"
// @Param request body updateUploadRequest true "Status update"
// @Success 200 {object} Upload "Upload completed"
// @Failure 400 {object} map[string]string "Content not uploaded yet or invalid status"
// @Failure 401 {string} string "Not authenticated"
// @Failure 404 {string} string "Upload session not found"
// @Router /accounts/{accountID}/uploads/{uploadID} [patch]
// @Router /drives/{driveID}/uploads/{uploadID} [patch]
func (h *HTTPHandler) Update(c *fiber.Ctx) error {
scopeAny := reqctx.VFSAccessScope(c)
scope, ok := scopeAny.(*virtualfs.Scope)

View File

@@ -103,7 +103,7 @@ func (s *Service) ReceiveUpload(ctx context.Context, db bun.IDB, uploadID string
return ErrUnauthorized
}
if upload.TargetNode.AccountID != scope.AccountID {
if upload.TargetNode.DriveID != scope.DriveID {
return ErrNotFound
}
@@ -132,7 +132,7 @@ func (s *Service) CompleteUpload(ctx context.Context, db bun.IDB, uploadID strin
return nil, ErrUnauthorized
}
if upload.TargetNode.AccountID != scope.AccountID {
if upload.TargetNode.DriveID != scope.DriveID {
return nil, ErrNotFound
}

View File

@@ -25,5 +25,5 @@ type Upload struct {
// Internal target node reference
TargetNode *virtualfs.Node `json:"-" swaggerignore:"true"`
// URL to upload file content to
UploadURL string `json:"uploadUrl" example:"https://api.example.com/api/accounts/550e8400-e29b-41d4-a716-446655440000/uploads/xNq5RVBt3K88/content"`
UploadURL string `json:"uploadUrl" example:"https://api.example.com/api/drives/550e8400-e29b-41d4-a716-446655440000/uploads/xNq5RVBt3K88/content"`
}

View File

@@ -29,7 +29,7 @@ func (r *HierarchicalKeyResolver) Resolve(ctx context.Context, db bun.IDB, node
return "", err
}
return blob.Key(fmt.Sprintf("%s/%s", node.AccountID, path)), nil
return blob.Key(fmt.Sprintf("%s/%s", node.DriveID, path)), nil
}
func (r *HierarchicalKeyResolver) ResolveDeletionKeys(ctx context.Context, node *Node, allKeys []blob.Key) (*DeletionPlan, error) {
@@ -37,7 +37,7 @@ func (r *HierarchicalKeyResolver) ResolveDeletionKeys(ctx context.Context, node
if err != nil {
return nil, err
}
return &DeletionPlan{Prefix: blob.Key(fmt.Sprintf("%s/%s", node.AccountID, path))}, nil
return &DeletionPlan{Prefix: blob.Key(fmt.Sprintf("%s/%s", node.DriveID, path))}, nil
}
// ResolveBulkMoveOps computes blob move operations for nodes being moved to a new parent.
@@ -48,7 +48,7 @@ func (r *HierarchicalKeyResolver) ResolveBulkMoveOps(ctx context.Context, db bun
return nil, nil
}
accountID := nodes[0].AccountID
driveID := nodes[0].DriveID
oldParentID := nodes[0].ParentID
for _, node := range nodes[1:] {
@@ -70,8 +70,8 @@ func (r *HierarchicalKeyResolver) ResolveBulkMoveOps(ctx context.Context, db bun
// For each node, construct old and new keys using the precomputed parent paths
ops := make([]BlobMoveOp, len(nodes))
for i, node := range nodes {
oldKey := blob.Key(fmt.Sprintf("%s/%s/%s", accountID, oldParentPath, node.Name))
newKey := blob.Key(fmt.Sprintf("%s/%s/%s", accountID, newParentPath, node.Name))
oldKey := blob.Key(fmt.Sprintf("%s/%s/%s", driveID, oldParentPath, node.Name))
newKey := blob.Key(fmt.Sprintf("%s/%s/%s", driveID, newParentPath, node.Name))
ops[i] = BlobMoveOp{Node: node, OldKey: oldKey, NewKey: newKey}
}

View File

@@ -25,13 +25,13 @@ const (
type Node struct {
bun.BaseModel `bun:"vfs_nodes"`
ID uuid.UUID `bun:",pk,type:uuid"`
PublicID string `bun:"public_id,notnull"`
AccountID uuid.UUID `bun:"account_id,notnull,type:uuid"`
ParentID uuid.UUID `bun:"parent_id,nullzero"`
Kind NodeKind `bun:"kind,notnull"`
Status NodeStatus `bun:"status,notnull"`
Name string `bun:"name,notnull"`
ID uuid.UUID `bun:",pk,type:uuid"`
PublicID string `bun:"public_id,notnull"`
DriveID uuid.UUID `bun:"drive_id,notnull,type:uuid"`
ParentID uuid.UUID `bun:"parent_id,nullzero"`
Kind NodeKind `bun:"kind,notnull"`
Status NodeStatus `bun:"status,notnull"`
Name string `bun:"name,notnull"`
BlobKey blob.Key `bun:"blob_key,nullzero"`
Size int64 `bun:"size"`

View File

@@ -5,8 +5,8 @@ import "github.com/google/uuid"
// Scope defines the bounded view of the virtual filesystem that a caller is allowed to operate on.
// It is populated by higher layers (account/share middleware) and enforced by VFS methods.
type Scope struct {
// AccountID is the owner of the storage. It stays constant even when a share actor accesses it.
AccountID uuid.UUID
// DriveID is the owner of the storage (the tenant). It stays constant even when a share actor accesses it.
DriveID uuid.UUID
// RootNodeID is the top-most node the caller is allowed to traverse; all accesses must stay under it.
// It must be set for all VFS access operations.

View File

@@ -81,7 +81,7 @@ FROM node_paths
WHERE id = ?;`
func isScopeSet(scope *Scope) bool {
return scope != nil && scope.AccountID != uuid.Nil && scope.RootNodeID != uuid.Nil
return scope != nil && scope.DriveID != uuid.Nil && scope.RootNodeID != uuid.Nil
}
// canAccessNode checks if the scope permits the operation and allows access to the node.

View File

@@ -6,7 +6,7 @@ import "github.com/gofiber/fiber/v2"
// returns a valid *Scope for all registered routes.
//
// This is the base type for routers that provide VFS access scope.
// More specific router types (like account.ScopedRouter) may embed this
// More specific router types may embed this
// to provide additional guarantees.
type ScopedRouter struct {
fiber.Router

View File

@@ -94,7 +94,7 @@ func (vfs *VirtualFS) FindNode(ctx context.Context, db bun.IDB, fileID string, s
var node Node
err := db.NewSelect().Model(&node).
Where("account_id = ?", scope.AccountID).
Where("drive_id = ?", scope.DriveID).
Where("id = ?", fileID).
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
@@ -135,7 +135,7 @@ func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, publi
var nodes []*Node
err := db.NewSelect().Model(&nodes).
Where("account_id = ?", scope.AccountID).
Where("drive_id = ?", scope.DriveID).
Where("public_id IN (?)", bun.In(publicIDs)).
Where("status = ?", NodeStatusReady).
Scan(ctx)
@@ -146,11 +146,11 @@ func (vfs *VirtualFS) FindNodesByPublicID(ctx context.Context, db bun.IDB, publi
return vfs.filterNodesByScope(ctx, db, scope, nodes)
}
func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) {
func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, driveID uuid.UUID) (*Node, error) {
root := new(Node)
err := db.NewSelect().Model(root).
Where("account_id = ?", accountID).
Where("drive_id = ?", driveID).
Where("parent_id IS NULL").
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL").
@@ -166,8 +166,8 @@ func (vfs *VirtualFS) FindRootDirectory(ctx context.Context, db bun.IDB, account
return root, nil
}
// CreateRootDirectory creates the account root directory node.
func (vfs *VirtualFS) CreateRootDirectory(ctx context.Context, db bun.IDB, accountID uuid.UUID) (*Node, error) {
// CreateRootDirectory creates the drive root directory node.
func (vfs *VirtualFS) CreateRootDirectory(ctx context.Context, db bun.IDB, driveID uuid.UUID) (*Node, error) {
pid, err := vfs.generatePublicID()
if err != nil {
return nil, err
@@ -181,7 +181,7 @@ func (vfs *VirtualFS) CreateRootDirectory(ctx context.Context, db bun.IDB, accou
node := &Node{
ID: id,
PublicID: pid,
AccountID: accountID,
DriveID: driveID,
ParentID: uuid.Nil,
Kind: NodeKindDirectory,
Status: NodeStatusReady,
@@ -212,7 +212,7 @@ func (vfs *VirtualFS) ListChildren(ctx context.Context, db bun.IDB, node *Node,
var nodes []*Node
q := db.NewSelect().Model(&nodes).
Where("account_id = ?", node.AccountID).
Where("drive_id = ?", node.DriveID).
Where("parent_id = ?", node.ID).
Where("status = ?", NodeStatusReady).
Where("deleted_at IS NULL")
@@ -326,13 +326,13 @@ func (vfs *VirtualFS) CreateFile(ctx context.Context, db bun.IDB, opts CreateFil
}
node := Node{
ID: id,
PublicID: pid,
AccountID: scope.AccountID,
ParentID: opts.ParentID,
Kind: NodeKindFile,
Status: NodeStatusPending,
Name: opts.Name,
ID: id,
PublicID: pid,
DriveID: scope.DriveID,
ParentID: opts.ParentID,
Kind: NodeKindFile,
Status: NodeStatusPending,
Name: opts.Name,
}
if vfs.keyResolver.ShouldPersistKey() {
@@ -492,13 +492,13 @@ func (vfs *VirtualFS) CreateDirectory(ctx context.Context, db bun.IDB, parentID
}
node := &Node{
ID: id,
PublicID: pid,
AccountID: scope.AccountID,
ParentID: parentID,
Kind: NodeKindDirectory,
Status: NodeStatusReady,
Name: name,
ID: id,
PublicID: pid,
DriveID: scope.DriveID,
ParentID: parentID,
Kind: NodeKindDirectory,
Status: NodeStatusReady,
Name: name,
}
_, err = db.NewInsert().Model(node).Exec(ctx)
@@ -739,13 +739,13 @@ func (vfs *VirtualFS) MoveNodesInSameDirectory(ctx context.Context, db bun.IDB,
nodeNames[i] = node.Name
}
var destinationConflicts []*Node
err = db.NewSelect().Model(&destinationConflicts).
Where("account_id = ?", allowedNodes[0].AccountID).
Where("parent_id = ?", newParentID).
Where("deleted_at IS NULL").
Where("name IN (?)", bun.In(nodeNames)).
Scan(ctx)
var destinationConflicts []*Node
err = db.NewSelect().Model(&destinationConflicts).
Where("drive_id = ?", allowedNodes[0].DriveID).
Where("parent_id = ?", newParentID).
Where("deleted_at IS NULL").
Where("name IN (?)", bun.In(nodeNames)).
Scan(ctx)
if err != nil {
return nil, err
}