mirror of
https://github.com/get-drexa/drive.git
synced 2026-02-02 14:51:18 +00:00
refactor: account model overhaul
This commit is contained in:
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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();
|
||||
|
||||
221
apps/backend/internal/drexa/api_integration_test.go
Normal file
221
apps/backend/internal/drexa/api_integration_test.go
Normal 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, ®)
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
29
apps/backend/internal/drive/drive.go
Normal file
29
apps/backend/internal/drive/drive.go
Normal 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()
|
||||
}
|
||||
9
apps/backend/internal/drive/err.go
Normal file
9
apps/backend/internal/drive/err.go
Normal 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")
|
||||
)
|
||||
114
apps/backend/internal/drive/http.go
Normal file
114
apps/backend/internal/drive/http.go
Normal 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()
|
||||
}
|
||||
130
apps/backend/internal/drive/service.go
Normal file
130
apps/backend/internal/drive/service.go
Normal 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})
|
||||
}
|
||||
31
apps/backend/internal/organization/organization.go
Normal file
31
apps/backend/internal/organization/organization.go
Normal 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()
|
||||
}
|
||||
|
||||
44
apps/backend/internal/organization/service.go
Normal file
44
apps/backend/internal/organization/service.go
Normal 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
|
||||
}
|
||||
|
||||
107
apps/backend/internal/registration/http.go
Normal file
107
apps/backend/internal/registration/http.go
Normal 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)
|
||||
}
|
||||
}
|
||||
90
apps/backend/internal/registration/service.go
Normal file
90
apps/backend/internal/registration/service.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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:"-"`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user