Files
b0esche_cloud/go_cloud/internal/database/db.go

1262 lines
36 KiB
Go
Raw Normal View History

2025-12-17 22:57:57 +01:00
package database
import (
"context"
"database/sql"
"database/sql/driver"
"encoding/json"
"log"
2025-12-17 22:57:57 +01:00
"time"
"github.com/google/uuid"
"go.b0esche.cloud/backend/internal/models"
2025-12-17 22:57:57 +01:00
)
type DB struct {
*sql.DB
}
func New(db *sql.DB) *DB {
return &DB{DB: db}
}
// StringArray handles nullable string arrays from PostgreSQL
type StringArray []string
// Scan handles NULL values properly
func (sa *StringArray) Scan(value interface{}) error {
if value == nil {
*sa = StringArray{}
return nil
}
// Handle byte slice from PostgreSQL array
if bytes, ok := value.([]byte); ok {
var arr []string
if err := json.Unmarshal(bytes, &arr); err != nil {
// If JSON parse fails, try as raw string
*sa = StringArray{string(bytes)}
return nil
}
*sa = StringArray(arr)
return nil
}
// Handle string directly
if str, ok := value.(string); ok {
if str == "" {
*sa = StringArray{}
return nil
}
*sa = StringArray{str}
return nil
}
return nil
}
// Value implements the driver.Valuer interface
func (sa StringArray) Value() (driver.Value, error) {
if len(sa) == 0 {
return nil, nil
}
return json.Marshal(sa)
}
2025-12-17 22:57:57 +01:00
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Username string `json:"username"`
DisplayName string `json:"displayName"`
PasswordHash *string `json:"-"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time `json:"lastLoginAt"`
2026-01-08 13:07:07 +01:00
}
type Credential struct {
ID string
UserID uuid.UUID
CredentialPublicKey []byte
CredentialID []byte
SignCount int64
CreatedAt time.Time
LastUsedAt *time.Time
Transports StringArray
2026-01-08 13:07:07 +01:00
}
type AuthChallenge struct {
ID uuid.UUID
UserID uuid.UUID
Challenge []byte
ChallengeType string
CreatedAt time.Time
ExpiresAt time.Time
UsedAt *time.Time
2025-12-17 22:57:57 +01:00
}
type Session struct {
ID uuid.UUID
UserID uuid.UUID
ExpiresAt time.Time
RevokedAt *time.Time
}
type Organization struct {
ID uuid.UUID `json:"id"`
OwnerID uuid.UUID `json:"ownerId"`
Name string `json:"name"`
Slug string `json:"slug"`
InviteLinkToken *string `json:"inviteLinkToken,omitempty"`
CreatedAt time.Time `json:"createdAt"`
2025-12-17 22:57:57 +01:00
}
type Membership struct {
UserID uuid.UUID
OrgID uuid.UUID
Role string
CreatedAt time.Time
}
type Invitation struct {
ID uuid.UUID `json:"id"`
OrgID uuid.UUID `json:"orgId"`
InvitedBy uuid.UUID `json:"invitedBy"`
Username string `json:"username"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
ExpiresAt time.Time `json:"expiresAt"`
AcceptedAt *time.Time `json:"acceptedAt"`
}
type JoinRequest struct {
ID uuid.UUID
OrgID uuid.UUID
UserID uuid.UUID
InviteToken *string
RequestedAt time.Time
Status string
}
2025-12-18 00:02:50 +01:00
type Activity struct {
ID uuid.UUID
UserID uuid.UUID
OrgID uuid.UUID
FileID *string
Action string
Metadata map[string]interface{}
Timestamp time.Time
}
2026-01-09 17:01:41 +01:00
type File struct {
ID uuid.UUID
OrgID *uuid.UUID
UserID *uuid.UUID
Name string
Path string
Type string
Size int64
LastModified time.Time
CreatedAt time.Time
ModifiedBy *uuid.UUID
ModifiedByName string
2026-01-09 17:01:41 +01:00
}
2025-12-17 22:57:57 +01:00
func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
INSERT INTO users (id, email, display_name)
VALUES (gen_random_uuid(), $1, $2)
ON CONFLICT (email) DO UPDATE SET
display_name = EXCLUDED.display_name,
last_login_at = NOW()
RETURNING id, email, display_name, created_at, last_login_at
`, email, name).Scan(&user.ID, &user.Email, &user.DisplayName, &user.CreatedAt, &user.LastLoginAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) CreateSession(ctx context.Context, userID uuid.UUID, expiresAt time.Time) (*Session, error) {
var session Session
err := db.QueryRowContext(ctx, `
INSERT INTO sessions (user_id, expires_at)
VALUES ($1, $2)
RETURNING id, user_id, expires_at, revoked_at
`, userID, expiresAt).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt)
if err != nil {
return nil, err
}
return &session, nil
}
func (db *DB) GetSession(ctx context.Context, sessionID uuid.UUID) (*Session, error) {
var session Session
err := db.QueryRowContext(ctx, `
SELECT id, user_id, expires_at, revoked_at
FROM sessions
WHERE id = $1
`, sessionID).Scan(&session.ID, &session.UserID, &session.ExpiresAt, &session.RevokedAt)
if err != nil {
return nil, err
}
return &session, nil
}
func (db *DB) RevokeSession(ctx context.Context, sessionID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE sessions
SET revoked_at = NOW()
WHERE id = $1 AND revoked_at IS NULL
`, sessionID)
return err
}
2025-12-17 22:57:57 +01:00
func (db *DB) GetUserOrganizations(ctx context.Context, userID uuid.UUID) ([]Organization, error) {
rows, err := db.QueryContext(ctx, `
SELECT o.id, o.owner_id, o.name, o.slug, o.created_at
2025-12-17 22:57:57 +01:00
FROM organizations o
JOIN memberships m ON o.id = m.org_id
WHERE m.user_id = $1
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var orgs []Organization
for rows.Next() {
var org Organization
if err := rows.Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.CreatedAt); err != nil {
2025-12-17 22:57:57 +01:00
return nil, err
}
orgs = append(orgs, org)
}
return orgs, rows.Err()
}
func (db *DB) GetUserMembership(ctx context.Context, userID, orgID uuid.UUID) (*Membership, error) {
var membership Membership
err := db.QueryRowContext(ctx, `
SELECT user_id, org_id, role, created_at
FROM memberships
WHERE user_id = $1 AND org_id = $2
`, userID, orgID).Scan(&membership.UserID, &membership.OrgID, &membership.Role, &membership.CreatedAt)
if err != nil {
return nil, err
}
return &membership, nil
}
2025-12-18 00:02:50 +01:00
// GetOrgMember is an alias for GetUserMembership - checks if user is a member of an org
func (db *DB) GetOrgMember(ctx context.Context, orgID, userID uuid.UUID) (*Membership, error) {
return db.GetUserMembership(ctx, userID, orgID)
}
func (db *DB) CreateOrg(ctx context.Context, ownerID uuid.UUID, name, slug string) (*Organization, error) {
// Generate a unique invite link token
inviteToken := uuid.New().String()
2025-12-18 00:02:50 +01:00
var org Organization
err := db.QueryRowContext(ctx, `
INSERT INTO organizations (owner_id, name, slug, invite_link_token)
VALUES ($1, $2, $3, $4)
RETURNING id, owner_id, name, slug, invite_link_token, created_at
`, ownerID, name, slug, inviteToken).Scan(&org.ID, &org.OwnerID, &org.Name, &org.Slug, &org.InviteLinkToken, &org.CreatedAt)
2025-12-18 00:02:50 +01:00
if err != nil {
return nil, err
}
return &org, nil
}
func (db *DB) AddMembership(ctx context.Context, userID, orgID uuid.UUID, role string) error {
_, err := db.ExecContext(ctx, `
INSERT INTO memberships (user_id, org_id, role)
VALUES ($1, $2, $3)
`, userID, orgID, role)
return err
}
func (db *DB) LogActivity(ctx context.Context, userID, orgID uuid.UUID, fileID *string, action string, metadata map[string]interface{}) error {
_, err := db.ExecContext(ctx, `
INSERT INTO activities (user_id, org_id, file_id, action, metadata)
VALUES ($1, $2, $3, $4, $5)
`, userID, orgID, fileID, action, metadata)
return err
}
func (db *DB) GetOrgActivities(ctx context.Context, orgID uuid.UUID, limit int) ([]Activity, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, org_id, file_id, action, metadata, timestamp
FROM activities
WHERE org_id = $1
ORDER BY timestamp DESC
LIMIT $2
`, orgID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var activities []Activity
for rows.Next() {
var a Activity
err := rows.Scan(&a.ID, &a.UserID, &a.OrgID, &a.FileID, &a.Action, &a.Metadata, &a.Timestamp)
if err != nil {
return nil, err
}
activities = append(activities, a)
}
return activities, rows.Err()
}
func (db *DB) GetOrgMembers(ctx context.Context, orgID uuid.UUID) ([]Membership, error) {
rows, err := db.QueryContext(ctx, `
SELECT user_id, org_id, role, created_at
FROM memberships
WHERE org_id = $1
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
var memberships []Membership
for rows.Next() {
var m Membership
err := rows.Scan(&m.UserID, &m.OrgID, &m.Role, &m.CreatedAt)
if err != nil {
return nil, err
}
memberships = append(memberships, m)
}
return memberships, rows.Err()
}
// GetOrgMembersWithUsers returns members with user details
func (db *DB) GetOrgMembersWithUsers(ctx context.Context, orgID uuid.UUID) ([]struct {
Membership
User
}, error) {
rows, err := db.QueryContext(ctx, `
SELECT m.user_id, m.org_id, m.role, m.created_at,
u.id, u.email, u.username, u.display_name, u.created_at, u.last_login_at
FROM memberships m
JOIN users u ON m.user_id = u.id
WHERE m.org_id = $1
ORDER BY m.created_at
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
members := make([]struct {
Membership
User
}, 0)
for rows.Next() {
var m struct {
Membership
User
}
err := rows.Scan(
&m.Membership.UserID, &m.Membership.OrgID, &m.Membership.Role, &m.Membership.CreatedAt,
&m.User.ID, &m.User.Email, &m.User.Username, &m.User.DisplayName, &m.User.CreatedAt, &m.User.LastLoginAt,
)
if err != nil {
return nil, err
}
members = append(members, m)
}
return members, rows.Err()
}
// UpdateMemberRole updates a member's role
func (db *DB) UpdateMemberRole(ctx context.Context, orgID, userID uuid.UUID, newRole string) error {
_, err := db.ExecContext(ctx, `
UPDATE memberships
SET role = $1
WHERE org_id = $2 AND user_id = $3
`, newRole, orgID, userID)
return err
}
// RemoveMember removes a user from an organization
func (db *DB) RemoveMember(ctx context.Context, orgID, userID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
DELETE FROM memberships
WHERE org_id = $1 AND user_id = $2
`, orgID, userID)
return err
}
// SearchUsersByUsername searches users by partial username match
func (db *DB) SearchUsersByUsername(ctx context.Context, query string, limit int) ([]User, error) {
if limit <= 0 {
limit = 10
}
rows, err := db.QueryContext(ctx, `
SELECT id, email, username, display_name, created_at, last_login_at
FROM users
WHERE username ILIKE $1
ORDER BY username
LIMIT $2
`, "%"+query+"%", limit)
if err != nil {
return nil, err
}
defer rows.Close()
users := make([]User, 0)
for rows.Next() {
var u User
err := rows.Scan(&u.ID, &u.Email, &u.Username, &u.DisplayName, &u.CreatedAt, &u.LastLoginAt)
if err != nil {
return nil, err
}
users = append(users, u)
}
return users, rows.Err()
}
// CreateInvitation creates a new invitation
func (db *DB) CreateInvitation(ctx context.Context, orgID, invitedBy uuid.UUID, username, role string) (*Invitation, error) {
var inv Invitation
err := db.QueryRowContext(ctx, `
INSERT INTO invitations (org_id, invited_by, username, role)
VALUES ($1, $2, $3, $4)
RETURNING id, org_id, invited_by, username, role, created_at, expires_at, accepted_at
`, orgID, invitedBy, username, role).Scan(
&inv.ID, &inv.OrgID, &inv.InvitedBy, &inv.Username, &inv.Role,
&inv.CreatedAt, &inv.ExpiresAt, &inv.AcceptedAt,
)
if err != nil {
return nil, err
}
return &inv, nil
}
// GetOrgInvitations returns pending invitations for an org
func (db *DB) GetOrgInvitations(ctx context.Context, orgID uuid.UUID) ([]Invitation, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, org_id, invited_by, username, role, created_at, expires_at, accepted_at
FROM invitations
WHERE org_id = $1 AND accepted_at IS NULL AND expires_at > NOW()
ORDER BY created_at DESC
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
invitations := make([]Invitation, 0)
for rows.Next() {
var inv Invitation
err := rows.Scan(
&inv.ID, &inv.OrgID, &inv.InvitedBy, &inv.Username, &inv.Role,
&inv.CreatedAt, &inv.ExpiresAt, &inv.AcceptedAt,
)
if err != nil {
return nil, err
}
invitations = append(invitations, inv)
}
return invitations, rows.Err()
}
// CancelInvitation cancels an invitation
func (db *DB) CancelInvitation(ctx context.Context, invitationID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
DELETE FROM invitations
WHERE id = $1
`, invitationID)
return err
}
// CreateJoinRequest creates a join request
func (db *DB) CreateJoinRequest(ctx context.Context, orgID, userID uuid.UUID, inviteToken *string) (*JoinRequest, error) {
var req JoinRequest
err := db.QueryRowContext(ctx, `
INSERT INTO join_requests (org_id, user_id, invite_token)
VALUES ($1, $2, $3)
ON CONFLICT (org_id, user_id) DO UPDATE SET
invite_token = EXCLUDED.invite_token,
requested_at = NOW(),
status = 'pending'
RETURNING id, org_id, user_id, invite_token, requested_at, status
`, orgID, userID, inviteToken).Scan(
&req.ID, &req.OrgID, &req.UserID, &req.InviteToken, &req.RequestedAt, &req.Status,
)
if err != nil {
return nil, err
}
return &req, nil
}
// GetOrgJoinRequests returns pending join requests for an org
func (db *DB) GetOrgJoinRequests(ctx context.Context, orgID uuid.UUID) ([]struct {
JoinRequest
User
}, error) {
rows, err := db.QueryContext(ctx, `
SELECT jr.id, jr.org_id, jr.user_id, jr.invite_token, jr.requested_at, jr.status,
u.id, u.email, u.username, u.display_name, u.created_at, u.last_login_at
FROM join_requests jr
JOIN users u ON jr.user_id = u.id
WHERE jr.org_id = $1 AND jr.status = 'pending'
ORDER BY jr.requested_at DESC
`, orgID)
if err != nil {
return nil, err
}
defer rows.Close()
requests := make([]struct {
JoinRequest
User
}, 0)
for rows.Next() {
var r struct {
JoinRequest
User
}
err := rows.Scan(
&r.JoinRequest.ID, &r.JoinRequest.OrgID, &r.JoinRequest.UserID, &r.JoinRequest.InviteToken, &r.JoinRequest.RequestedAt, &r.JoinRequest.Status,
&r.User.ID, &r.User.Email, &r.User.Username, &r.User.DisplayName, &r.User.CreatedAt, &r.User.LastLoginAt,
)
if err != nil {
return nil, err
}
requests = append(requests, r)
}
return requests, rows.Err()
}
// AcceptJoinRequest accepts a join request and adds the user as member
func (db *DB) AcceptJoinRequest(ctx context.Context, requestID uuid.UUID, role string) error {
// Get the request details
var orgID, userID uuid.UUID
err := db.QueryRowContext(ctx, `
SELECT org_id, user_id
FROM join_requests
WHERE id = $1 AND status = 'pending'
`, requestID).Scan(&orgID, &userID)
if err != nil {
return err
}
// Add membership
err = db.AddMembership(ctx, userID, orgID, role)
if err != nil {
return err
}
// Mark request as accepted
_, err = db.ExecContext(ctx, `
UPDATE join_requests
SET status = 'accepted'
WHERE id = $1
`, requestID)
return err
}
// RejectJoinRequest rejects a join request
func (db *DB) RejectJoinRequest(ctx context.Context, requestID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE join_requests
SET status = 'rejected'
WHERE id = $1
`, requestID)
return err
}
// GetInviteLink returns the invite link token for an org
func (db *DB) GetInviteLink(ctx context.Context, orgID uuid.UUID) (*string, error) {
var token *string
err := db.QueryRowContext(ctx, `
SELECT invite_link_token
FROM organizations
WHERE id = $1
`, orgID).Scan(&token)
if err != nil {
return nil, err
}
return token, nil
}
// RegenerateInviteLink generates a new invite link token
func (db *DB) RegenerateInviteLink(ctx context.Context, orgID uuid.UUID) (*string, error) {
newToken := uuid.New().String()
_, err := db.ExecContext(ctx, `
UPDATE organizations
SET invite_link_token = $1
WHERE id = $2
`, newToken, orgID)
if err != nil {
return nil, err
}
return &newToken, nil
}
2026-01-09 17:01:41 +01:00
// GetOrgFiles returns files for a given organization (top-level folder listing)
func (db *DB) GetOrgFiles(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
2026-01-09 17:01:41 +01:00
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 100
}
offset := (page - 1) * pageSize
orgIDStr := orgID.String()
userIDStr := userID.String()
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=%s, userId=%s, fileCount=0, path=%s", orgIDStr, userIDStr, path)
// Basic search and pagination. Returns only direct children of the given path.
// For root ("/"), we want files where path doesn't contain "/" after the first character.
// For subdirs, we want files where path starts with parent but has no additional "/" after parent.
2026-01-09 17:01:41 +01:00
rows, err := db.QueryContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
FROM files f
WHERE f.org_id = $1
AND EXISTS (
SELECT 1
FROM memberships m
WHERE m.org_id = $1 AND m.user_id = $2
)
AND f.path != $3
AND (
($3 = '/' AND f.path LIKE '/%' AND f.path NOT LIKE '/%/%')
OR ($3 != '/' AND f.path LIKE $3 || '/%' AND f.path NOT LIKE $3 || '/%/%')
)
AND ($4 = '' OR f.name ILIKE '%' || $4 || '%')
ORDER BY CASE WHEN f.type = 'folder' THEN 0 ELSE 1 END, f.name
LIMIT $5 OFFSET $6
`, orgID, userID, path, q, pageSize, offset)
2026-01-09 17:01:41 +01:00
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
}
return files, err
2026-01-09 17:01:41 +01:00
}
// GetAllOrgFilesUnderPath returns all files recursively under the given path for an org
func (db *DB) GetAllOrgFilesUnderPath(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string) ([]File, error) {
orgIDStr := orgID.String()
userIDStr := userID.String()
log.Printf("[DATA-ISOLATION] stage=before, action=list_recursive, orgId=%s, userId=%s, path=%s", orgIDStr, userIDStr, path)
rows, err := db.QueryContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at
FROM files f
WHERE f.org_id = $1
AND EXISTS (
SELECT 1
FROM memberships m
WHERE m.org_id = $1 AND m.user_id = $2
)
AND f.path LIKE $3 || '%'
AND f.path != $3
ORDER BY f.path
`, orgID, userID, path)
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list_recursive, orgId=%s, userId=%s, fileCount=%d, path=%s", orgIDStr, userIDStr, len(files), path)
}
return files, err
}
2026-01-09 17:01:41 +01:00
// GetUserFiles returns files for a user's personal workspace at a given path
func (db *DB) GetUserFiles(ctx context.Context, userID uuid.UUID, path string, q string, page, pageSize int) ([]File, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 100
}
offset := (page - 1) * pageSize
// Return only direct children of the given path
log.Printf("[DATA-ISOLATION] stage=before, action=list, orgId=, userId=%s, fileCount=0, path=%s", userID.String(), path)
2026-01-09 17:01:41 +01:00
rows, err := db.QueryContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
FROM files
WHERE user_id = $1
AND org_id IS NULL
AND path != $2
AND (
($2 = '/' AND path LIKE '/%' AND path NOT LIKE '/%/%')
OR ($2 != '/' AND path LIKE $2 || '/%' AND path NOT LIKE $2 || '/%/%')
)
AND ($3 = '' OR name ILIKE '%' || $3 || '%')
ORDER BY CASE WHEN type = 'folder' THEN 0 ELSE 1 END, name
2026-01-09 20:31:05 +01:00
LIMIT $4 OFFSET $5
`, userID, path, q, pageSize, offset)
2026-01-09 17:01:41 +01:00
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
}
return files, err
2026-01-09 17:01:41 +01:00
}
// GetAllUserFilesUnderPath returns all files recursively under the given path for a user
func (db *DB) GetAllUserFilesUnderPath(ctx context.Context, userID uuid.UUID, path string) ([]File, error) {
// Return all descendants of the given path
log.Printf("[DATA-ISOLATION] stage=before, action=list_recursive, orgId=, userId=%s, path=%s", userID.String(), path)
rows, err := db.QueryContext(ctx, `
SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
FROM files
WHERE user_id = $1
AND org_id IS NULL
AND path LIKE $2 || '%'
AND path != $2
ORDER BY path
`, userID, path)
if err != nil {
return nil, err
}
defer rows.Close()
var files []File
for rows.Next() {
var f File
var orgNull sql.NullString
var userNull sql.NullString
if err := rows.Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt); err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
files = append(files, f)
}
err = rows.Err()
if err == nil {
log.Printf("[DATA-ISOLATION] stage=after, action=list_recursive, orgId=, userId=%s, fileCount=%d, path=%s", userID.String(), len(files), path)
}
return files, err
}
2026-01-09 17:01:41 +01:00
// CreateFile inserts a file or folder record. orgID or userID may be nil.
func (db *DB) CreateFile(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, name, path, fileType string, size int64) (*File, error) {
var f File
var orgIDVal interface{}
var userIDVal interface{}
orgIDStr := ""
userIDStr := ""
2026-01-09 17:01:41 +01:00
if orgID != nil {
orgIDVal = *orgID
orgIDStr = orgID.String()
2026-01-09 17:01:41 +01:00
} else {
orgIDVal = nil
}
if userID != nil {
userIDVal = *userID
userIDStr = userID.String()
2026-01-09 17:01:41 +01:00
} else {
userIDVal = nil
}
log.Printf("[DATA-ISOLATION] stage=before, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, path)
2026-01-09 17:01:41 +01:00
err := db.QueryRowContext(ctx, `
INSERT INTO files (org_id, user_id, name, path, type, size)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at
`, orgIDVal, userIDVal, name, path, fileType, size).Scan(&f.ID, new(sql.NullString), new(sql.NullString), &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt)
if err != nil {
return nil, err
}
log.Printf("[DATA-ISOLATION] stage=after, action=create, orgId=%s, userId=%s, fileCount=1, path=%s", orgIDStr, userIDStr, f.Path)
2026-01-09 17:01:41 +01:00
return &f, nil
}
// GetFileByID retrieves a file by its ID
func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error) {
var f File
var orgNull sql.NullString
var userNull sql.NullString
var modifiedByNull sql.NullString
var modifiedByNameNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
f.modified_by::text, u.display_name
FROM files f
LEFT JOIN users u ON f.modified_by = u.id
WHERE f.id = $1
`, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
&modifiedByNull, &modifiedByNameNull)
if err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
if modifiedByNull.Valid {
mid, _ := uuid.Parse(modifiedByNull.String)
f.ModifiedBy = &mid
}
if modifiedByNameNull.Valid {
f.ModifiedByName = modifiedByNameNull.String
}
return &f, nil
}
// GetOrgFileByPath returns a file by path for an org
func (db *DB) GetOrgFileByPath(ctx context.Context, orgID uuid.UUID, userID uuid.UUID, path string) (*File, error) {
var f File
var orgNull sql.NullString
var userNull sql.NullString
var modifiedByNull sql.NullString
var modifiedByNameNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
f.modified_by::text, u.display_name
FROM files f
LEFT JOIN users u ON f.modified_by = u.id
WHERE f.org_id = $1
AND EXISTS (
SELECT 1
FROM memberships m
WHERE m.org_id = $1 AND m.user_id = $2
)
AND f.path = $3
`, orgID, userID, path).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
&modifiedByNull, &modifiedByNameNull)
if err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
if modifiedByNull.Valid {
mid, _ := uuid.Parse(modifiedByNull.String)
f.ModifiedBy = &mid
}
if modifiedByNameNull.Valid {
f.ModifiedByName = modifiedByNameNull.String
}
return &f, nil
}
// GetUserFileByPath returns a file by path for a user
func (db *DB) GetUserFileByPath(ctx context.Context, userID uuid.UUID, path string) (*File, error) {
var f File
var orgNull sql.NullString
var userNull sql.NullString
var modifiedByNull sql.NullString
var modifiedByNameNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at,
f.modified_by::text, u.display_name
FROM files f
LEFT JOIN users u ON f.modified_by = u.id
WHERE f.user_id = $1
AND f.org_id IS NULL
AND f.path = $2
`, userID, path).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt,
&modifiedByNull, &modifiedByNameNull)
if err != nil {
return nil, err
}
if orgNull.Valid {
oid, _ := uuid.Parse(orgNull.String)
f.OrgID = &oid
}
if userNull.Valid {
uid, _ := uuid.Parse(userNull.String)
f.UserID = &uid
}
if modifiedByNull.Valid {
mid, _ := uuid.Parse(modifiedByNull.String)
f.ModifiedBy = &mid
}
if modifiedByNameNull.Valid {
f.ModifiedByName = modifiedByNameNull.String
}
return &f, nil
}
// UpdateFileSize updates the size, modification time, and modifier of a file
func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64, modifiedBy *uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE files
SET size = $1, last_modified = NOW(), modified_by = $3
WHERE id = $2
`, size, fileID, modifiedBy)
return err
}
2026-01-09 17:01:41 +01:00
// DeleteFileByPath removes a file or folder matching path for a given org or user
func (db *DB) DeleteFileByPath(ctx context.Context, orgID *uuid.UUID, userID *uuid.UUID, path string) error {
var res sql.Result
var err error
if orgID != nil {
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE org_id = $1 AND path = $2`, *orgID, path)
} else if userID != nil {
res, err = db.ExecContext(ctx, `DELETE FROM files WHERE user_id = $1 AND path = $2`, *userID, path)
} else {
return nil
}
if err != nil {
return err
}
_, _ = res.RowsAffected()
return nil
}
2026-01-08 13:07:07 +01:00
// Passkey-related methods
func (db *DB) CreateUser(ctx context.Context, username, email, displayName string, passwordHash *string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
INSERT INTO users (id, username, email, display_name, password_hash)
VALUES (gen_random_uuid(), $1, $2, $3, $4)
RETURNING id, username, email, display_name, password_hash, created_at, last_login_at
`, username, email, displayName, passwordHash).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) GetUserByUsername(ctx context.Context, username string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
SELECT id, username, email, display_name, password_hash, created_at, last_login_at
FROM users
WHERE username = $1
`, username).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) GetUserByEmail(ctx context.Context, email string) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
SELECT id, username, email, display_name, password_hash, created_at, last_login_at
FROM users
WHERE email = $1
`, email).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) GetUserByID(ctx context.Context, userID uuid.UUID) (*User, error) {
var user User
err := db.QueryRowContext(ctx, `
SELECT id, username, email, display_name, password_hash, created_at, last_login_at
FROM users
WHERE id = $1
`, userID).Scan(&user.ID, &user.Username, &user.Email, &user.DisplayName, &user.PasswordHash, &user.CreatedAt, &user.LastLoginAt)
if err != nil {
return nil, err
}
return &user, nil
}
func (db *DB) UpdateUserLastLogin(ctx context.Context, userID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE users
SET last_login_at = NOW()
WHERE id = $1
`, userID)
return err
}
func (db *DB) CreateCredential(ctx context.Context, cred *Credential) error {
_, err := db.ExecContext(ctx, `
INSERT INTO credentials (id, user_id, credential_public_key, credential_id, sign_count, transports)
VALUES ($1, $2, $3, $4, $5, $6)
`, cred.ID, cred.UserID, cred.CredentialPublicKey, cred.CredentialID, cred.SignCount, cred.Transports)
return err
}
func (db *DB) GetCredentialByID(ctx context.Context, credentialID []byte) (*Credential, error) {
var cred Credential
err := db.QueryRowContext(ctx, `
SELECT id, user_id, credential_public_key, credential_id, sign_count, created_at, last_used_at, transports
FROM credentials
WHERE credential_id = $1
`, credentialID).Scan(&cred.ID, &cred.UserID, &cred.CredentialPublicKey, &cred.CredentialID, &cred.SignCount, &cred.CreatedAt, &cred.LastUsedAt, &cred.Transports)
if err != nil {
return nil, err
}
return &cred, nil
}
func (db *DB) GetUserCredentials(ctx context.Context, userID uuid.UUID) ([]Credential, error) {
rows, err := db.QueryContext(ctx, `
SELECT id, user_id, credential_public_key, credential_id, sign_count, created_at, last_used_at, transports
FROM credentials
WHERE user_id = $1
ORDER BY created_at DESC
`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var credentials []Credential
for rows.Next() {
var cred Credential
err := rows.Scan(&cred.ID, &cred.UserID, &cred.CredentialPublicKey, &cred.CredentialID, &cred.SignCount, &cred.CreatedAt, &cred.LastUsedAt, &cred.Transports)
if err != nil {
return nil, err
}
credentials = append(credentials, cred)
}
return credentials, rows.Err()
}
func (db *DB) UpdateCredentialLastUsed(ctx context.Context, credentialID string) error {
_, err := db.ExecContext(ctx, `
UPDATE credentials
SET last_used_at = NOW()
WHERE id = $1
`, credentialID)
return err
}
func (db *DB) CreateAuthChallenge(ctx context.Context, userID uuid.UUID, challenge []byte, challengeType string) error {
_, err := db.ExecContext(ctx, `
INSERT INTO auth_challenges (user_id, challenge, challenge_type, expires_at)
VALUES ($1, $2, $3, NOW() + INTERVAL '15 minutes')
`, userID, challenge, challengeType)
return err
}
func (db *DB) VerifyAuthChallenge(ctx context.Context, userID uuid.UUID, challenge []byte, challengeType string) error {
var count int
err := db.QueryRowContext(ctx, `
SELECT COUNT(*)
FROM auth_challenges
WHERE user_id = $1 AND challenge = $2 AND challenge_type = $3 AND expires_at > NOW() AND used_at IS NULL
`, userID, challenge, challengeType).Scan(&count)
if err != nil {
return err
}
if count == 0 {
return sql.ErrNoRows
}
return nil
}
func (db *DB) MarkChallengeUsed(ctx context.Context, challenge []byte) error {
_, err := db.ExecContext(ctx, `
UPDATE auth_challenges
SET used_at = NOW()
WHERE challenge = $1 AND used_at IS NULL
`, challenge)
return err
}
// FileShareLink methods
// CreateFileShareLink creates a new share link for a file
func (db *DB) CreateFileShareLink(ctx context.Context, token string, fileID uuid.UUID, orgID *uuid.UUID, createdByUserID uuid.UUID) (*models.FileShareLink, error) {
var link models.FileShareLink
var expiresAtNull sql.NullTime
var orgIDNull sql.NullString
// If caller didn't provide an orgID, try to infer it from the file record
if orgID == nil {
var fileOrgNull sql.NullString
fileErr := db.QueryRowContext(ctx, `SELECT org_id::text FROM files WHERE id = $1`, fileID).Scan(&fileOrgNull)
if fileErr == nil && fileOrgNull.Valid {
if parsed, perr := uuid.Parse(fileOrgNull.String); perr == nil {
orgID = &parsed
}
}
// If the file lookup failed or org_id is not set, orgID remains nil
}
err := db.QueryRowContext(ctx, `
INSERT INTO file_share_links (token, file_id, org_id, created_by_user_id)
VALUES ($1, $2, $3, $4)
RETURNING id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
`, token, fileID, orgID, createdByUserID).Scan(
&link.ID, &link.Token, &link.FileID, &orgIDNull, &link.CreatedByUserID,
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
if err != nil {
return nil, err
}
if orgIDNull.Valid {
parsed, err := uuid.Parse(orgIDNull.String)
if err != nil {
return nil, err
}
link.OrgID = &parsed
}
if expiresAtNull.Valid {
link.ExpiresAt = &expiresAtNull.Time
}
return &link, nil
}
// GetFileShareLinkByFileID gets the active share link for a file
func (db *DB) GetFileShareLinkByFileID(ctx context.Context, fileID uuid.UUID) (*models.FileShareLink, error) {
var link models.FileShareLink
var expiresAtNull sql.NullTime
var orgIDNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
FROM file_share_links
WHERE file_id = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW())
ORDER BY created_at DESC
LIMIT 1
`, fileID).Scan(
&link.ID, &link.Token, &link.FileID, &orgIDNull, &link.CreatedByUserID,
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
if err != nil {
return nil, err
}
if orgIDNull.Valid {
parsed, err := uuid.Parse(orgIDNull.String)
if err != nil {
return nil, err
}
link.OrgID = &parsed
}
if expiresAtNull.Valid {
link.ExpiresAt = &expiresAtNull.Time
}
return &link, nil
}
// GetFileShareLinkByToken gets a share link by token
func (db *DB) GetFileShareLinkByToken(ctx context.Context, token string) (*models.FileShareLink, error) {
var link models.FileShareLink
var expiresAtNull sql.NullTime
var orgIDNull sql.NullString
err := db.QueryRowContext(ctx, `
SELECT id, token, file_id, org_id, created_by_user_id, created_at, updated_at, expires_at, is_revoked
FROM file_share_links
WHERE token = $1 AND is_revoked = FALSE AND (expires_at IS NULL OR expires_at > NOW())
`, token).Scan(
&link.ID, &link.Token, &link.FileID, &orgIDNull, &link.CreatedByUserID,
&link.CreatedAt, &link.UpdatedAt, &expiresAtNull, &link.IsRevoked)
if err != nil {
return nil, err
}
if orgIDNull.Valid {
parsed, err := uuid.Parse(orgIDNull.String)
if err != nil {
return nil, err
}
link.OrgID = &parsed
}
if expiresAtNull.Valid {
link.ExpiresAt = &expiresAtNull.Time
}
return &link, nil
}
// RevokeFileShareLink revokes a share link
func (db *DB) RevokeFileShareLink(ctx context.Context, fileID uuid.UUID) error {
_, err := db.ExecContext(ctx, `
UPDATE file_share_links
SET is_revoked = TRUE, updated_at = NOW()
WHERE file_id = $1 AND is_revoked = FALSE
`, fileID)
return err
}