This commit is contained in:
2026-03-16 19:49:33 -04:00
parent 93707ff513
commit fd9afe86b0
22 changed files with 1188 additions and 224 deletions

View File

@@ -1,84 +1,33 @@
# Agent Context Hints # AnthoLume - Agent Context
## Current Status ## Migration Context
Currently mid migration from go templates (`./templates`) to React App (`./frontend`) Updating Go templates (rendered HTML) → React app using V1 API (OpenAPI spec)
## Architecture Context ## Critical Rules
- **Backend**: Go with Gin router (legacy), SQLC for database queries, currently migrating to V1 API (oapi-codegen)
- **Frontend**: React with Vite, currently migrating from Go templates (using the V1 API)
- **API**: OpenAPI 3.0 spec, generates Go server (oapi-codegen) and TS client (orval)
## Frontend Linting ### Database Access
The frontend uses ESLint and Prettier for code quality and formatting. - **NEVER write ad-hoc SQL** - Only use SQLC queries from `database/query.sql`
- Migrate V1 API by mirroring legacy implementation in `api/app-admin-routes.go` and `api/app-routes.go`
### Running Linting ### Migration Workflow
- **Check linting**: `cd frontend && bun run lint` 1. Check legacy implementation for business logic
- **Fix linting issues**: `cd frontend && bun run lint:fix` 2. Copy pattern but adapt to use `s.db.Queries.*` instead of `api.db.Queries.*`
- **Check formatting**: `cd frontend && bun run format` 3. Map legacy response types to V1 API response types
- **Format files**: `cd frontend && bun run format:fix` 4. Never create new DB queries
### When to Run Linting ### Surprises
Run linting after making any changes to the frontend to ensure code quality and consistency. All new code should pass linting before committing. - Templates may show fields the API doesn't return - cross-check with DB query
- `start_time` is `interface{}` in Go models, needs type assertion in Go
- Templates use `LOCAL_TIME()` SQL function for timezone-aware display
### Package Management ## Error Handling
The frontend uses **bun** for package management. Use: Use `fmt.Errorf("message: %w", err)` for wrapping. Do NOT use `github.com/pkg/errors`.
- `bun install` to install dependencies
- `bun add <package>` to add a new package
- `bun remove <package>` to remove a package
- `bunx <command>` to run CLI binaries locally
## Data Flow (CRITICAL for migrations) ## Frontend
1. Database schema → SQL queries (`database/query.sql`, `database/query.sql.go`) - **Package manager**: bun (not npm)
2. SQLC models → API handlers (`api/v1/*.go`) - **Lint**: `cd frontend && bun run lint` (and `lint:fix`)
3. Go templates show **intended UI** structure (`templates/pages/*.tmpl`) - **Format**: `cd frontend && bun run format` (and `format:fix`)
4. API spec defines actual API contract (`api/v1/openapi.yaml`)
5. Generated TS client → React components
## When Migrating from Go Templates ## Regeneration
- Check template AND database query results (Go templates may show fields API doesn't return)
- Template columns often map to: document_id, title, author, start_time, duration, start/end_percentage
- Go template rendering: `{{ template "component/table" }}` with "Columns" and "Keys"
## API Regeneration Commands
- Go backend: `go generate ./api/v1/generate.go` - Go backend: `go generate ./api/v1/generate.go`
- TS client: `cd frontend && npm run generate:api` - TS client: `cd frontend && npm run generate:api`
## Frontend Key Files and Directories
- **Source code**: `frontend/src/`
- **Configuration**: `frontend/eslint.config.js`, `frontend/.prettierrc`, `frontend/tsconfig.json`
- **Build output**: `frontend/dist/`
- **Generated API client**: `frontend/src/generated/`
## Key Files
- Database queries: `database/query.sql` → SQLc Query shows actual fields returned
- SQLC models: `database/query.sql.go` → SQLc Generated Go struct definitions
- Go templates: `templates/pages/*.tmpl` → Legacy UI reference
- API spec: `api/v1/openapi.yaml` → contract definition
- Generated TS types: `frontend/src/generated/model/*.ts`
## Common Gotchas
- API implementation may not map all fields from DB query (check `api/v1/activity.go` mapping)
- `start_time` is `interface{}` in Go models, needs type assertion
- Go templates use `LOCAL_TIME()` SQL function for timezone-aware display
## CRITICAL: Migration Implementation Rules
- **NEVER write ad-hoc SQL queries** - All database access must use existing SQLC queries from `database/query.sql`
- **Mirror legacy implementation** - Check `api/app-admin-routes.go`, `api/app-routes.go` for existing business logic
- **Reuse existing functions** - Look for helper functions in `api/utils.go` that handle file operations, metadata, etc.
- **SQLC query reference** - Check `database/query.sql` for available queries and `database/query.sql.go` for function signatures
- **When implementing TODOs in v1 API**:
1. Find the corresponding function in legacy API (e.g., `api/app-admin-routes.go`)
2. Copy the logic pattern but adapt to use `s.db.Queries.*` instead of `api.db.Queries.*`
3. Use existing helper functions from `api/utils.go` (make them accessible if needed)
4. Map legacy response types to new v1 API response types
5. Never create new database queries - use what SQLC already provides
## API Structure
- **Legacy API**: `api/` directory (e.g., `api/app-admin-routes.go`, `api/app-routes.go`)
- Uses Gin router
- Renders Go templates
- Contains all existing business logic to mirror
- **V1 API**: `api/v1/` directory (e.g., `api/v1/admin.go`, `api/v1/documents.go`)
- Uses oapi-codegen (OpenAPI spec driven)
- Returns JSON responses
- Currently being migrated from legacy patterns

View File

@@ -16,7 +16,6 @@ import (
"github.com/gin-contrib/sessions/cookie" "github.com/gin-contrib/sessions/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/microcosm-cc/bluemonday" "github.com/microcosm-cc/bluemonday"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"reichard.io/antholume/config" "reichard.io/antholume/config"
"reichard.io/antholume/database" "reichard.io/antholume/database"
@@ -298,7 +297,7 @@ func (api *API) loadTemplates(
templateDirectory := fmt.Sprintf("templates/%ss", basePath) templateDirectory := fmt.Sprintf("templates/%ss", basePath)
allFiles, err := fs.ReadDir(api.assets, templateDirectory) allFiles, err := fs.ReadDir(api.assets, templateDirectory)
if err != nil { if err != nil {
return errors.Wrap(err, fmt.Sprintf("unable to read template dir: %s", templateDirectory)) return fmt.Errorf("unable to read template dir %s: %w", templateDirectory, err)
} }
// Generate Templates // Generate Templates
@@ -310,7 +309,7 @@ func (api *API) loadTemplates(
// Read Template // Read Template
b, err := fs.ReadFile(api.assets, templatePath) b, err := fs.ReadFile(api.assets, templatePath)
if err != nil { if err != nil {
return errors.Wrap(err, fmt.Sprintf("unable to read template: %s", templateName)) return fmt.Errorf("unable to read template %s: %w", templateName, err)
} }
// Clone? (Pages - Don't Stomp) // Clone? (Pages - Don't Stomp)
@@ -321,7 +320,7 @@ func (api *API) loadTemplates(
// Parse Template // Parse Template
baseTemplate, err = baseTemplate.New(templateName).Parse(string(b)) baseTemplate, err = baseTemplate.New(templateName).Parse(string(b))
if err != nil { if err != nil {
return errors.Wrap(err, fmt.Sprintf("unable to parse template: %s", templateName)) return fmt.Errorf("unable to parse template %s: %w", templateName, err)
} }
allTemplates[templateName] = baseTemplate allTemplates[templateName] = baseTemplate

View File

@@ -22,7 +22,6 @@ import (
"github.com/gabriel-vasile/mimetype" "github.com/gabriel-vasile/mimetype"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/itchyny/gojq" "github.com/itchyny/gojq"
"github.com/pkg/errors"
log "github.com/sirupsen/logrus" log "github.com/sirupsen/logrus"
"reichard.io/antholume/database" "reichard.io/antholume/database"
"reichard.io/antholume/metadata" "reichard.io/antholume/metadata"
@@ -722,7 +721,7 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str
// Vacuum DB // Vacuum DB
_, err := api.db.DB.ExecContext(ctx, "VACUUM;") _, err := api.db.DB.ExecContext(ctx, "VACUUM;")
if err != nil { if err != nil {
return errors.Wrap(err, "Unable to vacuum database") return fmt.Errorf("Unable to vacuum database: %w", err)
} }
ar := zip.NewWriter(w) ar := zip.NewWriter(w)
@@ -796,7 +795,7 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str
func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) { func (api *API) isLastAdmin(ctx context.Context, userID string) (bool, error) {
allUsers, err := api.db.Queries.GetUsers(ctx) allUsers, err := api.db.Queries.GetUsers(ctx)
if err != nil { if err != nil {
return false, errors.Wrap(err, fmt.Sprintf("GetUsers DB Error: %v", err)) return false, fmt.Errorf("GetUsers DB Error: %w", err)
} }
hasAdmin := false hasAdmin := false
@@ -873,7 +872,7 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string
} else { } else {
user, err := api.db.Queries.GetUser(ctx, user) user, err := api.db.Queries.GetUser(ctx, user)
if err != nil { if err != nil {
return errors.Wrap(err, fmt.Sprintf("GetUser DB Error: %v", err)) return fmt.Errorf("GetUser DB Error: %w", err)
} }
updateParams.Admin = user.Admin updateParams.Admin = user.Admin
} }
@@ -911,7 +910,7 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string
// Update User // Update User
_, err := api.db.Queries.UpdateUser(ctx, updateParams) _, err := api.db.Queries.UpdateUser(ctx, updateParams)
if err != nil { if err != nil {
return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err)) return fmt.Errorf("UpdateUser DB Error: %w", err)
} }
return nil return nil
@@ -943,7 +942,7 @@ func (api *API) deleteUser(ctx context.Context, user string) error {
// Delete User // Delete User
_, err = api.db.Queries.DeleteUser(ctx, user) _, err = api.db.Queries.DeleteUser(ctx, user)
if err != nil { if err != nil {
return errors.Wrap(err, fmt.Sprintf("DeleteUser DB Error: %v", err)) return fmt.Errorf("DeleteUser DB Error: %w", err)
} }
return nil return nil

View File

@@ -9,7 +9,7 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"net/http" "mime/multipart"
"os" "os"
"path/filepath" "path/filepath"
"sort" "sort"
@@ -77,16 +77,30 @@ func (s *Server) PostAdminAction(ctx context.Context, request PostAdminActionReq
return PostAdminAction400JSONResponse{Code: 400, Message: "Missing request body"}, nil return PostAdminAction400JSONResponse{Code: 400, Message: "Missing request body"}, nil
} }
// Read the multipart form in a streaming way to support large files
reader := request.Body
form, err := reader.ReadForm(32 << 20) // 32MB for non-file fields (files are not stored in memory)
if err != nil {
return PostAdminAction400JSONResponse{Code: 400, Message: "Unable to parse form"}, nil
}
// Extract action from form
actionValues := form.Value["action"]
if len(actionValues) == 0 {
return PostAdminAction400JSONResponse{Code: 400, Message: "Missing action"}, nil
}
action := actionValues[0]
// Handle different admin actions mirroring legacy appPerformAdminAction // Handle different admin actions mirroring legacy appPerformAdminAction
switch request.Body.Action { switch action {
case "METADATA_MATCH": case "METADATA_MATCH":
// This is a TODO in the legacy code as well // This is a TODO in the legacy code as well
go func() { go func() {
// TODO: Implement metadata matching logic // TODO: Implement metadata matching logic
log.Info("Metadata match action triggered (not yet implemented)") log.Info("Metadata match action triggered (not yet implemented)")
}() }()
return PostAdminAction200ApplicationoctetStreamResponse{ return PostAdminAction200JSONResponse{
Body: strings.NewReader("Metadata match started"), Message: "Metadata match started",
}, nil }, nil
case "CACHE_TABLES": case "CACHE_TABLES":
@@ -97,15 +111,15 @@ func (s *Server) PostAdminAction(ctx context.Context, request PostAdminActionReq
log.Error("Unable to cache temp tables: ", err) log.Error("Unable to cache temp tables: ", err)
} }
}() }()
return PostAdminAction200ApplicationoctetStreamResponse{ return PostAdminAction200JSONResponse{
Body: strings.NewReader("Cache tables operation started"), Message: "Cache tables operation started",
}, nil }, nil
case "BACKUP": case "BACKUP":
return s.handleBackupAction(ctx, request) return s.handleBackupAction(ctx, request, form)
case "RESTORE": case "RESTORE":
return s.handleRestoreAction(ctx, request) return s.handleRestoreAction(ctx, request, form)
default: default:
return PostAdminAction400JSONResponse{Code: 400, Message: "Invalid action"}, nil return PostAdminAction400JSONResponse{Code: 400, Message: "Invalid action"}, nil
@@ -113,59 +127,51 @@ func (s *Server) PostAdminAction(ctx context.Context, request PostAdminActionReq
} }
// handleBackupAction handles the backup action, mirroring legacy createBackup logic // handleBackupAction handles the backup action, mirroring legacy createBackup logic
func (s *Server) handleBackupAction(ctx context.Context, request PostAdminActionRequestObject) (PostAdminActionResponseObject, error) { func (s *Server) handleBackupAction(ctx context.Context, request PostAdminActionRequestObject, form *multipart.Form) (PostAdminActionResponseObject, error) {
// Extract backup_types from form
backupTypesValues := form.Value["backup_types"]
// Create a pipe for streaming the backup // Create a pipe for streaming the backup
pr, pw := io.Pipe() pr, pw := io.Pipe()
go func() { go func() {
defer pw.Close() defer pw.Close()
var directories []string var directories []string
if request.Body.BackupTypes != nil { for _, val := range backupTypesValues {
for _, item := range *request.Body.BackupTypes { if val == "COVERS" {
if item == "COVERS" {
directories = append(directories, "covers") directories = append(directories, "covers")
} else if item == "DOCUMENTS" { } else if val == "DOCUMENTS" {
directories = append(directories, "documents") directories = append(directories, "documents")
} }
} }
} log.Info("Starting backup for directories: ", directories)
err := s.createBackup(ctx, pw, directories) err := s.createBackup(ctx, pw, directories)
if err != nil { if err != nil {
log.Error("Backup Error: ", err) log.Error("Backup failed: ", err)
} else {
log.Info("Backup completed successfully")
} }
}() }()
// Set Content-Length to 0 to enable chunked transfer encoding
// This allows streaming with unknown file size
return PostAdminAction200ApplicationoctetStreamResponse{ return PostAdminAction200ApplicationoctetStreamResponse{
Body: pr, Body: pr,
ContentLength: 0,
}, nil }, nil
} }
// handleRestoreAction handles the restore action, mirroring legacy processRestoreFile logic // handleRestoreAction handles the restore action, mirroring legacy processRestoreFile logic
func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActionRequestObject) (PostAdminActionResponseObject, error) { func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActionRequestObject, form *multipart.Form) (PostAdminActionResponseObject, error) {
if request.Body == nil || request.Body.RestoreFile == nil { // Get the uploaded file from form
fileHeaders := form.File["restore_file"]
if len(fileHeaders) == 0 {
return PostAdminAction400JSONResponse{Code: 400, Message: "Missing restore file"}, nil return PostAdminAction400JSONResponse{Code: 400, Message: "Missing restore file"}, nil
} }
// Read multipart form (similar to CreateDocument) file, err := fileHeaders[0].Open()
// Since the Body has the file, we need to extract it differently
// The request.Body.RestoreFile is of type openapi_types.File
// For now, let's access the raw request from context
r := ctx.Value("request").(*http.Request)
if r == nil {
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to get request"}, nil
}
// Parse multipart form from raw request
err := r.ParseMultipartForm(32 << 20) // 32MB max memory
if err != nil { if err != nil {
return PostAdminAction500JSONResponse{Code: 500, Message: "Failed to parse form"}, nil return PostAdminAction400JSONResponse{Code: 400, Message: "Unable to open restore file"}, nil
}
// Get the uploaded file
file, _, err := r.FormFile("restore_file")
if err != nil {
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to get file from form"}, nil
} }
defer file.Close() defer file.Close()
@@ -180,17 +186,20 @@ func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActio
// Save uploaded file to temp // Save uploaded file to temp
if _, err = io.Copy(tempFile, file); err != nil { if _, err = io.Copy(tempFile, file); err != nil {
log.Error("Unable to save uploaded file: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to save file"}, nil return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to save file"}, nil
} }
// Get file info and validate ZIP // Get file info and validate ZIP
fileInfo, err := tempFile.Stat() fileInfo, err := tempFile.Stat()
if err != nil { if err != nil {
log.Error("Unable to read temp file: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to read file"}, nil return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to read file"}, nil
} }
zipReader, err := zip.NewReader(tempFile, fileInfo.Size()) zipReader, err := zip.NewReader(tempFile, fileInfo.Size())
if err != nil { if err != nil {
log.Error("Unable to read zip: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to read zip"}, nil return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to read zip"}, nil
} }
@@ -213,9 +222,11 @@ func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActio
} }
// Create backup before restoring (mirroring legacy logic) // Create backup before restoring (mirroring legacy logic)
log.Info("Creating backup before restore...")
backupFilePath := filepath.Join(s.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405"))) backupFilePath := filepath.Join(s.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405")))
backupFile, err := os.Create(backupFilePath) backupFile, err := os.Create(backupFilePath)
if err != nil { if err != nil {
log.Error("Unable to create backup file: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to create backup file"}, nil return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to create backup file"}, nil
} }
defer backupFile.Close() defer backupFile.Close()
@@ -223,46 +234,55 @@ func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActio
w := bufio.NewWriter(backupFile) w := bufio.NewWriter(backupFile)
err = s.createBackup(ctx, w, []string{"covers", "documents"}) err = s.createBackup(ctx, w, []string{"covers", "documents"})
if err != nil { if err != nil {
log.Error("Unable to save backup file: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to save backup file"}, nil return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to save backup file"}, nil
} }
// Remove data (mirroring legacy removeData) // Remove data (mirroring legacy removeData)
log.Info("Removing data...")
err = s.removeData() err = s.removeData()
if err != nil { if err != nil {
log.Error("Unable to delete data: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to delete data"}, nil return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to delete data"}, nil
} }
// Restore data (mirroring legacy restoreData) // Restore data (mirroring legacy restoreData)
log.Info("Restoring data...")
err = s.restoreData(zipReader) err = s.restoreData(zipReader)
if err != nil { if err != nil {
log.Error("Unable to restore data: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to restore data"}, nil return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to restore data"}, nil
} }
// Reload DB (mirroring legacy Reload) // Reload DB (mirroring legacy Reload)
log.Info("Reloading database...")
if err := s.db.Reload(ctx); err != nil { if err := s.db.Reload(ctx); err != nil {
log.Error("Unable to reload DB: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to reload DB"}, nil return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to reload DB"}, nil
} }
// Rotate auth hashes (mirroring legacy rotateAllAuthHashes) // Rotate auth hashes (mirroring legacy rotateAllAuthHashes)
log.Info("Rotating auth hashes...")
if err := s.rotateAllAuthHashes(ctx); err != nil { if err := s.rotateAllAuthHashes(ctx); err != nil {
log.Error("Unable to rotate hashes: ", err)
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to rotate hashes"}, nil return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to rotate hashes"}, nil
} }
return PostAdminAction200ApplicationoctetStreamResponse{ log.Info("Restore completed successfully")
Body: strings.NewReader("Restore completed successfully"), return PostAdminAction200JSONResponse{
Message: "Restore completed successfully",
}, nil }, nil
} }
// createBackup creates a backup ZIP archive, mirroring legacy createBackup // createBackup creates a backup ZIP archive, mirroring legacy createBackup
func (s *Server) createBackup(ctx context.Context, w io.Writer, directories []string) error { func (s *Server) createBackup(ctx context.Context, w io.Writer, directories []string) error {
// Vacuum DB (mirroring legacy logic) // Vacuum DB
_, err := s.db.DB.ExecContext(ctx, "VACUUM;") _, err := s.db.DB.ExecContext(ctx, "VACUUM;")
if err != nil { if err != nil {
return err return fmt.Errorf("Unable to vacuum database: %w", err)
} }
ar := zip.NewWriter(w) ar := zip.NewWriter(w)
defer ar.Close()
// Helper function to walk and archive files // Helper function to walk and archive files
exportWalker := func(currentPath string, f fs.DirEntry, err error) error { exportWalker := func(currentPath string, f fs.DirEntry, err error) error {
@@ -319,6 +339,8 @@ func (s *Server) createBackup(ctx context.Context, w io.Writer, directories []st
} }
} }
// Close writer to flush all data before returning
ar.Close()
return nil return nil
} }
@@ -345,6 +367,10 @@ func (s *Server) removeData() error {
// restoreData restores data from ZIP archive, mirroring legacy restoreData // restoreData restores data from ZIP archive, mirroring legacy restoreData
func (s *Server) restoreData(zipReader *zip.Reader) error { func (s *Server) restoreData(zipReader *zip.Reader) error {
// Ensure Directories
s.cfg.EnsureDirectories()
// Restore Data
for _, file := range zipReader.File { for _, file := range zipReader.File {
rc, err := file.Open() rc, err := file.Open()
if err != nil { if err != nil {
@@ -355,12 +381,14 @@ func (s *Server) restoreData(zipReader *zip.Reader) error {
destPath := filepath.Join(s.cfg.DataPath, file.Name) destPath := filepath.Join(s.cfg.DataPath, file.Name)
destFile, err := os.Create(destPath) destFile, err := os.Create(destPath)
if err != nil { if err != nil {
log.Errorf("error creating destination file: %v", err)
return err return err
} }
defer destFile.Close() defer destFile.Close()
_, err = io.Copy(destFile, rc) // Copy the contents from the zip file to the destination file.
if err != nil { if _, err := io.Copy(destFile, rc); err != nil {
log.Errorf("Error copying file contents: %v", err)
return err return err
} }
} }

View File

@@ -101,16 +101,16 @@ func (e OperationType) Valid() bool {
} }
} }
// Defines values for PostAdminActionFormdataBodyAction. // Defines values for PostAdminActionMultipartBodyAction.
const ( const (
BACKUP PostAdminActionFormdataBodyAction = "BACKUP" BACKUP PostAdminActionMultipartBodyAction = "BACKUP"
CACHETABLES PostAdminActionFormdataBodyAction = "CACHE_TABLES" CACHETABLES PostAdminActionMultipartBodyAction = "CACHE_TABLES"
METADATAMATCH PostAdminActionFormdataBodyAction = "METADATA_MATCH" METADATAMATCH PostAdminActionMultipartBodyAction = "METADATA_MATCH"
RESTORE PostAdminActionFormdataBodyAction = "RESTORE" RESTORE PostAdminActionMultipartBodyAction = "RESTORE"
) )
// Valid indicates whether the value is a known member of the PostAdminActionFormdataBodyAction enum. // Valid indicates whether the value is a known member of the PostAdminActionMultipartBodyAction enum.
func (e PostAdminActionFormdataBodyAction) Valid() bool { func (e PostAdminActionMultipartBodyAction) Valid() bool {
switch e { switch e {
case BACKUP: case BACKUP:
return true return true
@@ -315,6 +315,11 @@ type LogsResponse struct {
Logs *[]LogEntry `json:"logs,omitempty"` Logs *[]LogEntry `json:"logs,omitempty"`
} }
// MessageResponse defines model for MessageResponse.
type MessageResponse struct {
Message string `json:"message"`
}
// OperationType defines model for OperationType. // OperationType defines model for OperationType.
type OperationType string type OperationType string
@@ -436,15 +441,15 @@ type GetActivityParams struct {
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"` Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
} }
// PostAdminActionFormdataBody defines parameters for PostAdminAction. // PostAdminActionMultipartBody defines parameters for PostAdminAction.
type PostAdminActionFormdataBody struct { type PostAdminActionMultipartBody struct {
Action PostAdminActionFormdataBodyAction `form:"action" json:"action"` Action PostAdminActionMultipartBodyAction `json:"action"`
BackupTypes *[]BackupType `form:"backup_types,omitempty" json:"backup_types,omitempty"` BackupTypes *[]BackupType `json:"backup_types,omitempty"`
RestoreFile *openapi_types.File `form:"restore_file,omitempty" json:"restore_file,omitempty"` RestoreFile *openapi_types.File `json:"restore_file,omitempty"`
} }
// PostAdminActionFormdataBodyAction defines parameters for PostAdminAction. // PostAdminActionMultipartBodyAction defines parameters for PostAdminAction.
type PostAdminActionFormdataBodyAction string type PostAdminActionMultipartBodyAction string
// GetImportDirectoryParams defines parameters for GetImportDirectory. // GetImportDirectoryParams defines parameters for GetImportDirectory.
type GetImportDirectoryParams struct { type GetImportDirectoryParams struct {
@@ -507,8 +512,8 @@ type PostSearchFormdataBody struct {
Title string `form:"title" json:"title"` Title string `form:"title" json:"title"`
} }
// PostAdminActionFormdataRequestBody defines body for PostAdminAction for application/x-www-form-urlencoded ContentType. // PostAdminActionMultipartRequestBody defines body for PostAdminAction for multipart/form-data ContentType.
type PostAdminActionFormdataRequestBody PostAdminActionFormdataBody type PostAdminActionMultipartRequestBody PostAdminActionMultipartBody
// PostImportFormdataRequestBody defines body for PostImport for application/x-www-form-urlencoded ContentType. // PostImportFormdataRequestBody defines body for PostImport for application/x-www-form-urlencoded ContentType.
type PostImportFormdataRequestBody PostImportFormdataBody type PostImportFormdataRequestBody PostImportFormdataBody
@@ -575,6 +580,12 @@ type ServerInterface interface {
// Get a single document // Get a single document
// (GET /documents/{id}) // (GET /documents/{id})
GetDocument(w http.ResponseWriter, r *http.Request, id string) GetDocument(w http.ResponseWriter, r *http.Request, id string)
// Get document cover image
// (GET /documents/{id}/cover)
GetDocumentCover(w http.ResponseWriter, r *http.Request, id string)
// Download document file
// (GET /documents/{id}/file)
GetDocumentFile(w http.ResponseWriter, r *http.Request, id string)
// Get home page data // Get home page data
// (GET /home) // (GET /home)
GetHome(w http.ResponseWriter, r *http.Request) GetHome(w http.ResponseWriter, r *http.Request)
@@ -1021,6 +1032,68 @@ func (siw *ServerInterfaceWrapper) GetDocument(w http.ResponseWriter, r *http.Re
handler.ServeHTTP(w, r) handler.ServeHTTP(w, r)
} }
// GetDocumentCover operation middleware
func (siw *ServerInterfaceWrapper) GetDocumentCover(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "id" -------------
var id string
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetDocumentCover(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetDocumentFile operation middleware
func (siw *ServerInterfaceWrapper) GetDocumentFile(w http.ResponseWriter, r *http.Request) {
var err error
// ------------- Path parameter "id" -------------
var id string
err = runtime.BindStyledParameterWithOptions("simple", "id", r.PathValue("id"), &id, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: ""})
if err != nil {
siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "id", Err: err})
return
}
ctx := r.Context()
ctx = context.WithValue(ctx, BearerAuthScopes, []string{})
r = r.WithContext(ctx)
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
siw.Handler.GetDocumentFile(w, r, id)
}))
for _, middleware := range siw.HandlerMiddlewares {
handler = middleware(handler)
}
handler.ServeHTTP(w, r)
}
// GetHome operation middleware // GetHome operation middleware
func (siw *ServerInterfaceWrapper) GetHome(w http.ResponseWriter, r *http.Request) { func (siw *ServerInterfaceWrapper) GetHome(w http.ResponseWriter, r *http.Request) {
@@ -1431,6 +1504,8 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H
m.HandleFunc("GET "+options.BaseURL+"/documents", wrapper.GetDocuments) m.HandleFunc("GET "+options.BaseURL+"/documents", wrapper.GetDocuments)
m.HandleFunc("POST "+options.BaseURL+"/documents", wrapper.CreateDocument) m.HandleFunc("POST "+options.BaseURL+"/documents", wrapper.CreateDocument)
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}", wrapper.GetDocument) m.HandleFunc("GET "+options.BaseURL+"/documents/{id}", wrapper.GetDocument)
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}/cover", wrapper.GetDocumentCover)
m.HandleFunc("GET "+options.BaseURL+"/documents/{id}/file", wrapper.GetDocumentFile)
m.HandleFunc("GET "+options.BaseURL+"/home", wrapper.GetHome) m.HandleFunc("GET "+options.BaseURL+"/home", wrapper.GetHome)
m.HandleFunc("GET "+options.BaseURL+"/home/graph", wrapper.GetGraphData) m.HandleFunc("GET "+options.BaseURL+"/home/graph", wrapper.GetGraphData)
m.HandleFunc("GET "+options.BaseURL+"/home/statistics", wrapper.GetUserStatistics) m.HandleFunc("GET "+options.BaseURL+"/home/statistics", wrapper.GetUserStatistics)
@@ -1508,13 +1583,22 @@ func (response GetAdmin401JSONResponse) VisitGetAdminResponse(w http.ResponseWri
} }
type PostAdminActionRequestObject struct { type PostAdminActionRequestObject struct {
Body *PostAdminActionFormdataRequestBody Body *multipart.Reader
} }
type PostAdminActionResponseObject interface { type PostAdminActionResponseObject interface {
VisitPostAdminActionResponse(w http.ResponseWriter) error VisitPostAdminActionResponse(w http.ResponseWriter) error
} }
type PostAdminAction200JSONResponse MessageResponse
func (response PostAdminAction200JSONResponse) VisitPostAdminActionResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200)
return json.NewEncoder(w).Encode(response)
}
type PostAdminAction200ApplicationoctetStreamResponse struct { type PostAdminAction200ApplicationoctetStreamResponse struct {
Body io.Reader Body io.Reader
ContentLength int64 ContentLength int64
@@ -2003,6 +2087,133 @@ func (response GetDocument500JSONResponse) VisitGetDocumentResponse(w http.Respo
return json.NewEncoder(w).Encode(response) return json.NewEncoder(w).Encode(response)
} }
type GetDocumentCoverRequestObject struct {
Id string `json:"id"`
}
type GetDocumentCoverResponseObject interface {
VisitGetDocumentCoverResponse(w http.ResponseWriter) error
}
type GetDocumentCover200ImagejpegResponse struct {
Body io.Reader
ContentLength int64
}
func (response GetDocumentCover200ImagejpegResponse) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "image/jpeg")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.ReadCloser); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
type GetDocumentCover200ImagepngResponse struct {
Body io.Reader
ContentLength int64
}
func (response GetDocumentCover200ImagepngResponse) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "image/png")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.ReadCloser); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
type GetDocumentCover401JSONResponse ErrorResponse
func (response GetDocumentCover401JSONResponse) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentCover404JSONResponse ErrorResponse
func (response GetDocumentCover404JSONResponse) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentCover500JSONResponse ErrorResponse
func (response GetDocumentCover500JSONResponse) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentFileRequestObject struct {
Id string `json:"id"`
}
type GetDocumentFileResponseObject interface {
VisitGetDocumentFileResponse(w http.ResponseWriter) error
}
type GetDocumentFile200ApplicationoctetStreamResponse struct {
Body io.Reader
ContentLength int64
}
func (response GetDocumentFile200ApplicationoctetStreamResponse) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/octet-stream")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.ReadCloser); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
type GetDocumentFile401JSONResponse ErrorResponse
func (response GetDocumentFile401JSONResponse) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(401)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentFile404JSONResponse ErrorResponse
func (response GetDocumentFile404JSONResponse) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(404)
return json.NewEncoder(w).Encode(response)
}
type GetDocumentFile500JSONResponse ErrorResponse
func (response GetDocumentFile500JSONResponse) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(500)
return json.NewEncoder(w).Encode(response)
}
type GetHomeRequestObject struct { type GetHomeRequestObject struct {
} }
@@ -2421,6 +2632,12 @@ type StrictServerInterface interface {
// Get a single document // Get a single document
// (GET /documents/{id}) // (GET /documents/{id})
GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error) GetDocument(ctx context.Context, request GetDocumentRequestObject) (GetDocumentResponseObject, error)
// Get document cover image
// (GET /documents/{id}/cover)
GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error)
// Download document file
// (GET /documents/{id}/file)
GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error)
// Get home page data // Get home page data
// (GET /home) // (GET /home)
GetHome(ctx context.Context, request GetHomeRequestObject) (GetHomeResponseObject, error) GetHome(ctx context.Context, request GetHomeRequestObject) (GetHomeResponseObject, error)
@@ -2536,16 +2753,12 @@ func (sh *strictHandler) GetAdmin(w http.ResponseWriter, r *http.Request) {
func (sh *strictHandler) PostAdminAction(w http.ResponseWriter, r *http.Request) { func (sh *strictHandler) PostAdminAction(w http.ResponseWriter, r *http.Request) {
var request PostAdminActionRequestObject var request PostAdminActionRequestObject
if err := r.ParseForm(); err != nil { if reader, err := r.MultipartReader(); err != nil {
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode formdata: %w", err)) sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err))
return return
} else {
request.Body = reader
} }
var body PostAdminActionFormdataRequestBody
if err := runtime.BindForm(&body, r.Form, nil, nil); err != nil {
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't bind formdata: %w", err))
return
}
request.Body = &body
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.PostAdminAction(ctx, request.(PostAdminActionRequestObject)) return sh.ssi.PostAdminAction(ctx, request.(PostAdminActionRequestObject))
@@ -2899,6 +3112,58 @@ func (sh *strictHandler) GetDocument(w http.ResponseWriter, r *http.Request, id
} }
} }
// GetDocumentCover operation middleware
func (sh *strictHandler) GetDocumentCover(w http.ResponseWriter, r *http.Request, id string) {
var request GetDocumentCoverRequestObject
request.Id = id
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetDocumentCover(ctx, request.(GetDocumentCoverRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetDocumentCover")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetDocumentCoverResponseObject); ok {
if err := validResponse.VisitGetDocumentCoverResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// GetDocumentFile operation middleware
func (sh *strictHandler) GetDocumentFile(w http.ResponseWriter, r *http.Request, id string) {
var request GetDocumentFileRequestObject
request.Id = id
handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) {
return sh.ssi.GetDocumentFile(ctx, request.(GetDocumentFileRequestObject))
}
for _, middleware := range sh.middlewares {
handler = middleware(handler, "GetDocumentFile")
}
response, err := handler(r.Context(), w, r, request)
if err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
} else if validResponse, ok := response.(GetDocumentFileResponseObject); ok {
if err := validResponse.VisitGetDocumentFileResponse(w); err != nil {
sh.options.ResponseErrorHandlerFunc(w, r, err)
}
} else if response != nil {
sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response))
}
}
// GetHome operation middleware // GetHome operation middleware
func (sh *strictHandler) GetHome(w http.ResponseWriter, r *http.Request) { func (sh *strictHandler) GetHome(w http.ResponseWriter, r *http.Request) {
var request GetHomeRequestObject var request GetHomeRequestObject

View File

@@ -44,7 +44,7 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
return Login500JSONResponse{Code: 500, Message: "Internal context error"}, nil return Login500JSONResponse{Code: 500, Message: "Internal context error"}, nil
} }
// Create session // Create session with cookie options for Vite proxy compatibility
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey)) store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
if s.cfg.CookieEncKey != "" { if s.cfg.CookieEncKey != "" {
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 { if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
@@ -53,6 +53,17 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
} }
session, _ := store.Get(r, "token") session, _ := store.Get(r, "token")
// Configure cookie options to work with Vite proxy
// For localhost development, we need SameSite to allow cookies across ports
session.Options.SameSite = http.SameSiteLaxMode
session.Options.HttpOnly = true
if !s.cfg.CookieSecure {
session.Options.Secure = false // Allow HTTP for localhost development
} else {
session.Options.Secure = true
}
session.Values["authorizedUser"] = user.ID session.Values["authorizedUser"] = user.ID
session.Values["isAdmin"] = user.Admin session.Values["isAdmin"] = user.Admin
session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7) session.Values["expiresAt"] = time.Now().Unix() + (60 * 60 * 24 * 7)
@@ -82,8 +93,25 @@ func (s *Server) Logout(ctx context.Context, request LogoutRequestObject) (Logou
return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil return Logout401JSONResponse{Code: 401, Message: "Internal context error"}, nil
} }
// Create session store
store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey)) store := sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey))
if s.cfg.CookieEncKey != "" {
if len(s.cfg.CookieEncKey) == 16 || len(s.cfg.CookieEncKey) == 32 {
store = sessions.NewCookieStore([]byte(s.cfg.CookieAuthKey), []byte(s.cfg.CookieEncKey))
}
}
session, _ := store.Get(r, "token") session, _ := store.Get(r, "token")
// Configure cookie options (same as login)
session.Options.SameSite = http.SameSiteLaxMode
session.Options.HttpOnly = true
if !s.cfg.CookieSecure {
session.Options.Secure = false
} else {
session.Options.Secure = true
}
session.Values = make(map[any]any) session.Values = make(map[any]any)
if err := session.Save(r, w); err != nil { if err := session.Save(r, w); err != nil {

View File

@@ -46,7 +46,7 @@ func TestAuth(t *testing.T) {
func (suite *AuthTestSuite) SetupTest() { func (suite *AuthTestSuite) SetupTest() {
suite.cfg = suite.setupConfig() suite.cfg = suite.setupConfig()
suite.db = database.NewMgr(suite.cfg) suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg) suite.srv = NewServer(suite.db, suite.cfg, nil)
} }
func (suite *AuthTestSuite) createTestUser(username, password string) { func (suite *AuthTestSuite) createTestUser(username, password string) {

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"fmt" "fmt"
"io" "io"
"io/fs"
"net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -197,6 +199,221 @@ func parseInterfaceTime(t interface{}) *time.Time {
} }
} }
// serveNoCover serves the default no-cover image from assets
func (s *Server) serveNoCover() (fs.File, string, int64, error) {
// Try to open the no-cover image from assets
file, err := s.assets.Open("assets/images/no-cover.jpg")
if err != nil {
return nil, "", 0, err
}
// Get file info
info, err := file.Stat()
if err != nil {
file.Close()
return nil, "", 0, err
}
return file, "image/jpeg", info.Size(), nil
}
// openFileReader opens a file and returns it as an io.ReaderCloser
func openFileReader(path string) (*os.File, error) {
return os.Open(path)
}
// GET /documents/{id}/cover
func (s *Server) GetDocumentCover(ctx context.Context, request GetDocumentCoverRequestObject) (GetDocumentCoverResponseObject, error) {
// Authentication is handled by middleware, which also adds auth data to context
// This endpoint just serves the cover image
// Validate Document Exists in DB
document, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
log.Error("GetDocument DB Error:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
var coverFile fs.File
var contentType string
var contentLength int64
var needMetadataFetch bool
// Handle Identified Document
if document.Coverfile != nil {
if *document.Coverfile == "UNKNOWN" {
// Serve no-cover image
file, ct, size, err := s.serveNoCover()
if err != nil {
log.Error("Failed to open no-cover image:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
}
coverFile = file
contentType = ct
contentLength = size
needMetadataFetch = true
} else {
// Derive Path
coverPath := filepath.Join(s.cfg.DataPath, "covers", *document.Coverfile)
// Validate File Exists
fileInfo, err := os.Stat(coverPath)
if os.IsNotExist(err) {
log.Error("Cover file should but doesn't exist: ", err)
// Serve no-cover image
file, ct, size, err := s.serveNoCover()
if err != nil {
log.Error("Failed to open no-cover image:", err)
return GetDocumentCover404JSONResponse{Code: 404, Message: "Cover not found"}, nil
}
coverFile = file
contentType = ct
contentLength = size
needMetadataFetch = true
} else {
// Open the cover file
file, err := openFileReader(coverPath)
if err != nil {
log.Error("Failed to open cover file:", err)
return GetDocumentCover500JSONResponse{Code: 500, Message: "Failed to open cover"}, nil
}
coverFile = file
contentLength = fileInfo.Size()
// Determine content type based on file extension
contentType = "image/jpeg"
if strings.HasSuffix(coverPath, ".png") {
contentType = "image/png"
}
}
}
} else {
needMetadataFetch = true
}
// Attempt Metadata fetch if needed
var cachedCoverFile string = "UNKNOWN"
var coverDir string = filepath.Join(s.cfg.DataPath, "covers")
if needMetadataFetch {
// Identify Documents & Save Covers
metadataResults, err := metadata.SearchMetadata(metadata.SOURCE_GBOOK, metadata.MetadataInfo{
Title: document.Title,
Author: document.Author,
})
if err == nil && len(metadataResults) > 0 && metadataResults[0].ID != nil {
firstResult := metadataResults[0]
// Save Cover
fileName, err := metadata.CacheCover(*firstResult.ID, coverDir, document.ID, false)
if err == nil {
cachedCoverFile = *fileName
}
// Store First Metadata Result
if _, err = s.db.Queries.AddMetadata(ctx, database.AddMetadataParams{
DocumentID: document.ID,
Title: firstResult.Title,
Author: firstResult.Author,
Description: firstResult.Description,
Gbid: firstResult.ID,
Olid: nil,
Isbn10: firstResult.ISBN10,
Isbn13: firstResult.ISBN13,
}); err != nil {
log.Error("AddMetadata DB Error:", err)
}
}
// Upsert Document
if _, err = s.db.Queries.UpsertDocument(ctx, database.UpsertDocumentParams{
ID: document.ID,
Coverfile: &cachedCoverFile,
}); err != nil {
log.Warn("UpsertDocument DB Error:", err)
}
// Update cover file if we got a new cover
if cachedCoverFile != "UNKNOWN" {
coverPath := filepath.Join(coverDir, cachedCoverFile)
fileInfo, err := os.Stat(coverPath)
if err != nil {
log.Error("Failed to stat cached cover:", err)
// Keep the no-cover image
} else {
file, err := openFileReader(coverPath)
if err != nil {
log.Error("Failed to open cached cover:", err)
// Keep the no-cover image
} else {
_ = coverFile.Close() // Close the previous file
coverFile = file
contentLength = fileInfo.Size()
// Determine content type based on file extension
contentType = "image/jpeg"
if strings.HasSuffix(coverPath, ".png") {
contentType = "image/png"
}
}
}
}
}
return &GetDocumentCover200Response{
Body: coverFile,
ContentLength: contentLength,
ContentType: contentType,
}, nil
}
// GET /documents/{id}/file
func (s *Server) GetDocumentFile(ctx context.Context, request GetDocumentFileRequestObject) (GetDocumentFileResponseObject, error) {
// Authentication is handled by middleware, which also adds auth data to context
// This endpoint just serves the document file download
// Get Document
document, err := s.db.Queries.GetDocument(ctx, request.Id)
if err != nil {
log.Error("GetDocument DB Error:", err)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document not found"}, nil
}
if document.Filepath == nil {
log.Error("Document Doesn't Have File:", request.Id)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
}
// Derive Basepath
basepath := filepath.Join(s.cfg.DataPath, "documents")
if document.Basepath != nil && *document.Basepath != "" {
basepath = *document.Basepath
}
// Derive Storage Location
filePath := filepath.Join(basepath, *document.Filepath)
// Validate File Exists
fileInfo, err := os.Stat(filePath)
if os.IsNotExist(err) {
log.Error("File should but doesn't exist:", err)
return GetDocumentFile404JSONResponse{Code: 404, Message: "Document file not found"}, nil
}
// Open file
file, err := os.Open(filePath)
if err != nil {
log.Error("Failed to open document file:", err)
return GetDocumentFile500JSONResponse{Code: 500, Message: "Failed to open document"}, nil
}
return &GetDocumentFile200Response{
Body: file,
ContentLength: fileInfo.Size(),
Filename: filepath.Base(*document.Filepath),
}, nil
}
// POST /documents // POST /documents
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) { func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
auth, ok := s.getSessionFromContext(ctx) auth, ok := s.getSessionFromContext(ctx)
@@ -339,3 +556,46 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
return CreateDocument200JSONResponse(response), nil return CreateDocument200JSONResponse(response), nil
} }
// GetDocumentCover200Response is a custom response type that allows setting content type
type GetDocumentCover200Response struct {
Body io.Reader
ContentLength int64
ContentType string
}
func (response GetDocumentCover200Response) VisitGetDocumentCoverResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", response.ContentType)
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.WriteHeader(200)
if closer, ok := response.Body.(io.Closer); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}
// GetDocumentFile200Response is a custom response type that allows setting filename for download
type GetDocumentFile200Response struct {
Body io.Reader
ContentLength int64
Filename string
}
func (response GetDocumentFile200Response) VisitGetDocumentFileResponse(w http.ResponseWriter) error {
w.Header().Set("Content-Type", "application/octet-stream")
if response.ContentLength != 0 {
w.Header().Set("Content-Length", fmt.Sprint(response.ContentLength))
}
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", response.Filename))
w.WriteHeader(200)
if closer, ok := response.Body.(io.Closer); ok {
defer closer.Close()
}
_, err := io.Copy(w, response.Body)
return err
}

View File

@@ -47,7 +47,7 @@ func TestDocuments(t *testing.T) {
func (suite *DocumentsTestSuite) SetupTest() { func (suite *DocumentsTestSuite) SetupTest() {
suite.cfg = suite.setupConfig() suite.cfg = suite.setupConfig()
suite.db = database.NewMgr(suite.cfg) suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg) suite.srv = NewServer(suite.db, suite.cfg, nil)
} }
func (suite *DocumentsTestSuite) createTestUser(username, password string) { func (suite *DocumentsTestSuite) createTestUser(username, password string) {
@@ -159,3 +159,21 @@ func (suite *DocumentsTestSuite) TestAPIGetDocumentNotFound() {
suite.Equal(http.StatusNotFound, w.Code) suite.Equal(http.StatusNotFound, w.Code)
} }
func (suite *DocumentsTestSuite) TestAPIGetDocumentCoverUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/cover", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}
func (suite *DocumentsTestSuite) TestAPIGetDocumentFileUnauthenticated() {
req := httptest.NewRequest(http.MethodGet, "/api/v1/documents/test-id/file", nil)
w := httptest.NewRecorder()
suite.srv.ServeHTTP(w, req)
suite.Equal(http.StatusUnauthorized, w.Code)
}

View File

@@ -359,6 +359,14 @@ components:
- code - code
- message - message
MessageResponse:
type: object
properties:
message:
type: string
required:
- message
DatabaseInfo: DatabaseInfo:
type: object type: object
properties: properties:
@@ -744,6 +752,86 @@ paths:
schema: schema:
$ref: '#/components/schemas/ErrorResponse' $ref: '#/components/schemas/ErrorResponse'
/documents/{id}/cover:
get:
summary: Get document cover image
operationId: getDocumentCover
tags:
- Documents
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- BearerAuth: []
responses:
200:
description: Cover image
content:
image/jpeg: {}
image/png: {}
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
404:
description: Document not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
500:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/documents/{id}/file:
get:
summary: Download document file
operationId: getDocumentFile
tags:
- Documents
parameters:
- name: id
in: path
required: true
schema:
type: string
security:
- BearerAuth: []
responses:
200:
description: Document file download
content:
application/octet-stream:
schema:
type: string
format: binary
401:
description: Unauthorized
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
404:
description: Document not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
500:
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/progress: /progress:
get: get:
summary: List progress records summary: List progress records
@@ -1257,7 +1345,7 @@ paths:
requestBody: requestBody:
required: true required: true
content: content:
application/x-www-form-urlencoded: multipart/form-data:
schema: schema:
type: object type: object
properties: properties:
@@ -1279,6 +1367,9 @@ paths:
200: 200:
description: Action completed successfully description: Action completed successfully
content: content:
application/json:
schema:
$ref: '#/components/schemas/MessageResponse'
application/octet-stream: application/octet-stream:
schema: schema:
type: string type: string

View File

@@ -3,6 +3,7 @@ package v1
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"io/fs"
"net/http" "net/http"
"reichard.io/antholume/config" "reichard.io/antholume/config"
@@ -15,14 +16,16 @@ type Server struct {
mux *http.ServeMux mux *http.ServeMux
db *database.DBManager db *database.DBManager
cfg *config.Config cfg *config.Config
assets fs.FS
} }
// NewServer creates a new native HTTP server // NewServer creates a new native HTTP server
func NewServer(db *database.DBManager, cfg *config.Config) *Server { func NewServer(db *database.DBManager, cfg *config.Config, assets fs.FS) *Server {
s := &Server{ s := &Server{
mux: http.NewServeMux(), mux: http.NewServeMux(),
db: db, db: db,
cfg: cfg, cfg: cfg,
assets: assets,
} }
// Create strict handler with authentication middleware // Create strict handler with authentication middleware
@@ -43,7 +46,7 @@ func (s *Server) authMiddleware(handler StrictHandlerFunc, operationID string) S
ctx = context.WithValue(ctx, "request", r) ctx = context.WithValue(ctx, "request", r)
ctx = context.WithValue(ctx, "response", w) ctx = context.WithValue(ctx, "response", w)
// Skip auth for login endpoint // Skip auth for login endpoint only - cover and file require auth via cookies
if operationID == "Login" { if operationID == "Login" {
return handler(ctx, w, r, request) return handler(ctx, w, r, request)
} }

View File

@@ -38,7 +38,7 @@ func (suite *ServerTestSuite) SetupTest() {
} }
suite.db = database.NewMgr(suite.cfg) suite.db = database.NewMgr(suite.cfg)
suite.srv = NewServer(suite.db, suite.cfg) suite.srv = NewServer(suite.db, suite.cfg, nil)
} }
func (suite *ServerTestSuite) TestNewServer() { func (suite *ServerTestSuite) TestNewServer() {

View File

@@ -35,23 +35,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setAuthState(prev => { setAuthState(prev => {
if (meLoading) { if (meLoading) {
// Still checking authentication // Still checking authentication
console.log('[AuthContext] Checking authentication status...');
return { ...prev, isCheckingAuth: true }; return { ...prev, isCheckingAuth: true };
} else if (meData?.data) { } else if (meData?.data && meData.status === 200) {
// User is authenticated // User is authenticated - check that response has valid data
console.log('[AuthContext] User authenticated:', meData.data);
return { return {
isAuthenticated: true, isAuthenticated: true,
user: meData.data, user: meData.data,
isCheckingAuth: false, isCheckingAuth: false,
}; };
} else if (meError) { } else if (
meError ||
(meData && meData.status === 401) ||
(meData && meData.status === 403)
) {
// User is not authenticated or error occurred // User is not authenticated or error occurred
console.log('[AuthContext] User not authenticated:', meError?.message || String(meError));
return { return {
isAuthenticated: false, isAuthenticated: false,
user: null, user: null,
isCheckingAuth: false, isCheckingAuth: false,
}; };
} }
return prev; console.log('[AuthContext] Unexpected state - checking...');
return { ...prev, isCheckingAuth: false }; // Assume not authenticated if we can't determine
}); });
}, [meData, meError, meLoading]); }, [meData, meError, meLoading]);
@@ -75,6 +83,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
navigate('/'); navigate('/');
} catch (_error) { } catch (_error) {
console.error('[AuthContext] Login failed:', _error);
throw new Error('Login failed'); throw new Error('Login failed');
} }
}, },

View File

@@ -77,11 +77,11 @@ function ToastContainer({ toasts }: ToastContainerProps) {
return ( return (
<div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2"> <div className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2">
<div className="pointer-events-auto">
{toasts.map(toast => ( {toasts.map(toast => (
<Toast key={toast.id} {...toast} /> <div key={toast.id} className="pointer-events-auto">
))} <Toast {...toast} />
</div> </div>
))}
</div> </div>
); );
} }

View File

@@ -44,6 +44,7 @@ import type {
LoginRequest, LoginRequest,
LoginResponse, LoginResponse,
LogsResponse, LogsResponse,
MessageResponse,
PostAdminActionBody, PostAdminActionBody,
PostImportBody, PostImportBody,
PostSearchBody, PostSearchBody,
@@ -440,6 +441,279 @@ export function useGetDocument<TData = Awaited<ReturnType<typeof getDocument>>,
/**
* @summary Get document cover image
*/
export type getDocumentCoverResponse200ImageJpeg = {
data: Blob
status: 200
}
export type getDocumentCoverResponse200ImagePng = {
data: Blob
status: 200
}
export type getDocumentCoverResponse401 = {
data: ErrorResponse
status: 401
}
export type getDocumentCoverResponse404 = {
data: ErrorResponse
status: 404
}
export type getDocumentCoverResponse500 = {
data: ErrorResponse
status: 500
}
export type getDocumentCoverResponseSuccess = (getDocumentCoverResponse200ImageJpeg | getDocumentCoverResponse200ImagePng) & {
headers: Headers;
};
export type getDocumentCoverResponseError = (getDocumentCoverResponse401 | getDocumentCoverResponse404 | getDocumentCoverResponse500) & {
headers: Headers;
};
export type getDocumentCoverResponse = (getDocumentCoverResponseSuccess | getDocumentCoverResponseError)
export const getGetDocumentCoverUrl = (id: string,) => {
return `/api/v1/documents/${id}/cover`
}
export const getDocumentCover = async (id: string, options?: RequestInit): Promise<getDocumentCoverResponse> => {
const res = await fetch(getGetDocumentCoverUrl(id),
{
...options,
method: 'GET'
}
)
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
const data: getDocumentCoverResponse['data'] = body ? JSON.parse(body) : {}
return { data, status: res.status, headers: res.headers } as getDocumentCoverResponse
}
export const getGetDocumentCoverQueryKey = (id: string,) => {
return [
`/api/v1/documents/${id}/cover`
] as const;
}
export const getGetDocumentCoverQueryOptions = <TData = Awaited<ReturnType<typeof getDocumentCover>>, TError = ErrorResponse>(id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData>>, fetch?: RequestInit}
) => {
const {query: queryOptions, fetch: fetchOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetDocumentCoverQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getDocumentCover>>> = ({ signal }) => getDocumentCover(id, { signal, ...fetchOptions });
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetDocumentCoverQueryResult = NonNullable<Awaited<ReturnType<typeof getDocumentCover>>>
export type GetDocumentCoverQueryError = ErrorResponse
export function useGetDocumentCover<TData = Awaited<ReturnType<typeof getDocumentCover>>, TError = ErrorResponse>(
id: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getDocumentCover>>,
TError,
Awaited<ReturnType<typeof getDocumentCover>>
> , 'initialData'
>, fetch?: RequestInit}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetDocumentCover<TData = Awaited<ReturnType<typeof getDocumentCover>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getDocumentCover>>,
TError,
Awaited<ReturnType<typeof getDocumentCover>>
> , 'initialData'
>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetDocumentCover<TData = Awaited<ReturnType<typeof getDocumentCover>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData>>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Get document cover image
*/
export function useGetDocumentCover<TData = Awaited<ReturnType<typeof getDocumentCover>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentCover>>, TError, TData>>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetDocumentCoverQueryOptions(id,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Download document file
*/
export type getDocumentFileResponse200 = {
data: Blob
status: 200
}
export type getDocumentFileResponse401 = {
data: ErrorResponse
status: 401
}
export type getDocumentFileResponse404 = {
data: ErrorResponse
status: 404
}
export type getDocumentFileResponse500 = {
data: ErrorResponse
status: 500
}
export type getDocumentFileResponseSuccess = (getDocumentFileResponse200) & {
headers: Headers;
};
export type getDocumentFileResponseError = (getDocumentFileResponse401 | getDocumentFileResponse404 | getDocumentFileResponse500) & {
headers: Headers;
};
export type getDocumentFileResponse = (getDocumentFileResponseSuccess | getDocumentFileResponseError)
export const getGetDocumentFileUrl = (id: string,) => {
return `/api/v1/documents/${id}/file`
}
export const getDocumentFile = async (id: string, options?: RequestInit): Promise<getDocumentFileResponse> => {
const res = await fetch(getGetDocumentFileUrl(id),
{
...options,
method: 'GET'
}
)
const body = [204, 205, 304].includes(res.status) ? null : await res.text();
const data: getDocumentFileResponse['data'] = body ? JSON.parse(body) : {}
return { data, status: res.status, headers: res.headers } as getDocumentFileResponse
}
export const getGetDocumentFileQueryKey = (id: string,) => {
return [
`/api/v1/documents/${id}/file`
] as const;
}
export const getGetDocumentFileQueryOptions = <TData = Awaited<ReturnType<typeof getDocumentFile>>, TError = ErrorResponse>(id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData>>, fetch?: RequestInit}
) => {
const {query: queryOptions, fetch: fetchOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetDocumentFileQueryKey(id);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getDocumentFile>>> = ({ signal }) => getDocumentFile(id, { signal, ...fetchOptions });
return { queryKey, queryFn, enabled: !!(id), ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type GetDocumentFileQueryResult = NonNullable<Awaited<ReturnType<typeof getDocumentFile>>>
export type GetDocumentFileQueryError = ErrorResponse
export function useGetDocumentFile<TData = Awaited<ReturnType<typeof getDocumentFile>>, TError = ErrorResponse>(
id: string, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof getDocumentFile>>,
TError,
Awaited<ReturnType<typeof getDocumentFile>>
> , 'initialData'
>, fetch?: RequestInit}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetDocumentFile<TData = Awaited<ReturnType<typeof getDocumentFile>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof getDocumentFile>>,
TError,
Awaited<ReturnType<typeof getDocumentFile>>
> , 'initialData'
>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useGetDocumentFile<TData = Awaited<ReturnType<typeof getDocumentFile>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData>>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary Download document file
*/
export function useGetDocumentFile<TData = Awaited<ReturnType<typeof getDocumentFile>>, TError = ErrorResponse>(
id: string, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof getDocumentFile>>, TError, TData>>, fetch?: RequestInit}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getGetDocumentFileQueryOptions(id,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
return { ...query, queryKey: queryOptions.queryKey };
}
/** /**
* @summary List progress records * @summary List progress records
*/ */
@@ -2296,7 +2570,12 @@ export function useGetAdmin<TData = Awaited<ReturnType<typeof getAdmin>>, TError
/** /**
* @summary Perform admin action (backup, restore, etc.) * @summary Perform admin action (backup, restore, etc.)
*/ */
export type postAdminActionResponse200 = { export type postAdminActionResponse200ApplicationJson = {
data: MessageResponse
status: 200
}
export type postAdminActionResponse200ApplicationOctetStream = {
data: Blob data: Blob
status: 200 status: 200
} }
@@ -2316,7 +2595,7 @@ export type postAdminActionResponse500 = {
status: 500 status: 500
} }
export type postAdminActionResponseSuccess = (postAdminActionResponse200) & { export type postAdminActionResponseSuccess = (postAdminActionResponse200ApplicationJson | postAdminActionResponse200ApplicationOctetStream) & {
headers: Headers; headers: Headers;
}; };
export type postAdminActionResponseError = (postAdminActionResponse400 | postAdminActionResponse401 | postAdminActionResponse500) & { export type postAdminActionResponseError = (postAdminActionResponse400 | postAdminActionResponse401 | postAdminActionResponse500) & {
@@ -2334,22 +2613,22 @@ export const getPostAdminActionUrl = () => {
} }
export const postAdminAction = async (postAdminActionBody: PostAdminActionBody, options?: RequestInit): Promise<postAdminActionResponse> => { export const postAdminAction = async (postAdminActionBody: PostAdminActionBody, options?: RequestInit): Promise<postAdminActionResponse> => {
const formUrlEncoded = new URLSearchParams(); const formData = new FormData();
formUrlEncoded.append(`action`, postAdminActionBody.action); formData.append(`action`, postAdminActionBody.action);
if(postAdminActionBody.backup_types !== undefined) { if(postAdminActionBody.backup_types !== undefined) {
postAdminActionBody.backup_types.forEach(value => formUrlEncoded.append(`backup_types`, value)); postAdminActionBody.backup_types.forEach(value => formData.append(`backup_types`, value));
} }
if(postAdminActionBody.restore_file !== undefined) { if(postAdminActionBody.restore_file !== undefined) {
formUrlEncoded.append(`restore_file`, postAdminActionBody.restore_file); formData.append(`restore_file`, postAdminActionBody.restore_file);
} }
const res = await fetch(getPostAdminActionUrl(), const res = await fetch(getPostAdminActionUrl(),
{ {
...options, ...options,
method: 'POST', method: 'POST'
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...options?.headers }, ,
body: body:
formUrlEncoded, formData,
} }
) )

View File

@@ -39,6 +39,7 @@ export * from './logEntry';
export * from './loginRequest'; export * from './loginRequest';
export * from './loginResponse'; export * from './loginResponse';
export * from './logsResponse'; export * from './logsResponse';
export * from './messageResponse';
export * from './operationType'; export * from './operationType';
export * from './postAdminActionBody'; export * from './postAdminActionBody';
export * from './postAdminActionBodyAction'; export * from './postAdminActionBodyAction';

View File

@@ -0,0 +1,11 @@
/**
* Generated by orval v8.5.3 🍺
* Do not edit manually.
* AnthoLume API v1
* REST API for AnthoLume document management system
* OpenAPI spec version: 1.0.0
*/
export interface MessageResponse {
message: string;
}

View File

@@ -19,52 +19,79 @@ export default function AdminPage() {
}); });
const [restoreFile, setRestoreFile] = useState<File | null>(null); const [restoreFile, setRestoreFile] = useState<File | null>(null);
const handleBackupSubmit = (e: FormEvent) => { const handleBackupSubmit = async (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
const backupTypesList: string[] = []; const backupTypesList: string[] = [];
if (backupTypes.covers) backupTypesList.push('COVERS'); if (backupTypes.covers) backupTypesList.push('COVERS');
if (backupTypes.documents) backupTypesList.push('DOCUMENTS'); if (backupTypes.documents) backupTypesList.push('DOCUMENTS');
postAdminAction.mutate( try {
{ const formData = new FormData();
data: { formData.append('action', 'BACKUP');
action: 'BACKUP', backupTypesList.forEach(value => formData.append('backup_types', value));
backup_types: backupTypesList as any,
}, const response = await fetch('/api/v1/admin', {
}, method: 'POST',
{ body: formData,
onSuccess: response => { });
// Handle file download
const url = window.URL.createObjectURL(new Blob([response.data])); if (!response.ok) {
const link = document.createElement('a'); throw new Error('Backup failed: ' + response.statusText);
link.href = url;
link.setAttribute(
'download',
`AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`
);
document.body.appendChild(link);
link.click();
link.remove();
showInfo('Backup completed successfully');
},
onError: error => {
showError('Backup failed: ' + (error as any).message);
},
} }
const filename = `AnthoLumeBackup_${new Date().toISOString().replace(/[:.]/g, '')}.zip`;
// Stream the response directly to disk using File System Access API
// This avoids loading multi-GB files into browser memory
if (typeof (window as any).showSaveFilePicker === 'function') {
try {
// Modern browsers: Use File System Access API for direct disk writes
const handle = await (window as any).showSaveFilePicker({
suggestedName: filename,
types: [{ description: 'ZIP Archive', accept: { 'application/zip': ['.zip'] } }],
});
const writable = await handle.createWritable();
// Stream response body directly to file without buffering
const reader = response.body?.getReader();
if (!reader) throw new Error('Unable to read response');
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writable.write(value);
}
await writable.close();
showInfo('Backup completed successfully');
} catch (err) {
// User cancelled or error
if ((err as Error).name !== 'AbortError') {
showError('Backup failed: ' + (err as Error).message);
}
}
} else {
// Fallback for older browsers
showError(
'Your browser does not support large file downloads. Please use Chrome, Edge, or Safari.'
); );
}
} catch (error) {
showError('Backup failed: ' + (error as any).message);
}
}; };
const handleRestoreSubmit = (e: FormEvent) => { const handleRestoreSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!restoreFile) return; if (!restoreFile) return;
const formData = new FormData();
formData.append('restore_file', restoreFile);
formData.append('action', 'RESTORE');
postAdminAction.mutate( postAdminAction.mutate(
{ {
data: formData as any, data: {
action: 'RESTORE',
restore_file: restoreFile,
},
}, },
{ {
onSuccess: () => { onSuccess: () => {

View File

@@ -76,9 +76,9 @@ function DocumentCard({ doc }: DocumentCardProps) {
<Activity size={20} /> <Activity size={20} />
</Link> </Link>
{doc.filepath ? ( {doc.filepath ? (
<Link to={`/documents/${doc.id}/file`}> <a href={`/api/v1/documents/${doc.id}/file`}>
<Download size={20} /> <Download size={20} />
</Link> </a>
) : ( ) : (
<Download size={20} className="text-gray-400" /> <Download size={20} className="text-gray-400" />
)} )}

1
go.mod
View File

@@ -14,7 +14,6 @@ require (
github.com/jarcoal/httpmock v1.3.1 github.com/jarcoal/httpmock v1.3.1
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/oapi-codegen/runtime v1.2.0 github.com/oapi-codegen/runtime v1.2.0
github.com/pkg/errors v0.9.1
github.com/pressly/goose/v3 v3.24.3 github.com/pressly/goose/v3 v3.24.3
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0

2
go.sum
View File

@@ -93,8 +93,6 @@ github.com/oapi-codegen/runtime v1.2.0 h1:RvKc1CVS1QeKSNzO97FBQbSMZyQ8s6rZd+Lpmz
github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0= github.com/oapi-codegen/runtime v1.2.0/go.mod h1:Y7ZhmmlE8ikZOmuHRRndiIm7nf3xcVv+YMweKgG1DT0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM= github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=

View File

@@ -28,7 +28,7 @@ type server struct {
func New(c *config.Config, assets fs.FS) *server { func New(c *config.Config, assets fs.FS) *server {
db := database.NewMgr(c) db := database.NewMgr(c)
ginAPI := api.NewApi(db, c, assets) ginAPI := api.NewApi(db, c, assets)
v1API := v1.NewServer(db, c) v1API := v1.NewServer(db, c, assets)
// Create combined mux that handles both Gin and v1 API // Create combined mux that handles both Gin and v1 API
mux := http.NewServeMux() mux := http.NewServeMux()