wip 13
This commit is contained in:
97
AGENTS.md
97
AGENTS.md
@@ -1,84 +1,33 @@
|
||||
# Agent Context Hints
|
||||
# AnthoLume - Agent Context
|
||||
|
||||
## Current Status
|
||||
Currently mid migration from go templates (`./templates`) to React App (`./frontend`)
|
||||
## Migration Context
|
||||
Updating Go templates (rendered HTML) → React app using V1 API (OpenAPI spec)
|
||||
|
||||
## Architecture Context
|
||||
- **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)
|
||||
## Critical Rules
|
||||
|
||||
## Frontend Linting
|
||||
The frontend uses ESLint and Prettier for code quality and formatting.
|
||||
### Database Access
|
||||
- **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
|
||||
- **Check linting**: `cd frontend && bun run lint`
|
||||
- **Fix linting issues**: `cd frontend && bun run lint:fix`
|
||||
- **Check formatting**: `cd frontend && bun run format`
|
||||
- **Format files**: `cd frontend && bun run format:fix`
|
||||
### Migration Workflow
|
||||
1. Check legacy implementation for business logic
|
||||
2. Copy pattern but adapt to use `s.db.Queries.*` instead of `api.db.Queries.*`
|
||||
3. Map legacy response types to V1 API response types
|
||||
4. Never create new DB queries
|
||||
|
||||
### When to Run Linting
|
||||
Run linting after making any changes to the frontend to ensure code quality and consistency. All new code should pass linting before committing.
|
||||
### Surprises
|
||||
- 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
|
||||
The frontend uses **bun** for package management. Use:
|
||||
- `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
|
||||
## Error Handling
|
||||
Use `fmt.Errorf("message: %w", err)` for wrapping. Do NOT use `github.com/pkg/errors`.
|
||||
|
||||
## Data Flow (CRITICAL for migrations)
|
||||
1. Database schema → SQL queries (`database/query.sql`, `database/query.sql.go`)
|
||||
2. SQLC models → API handlers (`api/v1/*.go`)
|
||||
3. Go templates show **intended UI** structure (`templates/pages/*.tmpl`)
|
||||
4. API spec defines actual API contract (`api/v1/openapi.yaml`)
|
||||
5. Generated TS client → React components
|
||||
## Frontend
|
||||
- **Package manager**: bun (not npm)
|
||||
- **Lint**: `cd frontend && bun run lint` (and `lint:fix`)
|
||||
- **Format**: `cd frontend && bun run format` (and `format:fix`)
|
||||
|
||||
## When Migrating from Go Templates
|
||||
- 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
|
||||
## Regeneration
|
||||
- Go backend: `go generate ./api/v1/generate.go`
|
||||
- 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
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/gin-contrib/sessions/cookie"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"reichard.io/antholume/config"
|
||||
"reichard.io/antholume/database"
|
||||
@@ -298,7 +297,7 @@ func (api *API) loadTemplates(
|
||||
templateDirectory := fmt.Sprintf("templates/%ss", basePath)
|
||||
allFiles, err := fs.ReadDir(api.assets, templateDirectory)
|
||||
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
|
||||
@@ -310,7 +309,7 @@ func (api *API) loadTemplates(
|
||||
// Read Template
|
||||
b, err := fs.ReadFile(api.assets, templatePath)
|
||||
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)
|
||||
@@ -321,7 +320,7 @@ func (api *API) loadTemplates(
|
||||
// Parse Template
|
||||
baseTemplate, err = baseTemplate.New(templateName).Parse(string(b))
|
||||
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
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"github.com/gabriel-vasile/mimetype"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/itchyny/gojq"
|
||||
"github.com/pkg/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"reichard.io/antholume/database"
|
||||
"reichard.io/antholume/metadata"
|
||||
@@ -722,7 +721,7 @@ func (api *API) createBackup(ctx context.Context, w io.Writer, directories []str
|
||||
// Vacuum DB
|
||||
_, err := api.db.DB.ExecContext(ctx, "VACUUM;")
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "Unable to vacuum database")
|
||||
return fmt.Errorf("Unable to vacuum database: %w", err)
|
||||
}
|
||||
|
||||
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) {
|
||||
allUsers, err := api.db.Queries.GetUsers(ctx)
|
||||
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
|
||||
@@ -873,7 +872,7 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string
|
||||
} else {
|
||||
user, err := api.db.Queries.GetUser(ctx, user)
|
||||
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
|
||||
}
|
||||
@@ -911,7 +910,7 @@ func (api *API) updateUser(ctx context.Context, user string, rawPassword *string
|
||||
// Update User
|
||||
_, err := api.db.Queries.UpdateUser(ctx, updateParams)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, fmt.Sprintf("UpdateUser DB Error: %v", err))
|
||||
return fmt.Errorf("UpdateUser DB Error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -943,7 +942,7 @@ func (api *API) deleteUser(ctx context.Context, user string) error {
|
||||
// Delete User
|
||||
_, err = api.db.Queries.DeleteUser(ctx, user)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, fmt.Sprintf("DeleteUser DB Error: %v", err))
|
||||
return fmt.Errorf("DeleteUser DB Error: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
120
api/v1/admin.go
120
api/v1/admin.go
@@ -9,7 +9,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
@@ -77,16 +77,30 @@ func (s *Server) PostAdminAction(ctx context.Context, request PostAdminActionReq
|
||||
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
|
||||
switch request.Body.Action {
|
||||
switch action {
|
||||
case "METADATA_MATCH":
|
||||
// This is a TODO in the legacy code as well
|
||||
go func() {
|
||||
// TODO: Implement metadata matching logic
|
||||
log.Info("Metadata match action triggered (not yet implemented)")
|
||||
}()
|
||||
return PostAdminAction200ApplicationoctetStreamResponse{
|
||||
Body: strings.NewReader("Metadata match started"),
|
||||
return PostAdminAction200JSONResponse{
|
||||
Message: "Metadata match started",
|
||||
}, nil
|
||||
|
||||
case "CACHE_TABLES":
|
||||
@@ -97,15 +111,15 @@ func (s *Server) PostAdminAction(ctx context.Context, request PostAdminActionReq
|
||||
log.Error("Unable to cache temp tables: ", err)
|
||||
}
|
||||
}()
|
||||
return PostAdminAction200ApplicationoctetStreamResponse{
|
||||
Body: strings.NewReader("Cache tables operation started"),
|
||||
return PostAdminAction200JSONResponse{
|
||||
Message: "Cache tables operation started",
|
||||
}, nil
|
||||
|
||||
case "BACKUP":
|
||||
return s.handleBackupAction(ctx, request)
|
||||
return s.handleBackupAction(ctx, request, form)
|
||||
|
||||
case "RESTORE":
|
||||
return s.handleRestoreAction(ctx, request)
|
||||
return s.handleRestoreAction(ctx, request, form)
|
||||
|
||||
default:
|
||||
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
|
||||
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
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
var directories []string
|
||||
if request.Body.BackupTypes != nil {
|
||||
for _, item := range *request.Body.BackupTypes {
|
||||
if item == "COVERS" {
|
||||
directories = append(directories, "covers")
|
||||
} else if item == "DOCUMENTS" {
|
||||
directories = append(directories, "documents")
|
||||
}
|
||||
for _, val := range backupTypesValues {
|
||||
if val == "COVERS" {
|
||||
directories = append(directories, "covers")
|
||||
} else if val == "DOCUMENTS" {
|
||||
directories = append(directories, "documents")
|
||||
}
|
||||
}
|
||||
log.Info("Starting backup for directories: ", directories)
|
||||
err := s.createBackup(ctx, pw, directories)
|
||||
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{
|
||||
Body: pr,
|
||||
Body: pr,
|
||||
ContentLength: 0,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleRestoreAction handles the restore action, mirroring legacy processRestoreFile logic
|
||||
func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActionRequestObject) (PostAdminActionResponseObject, error) {
|
||||
if request.Body == nil || request.Body.RestoreFile == nil {
|
||||
func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActionRequestObject, form *multipart.Form) (PostAdminActionResponseObject, error) {
|
||||
// Get the uploaded file from form
|
||||
fileHeaders := form.File["restore_file"]
|
||||
if len(fileHeaders) == 0 {
|
||||
return PostAdminAction400JSONResponse{Code: 400, Message: "Missing restore file"}, nil
|
||||
}
|
||||
|
||||
// Read multipart form (similar to CreateDocument)
|
||||
// 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
|
||||
file, err := fileHeaders[0].Open()
|
||||
if err != nil {
|
||||
return PostAdminAction500JSONResponse{Code: 500, Message: "Failed to parse form"}, 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
|
||||
return PostAdminAction400JSONResponse{Code: 400, Message: "Unable to open restore file"}, nil
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
@@ -180,17 +186,20 @@ func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActio
|
||||
|
||||
// Save uploaded file to temp
|
||||
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
|
||||
}
|
||||
|
||||
// Get file info and validate ZIP
|
||||
fileInfo, err := tempFile.Stat()
|
||||
if err != nil {
|
||||
log.Error("Unable to read temp file: ", err)
|
||||
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to read file"}, nil
|
||||
}
|
||||
|
||||
zipReader, err := zip.NewReader(tempFile, fileInfo.Size())
|
||||
if err != nil {
|
||||
log.Error("Unable to read zip: ", err)
|
||||
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)
|
||||
log.Info("Creating backup before restore...")
|
||||
backupFilePath := filepath.Join(s.cfg.ConfigPath, fmt.Sprintf("backups/AnthoLumeBackup_%s.zip", time.Now().Format("20060102150405")))
|
||||
backupFile, err := os.Create(backupFilePath)
|
||||
if err != nil {
|
||||
log.Error("Unable to create backup file: ", err)
|
||||
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to create backup file"}, nil
|
||||
}
|
||||
defer backupFile.Close()
|
||||
@@ -223,46 +234,55 @@ func (s *Server) handleRestoreAction(ctx context.Context, request PostAdminActio
|
||||
w := bufio.NewWriter(backupFile)
|
||||
err = s.createBackup(ctx, w, []string{"covers", "documents"})
|
||||
if err != nil {
|
||||
log.Error("Unable to save backup file: ", err)
|
||||
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to save backup file"}, nil
|
||||
}
|
||||
|
||||
// Remove data (mirroring legacy removeData)
|
||||
log.Info("Removing data...")
|
||||
err = s.removeData()
|
||||
if err != nil {
|
||||
log.Error("Unable to delete data: ", err)
|
||||
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to delete data"}, nil
|
||||
}
|
||||
|
||||
// Restore data (mirroring legacy restoreData)
|
||||
log.Info("Restoring data...")
|
||||
err = s.restoreData(zipReader)
|
||||
if err != nil {
|
||||
log.Error("Unable to restore data: ", err)
|
||||
return PostAdminAction500JSONResponse{Code: 500, Message: "Unable to restore data"}, nil
|
||||
}
|
||||
|
||||
// Reload DB (mirroring legacy Reload)
|
||||
log.Info("Reloading database...")
|
||||
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
|
||||
}
|
||||
|
||||
// Rotate auth hashes (mirroring legacy rotateAllAuthHashes)
|
||||
log.Info("Rotating auth hashes...")
|
||||
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 PostAdminAction200ApplicationoctetStreamResponse{
|
||||
Body: strings.NewReader("Restore completed successfully"),
|
||||
log.Info("Restore completed successfully")
|
||||
return PostAdminAction200JSONResponse{
|
||||
Message: "Restore completed successfully",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createBackup creates a backup ZIP archive, mirroring legacy createBackup
|
||||
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;")
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("Unable to vacuum database: %w", err)
|
||||
}
|
||||
|
||||
ar := zip.NewWriter(w)
|
||||
defer ar.Close()
|
||||
|
||||
// Helper function to walk and archive files
|
||||
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
|
||||
}
|
||||
|
||||
@@ -345,6 +367,10 @@ func (s *Server) removeData() error {
|
||||
|
||||
// restoreData restores data from ZIP archive, mirroring legacy restoreData
|
||||
func (s *Server) restoreData(zipReader *zip.Reader) error {
|
||||
// Ensure Directories
|
||||
s.cfg.EnsureDirectories()
|
||||
|
||||
// Restore Data
|
||||
for _, file := range zipReader.File {
|
||||
rc, err := file.Open()
|
||||
if err != nil {
|
||||
@@ -355,12 +381,14 @@ func (s *Server) restoreData(zipReader *zip.Reader) error {
|
||||
destPath := filepath.Join(s.cfg.DataPath, file.Name)
|
||||
destFile, err := os.Create(destPath)
|
||||
if err != nil {
|
||||
log.Errorf("error creating destination file: %v", err)
|
||||
return err
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, rc)
|
||||
if err != nil {
|
||||
// Copy the contents from the zip file to the destination file.
|
||||
if _, err := io.Copy(destFile, rc); err != nil {
|
||||
log.Errorf("Error copying file contents: %v", err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,16 +101,16 @@ func (e OperationType) Valid() bool {
|
||||
}
|
||||
}
|
||||
|
||||
// Defines values for PostAdminActionFormdataBodyAction.
|
||||
// Defines values for PostAdminActionMultipartBodyAction.
|
||||
const (
|
||||
BACKUP PostAdminActionFormdataBodyAction = "BACKUP"
|
||||
CACHETABLES PostAdminActionFormdataBodyAction = "CACHE_TABLES"
|
||||
METADATAMATCH PostAdminActionFormdataBodyAction = "METADATA_MATCH"
|
||||
RESTORE PostAdminActionFormdataBodyAction = "RESTORE"
|
||||
BACKUP PostAdminActionMultipartBodyAction = "BACKUP"
|
||||
CACHETABLES PostAdminActionMultipartBodyAction = "CACHE_TABLES"
|
||||
METADATAMATCH PostAdminActionMultipartBodyAction = "METADATA_MATCH"
|
||||
RESTORE PostAdminActionMultipartBodyAction = "RESTORE"
|
||||
)
|
||||
|
||||
// Valid indicates whether the value is a known member of the PostAdminActionFormdataBodyAction enum.
|
||||
func (e PostAdminActionFormdataBodyAction) Valid() bool {
|
||||
// Valid indicates whether the value is a known member of the PostAdminActionMultipartBodyAction enum.
|
||||
func (e PostAdminActionMultipartBodyAction) Valid() bool {
|
||||
switch e {
|
||||
case BACKUP:
|
||||
return true
|
||||
@@ -315,6 +315,11 @@ type LogsResponse struct {
|
||||
Logs *[]LogEntry `json:"logs,omitempty"`
|
||||
}
|
||||
|
||||
// MessageResponse defines model for MessageResponse.
|
||||
type MessageResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// OperationType defines model for OperationType.
|
||||
type OperationType string
|
||||
|
||||
@@ -436,15 +441,15 @@ type GetActivityParams struct {
|
||||
Limit *int64 `form:"limit,omitempty" json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
// PostAdminActionFormdataBody defines parameters for PostAdminAction.
|
||||
type PostAdminActionFormdataBody struct {
|
||||
Action PostAdminActionFormdataBodyAction `form:"action" json:"action"`
|
||||
BackupTypes *[]BackupType `form:"backup_types,omitempty" json:"backup_types,omitempty"`
|
||||
RestoreFile *openapi_types.File `form:"restore_file,omitempty" json:"restore_file,omitempty"`
|
||||
// PostAdminActionMultipartBody defines parameters for PostAdminAction.
|
||||
type PostAdminActionMultipartBody struct {
|
||||
Action PostAdminActionMultipartBodyAction `json:"action"`
|
||||
BackupTypes *[]BackupType `json:"backup_types,omitempty"`
|
||||
RestoreFile *openapi_types.File `json:"restore_file,omitempty"`
|
||||
}
|
||||
|
||||
// PostAdminActionFormdataBodyAction defines parameters for PostAdminAction.
|
||||
type PostAdminActionFormdataBodyAction string
|
||||
// PostAdminActionMultipartBodyAction defines parameters for PostAdminAction.
|
||||
type PostAdminActionMultipartBodyAction string
|
||||
|
||||
// GetImportDirectoryParams defines parameters for GetImportDirectory.
|
||||
type GetImportDirectoryParams struct {
|
||||
@@ -507,8 +512,8 @@ type PostSearchFormdataBody struct {
|
||||
Title string `form:"title" json:"title"`
|
||||
}
|
||||
|
||||
// PostAdminActionFormdataRequestBody defines body for PostAdminAction for application/x-www-form-urlencoded ContentType.
|
||||
type PostAdminActionFormdataRequestBody PostAdminActionFormdataBody
|
||||
// PostAdminActionMultipartRequestBody defines body for PostAdminAction for multipart/form-data ContentType.
|
||||
type PostAdminActionMultipartRequestBody PostAdminActionMultipartBody
|
||||
|
||||
// PostImportFormdataRequestBody defines body for PostImport for application/x-www-form-urlencoded ContentType.
|
||||
type PostImportFormdataRequestBody PostImportFormdataBody
|
||||
@@ -575,6 +580,12 @@ type ServerInterface interface {
|
||||
// Get a single document
|
||||
// (GET /documents/{id})
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
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("POST "+options.BaseURL+"/documents", wrapper.CreateDocument)
|
||||
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/graph", wrapper.GetGraphData)
|
||||
m.HandleFunc("GET "+options.BaseURL+"/home/statistics", wrapper.GetUserStatistics)
|
||||
@@ -1508,13 +1583,22 @@ func (response GetAdmin401JSONResponse) VisitGetAdminResponse(w http.ResponseWri
|
||||
}
|
||||
|
||||
type PostAdminActionRequestObject struct {
|
||||
Body *PostAdminActionFormdataRequestBody
|
||||
Body *multipart.Reader
|
||||
}
|
||||
|
||||
type PostAdminActionResponseObject interface {
|
||||
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 {
|
||||
Body io.Reader
|
||||
ContentLength int64
|
||||
@@ -2003,6 +2087,133 @@ func (response GetDocument500JSONResponse) VisitGetDocumentResponse(w http.Respo
|
||||
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 {
|
||||
}
|
||||
|
||||
@@ -2421,6 +2632,12 @@ type StrictServerInterface interface {
|
||||
// Get a single document
|
||||
// (GET /documents/{id})
|
||||
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)
|
||||
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) {
|
||||
var request PostAdminActionRequestObject
|
||||
|
||||
if err := r.ParseForm(); err != nil {
|
||||
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode formdata: %w", err))
|
||||
if reader, err := r.MultipartReader(); err != nil {
|
||||
sh.options.RequestErrorHandlerFunc(w, r, fmt.Errorf("can't decode multipart body: %w", err))
|
||||
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) {
|
||||
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
|
||||
func (sh *strictHandler) GetHome(w http.ResponseWriter, r *http.Request) {
|
||||
var request GetHomeRequestObject
|
||||
|
||||
@@ -44,7 +44,7 @@ func (s *Server) Login(ctx context.Context, request LoginRequestObject) (LoginRe
|
||||
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))
|
||||
if s.cfg.CookieEncKey != "" {
|
||||
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")
|
||||
|
||||
// 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["isAdmin"] = user.Admin
|
||||
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
|
||||
}
|
||||
|
||||
// Create session store
|
||||
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")
|
||||
|
||||
// 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)
|
||||
|
||||
if err := session.Save(r, w); err != nil {
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestAuth(t *testing.T) {
|
||||
func (suite *AuthTestSuite) SetupTest() {
|
||||
suite.cfg = suite.setupConfig()
|
||||
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) {
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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
|
||||
func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentRequestObject) (CreateDocumentResponseObject, error) {
|
||||
auth, ok := s.getSessionFromContext(ctx)
|
||||
@@ -339,3 +556,46 @@ func (s *Server) CreateDocument(ctx context.Context, request CreateDocumentReque
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestDocuments(t *testing.T) {
|
||||
func (suite *DocumentsTestSuite) SetupTest() {
|
||||
suite.cfg = suite.setupConfig()
|
||||
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) {
|
||||
@@ -158,4 +158,22 @@ func (suite *DocumentsTestSuite) TestAPIGetDocumentNotFound() {
|
||||
suite.srv.ServeHTTP(w, req)
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -359,6 +359,14 @@ components:
|
||||
- code
|
||||
- message
|
||||
|
||||
MessageResponse:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
required:
|
||||
- message
|
||||
|
||||
DatabaseInfo:
|
||||
type: object
|
||||
properties:
|
||||
@@ -744,6 +752,86 @@ paths:
|
||||
schema:
|
||||
$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:
|
||||
get:
|
||||
summary: List progress records
|
||||
@@ -1257,7 +1345,7 @@ paths:
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/x-www-form-urlencoded:
|
||||
multipart/form-data:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
@@ -1279,6 +1367,9 @@ paths:
|
||||
200:
|
||||
description: Action completed successfully
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/MessageResponse'
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
@@ -3,6 +3,7 @@ package v1
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
|
||||
"reichard.io/antholume/config"
|
||||
@@ -12,17 +13,19 @@ import (
|
||||
var _ StrictServerInterface = (*Server)(nil)
|
||||
|
||||
type Server struct {
|
||||
mux *http.ServeMux
|
||||
db *database.DBManager
|
||||
cfg *config.Config
|
||||
mux *http.ServeMux
|
||||
db *database.DBManager
|
||||
cfg *config.Config
|
||||
assets fs.FS
|
||||
}
|
||||
|
||||
// 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{
|
||||
mux: http.NewServeMux(),
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
mux: http.NewServeMux(),
|
||||
db: db,
|
||||
cfg: cfg,
|
||||
assets: assets,
|
||||
}
|
||||
|
||||
// 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, "response", w)
|
||||
|
||||
// Skip auth for login endpoint
|
||||
// Skip auth for login endpoint only - cover and file require auth via cookies
|
||||
if operationID == "Login" {
|
||||
return handler(ctx, w, r, request)
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ func (suite *ServerTestSuite) SetupTest() {
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
@@ -35,23 +35,31 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
setAuthState(prev => {
|
||||
if (meLoading) {
|
||||
// Still checking authentication
|
||||
console.log('[AuthContext] Checking authentication status...');
|
||||
return { ...prev, isCheckingAuth: true };
|
||||
} else if (meData?.data) {
|
||||
// User is authenticated
|
||||
} else if (meData?.data && meData.status === 200) {
|
||||
// User is authenticated - check that response has valid data
|
||||
console.log('[AuthContext] User authenticated:', meData.data);
|
||||
return {
|
||||
isAuthenticated: true,
|
||||
user: meData.data,
|
||||
isCheckingAuth: false,
|
||||
};
|
||||
} else if (meError) {
|
||||
} else if (
|
||||
meError ||
|
||||
(meData && meData.status === 401) ||
|
||||
(meData && meData.status === 403)
|
||||
) {
|
||||
// User is not authenticated or error occurred
|
||||
console.log('[AuthContext] User not authenticated:', meError?.message || String(meError));
|
||||
return {
|
||||
isAuthenticated: false,
|
||||
user: null,
|
||||
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]);
|
||||
|
||||
@@ -75,6 +83,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
navigate('/');
|
||||
} catch (_error) {
|
||||
console.error('[AuthContext] Login failed:', _error);
|
||||
throw new Error('Login failed');
|
||||
}
|
||||
},
|
||||
|
||||
@@ -77,11 +77,11 @@ function ToastContainer({ toasts }: ToastContainerProps) {
|
||||
|
||||
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-auto">
|
||||
{toasts.map(toast => (
|
||||
<Toast key={toast.id} {...toast} />
|
||||
))}
|
||||
</div>
|
||||
{toasts.map(toast => (
|
||||
<div key={toast.id} className="pointer-events-auto">
|
||||
<Toast {...toast} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@ import type {
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
LogsResponse,
|
||||
MessageResponse,
|
||||
PostAdminActionBody,
|
||||
PostImportBody,
|
||||
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
|
||||
*/
|
||||
@@ -2296,7 +2570,12 @@ export function useGetAdmin<TData = Awaited<ReturnType<typeof getAdmin>>, TError
|
||||
/**
|
||||
* @summary Perform admin action (backup, restore, etc.)
|
||||
*/
|
||||
export type postAdminActionResponse200 = {
|
||||
export type postAdminActionResponse200ApplicationJson = {
|
||||
data: MessageResponse
|
||||
status: 200
|
||||
}
|
||||
|
||||
export type postAdminActionResponse200ApplicationOctetStream = {
|
||||
data: Blob
|
||||
status: 200
|
||||
}
|
||||
@@ -2316,7 +2595,7 @@ export type postAdminActionResponse500 = {
|
||||
status: 500
|
||||
}
|
||||
|
||||
export type postAdminActionResponseSuccess = (postAdminActionResponse200) & {
|
||||
export type postAdminActionResponseSuccess = (postAdminActionResponse200ApplicationJson | postAdminActionResponse200ApplicationOctetStream) & {
|
||||
headers: Headers;
|
||||
};
|
||||
export type postAdminActionResponseError = (postAdminActionResponse400 | postAdminActionResponse401 | postAdminActionResponse500) & {
|
||||
@@ -2334,22 +2613,22 @@ export const getPostAdminActionUrl = () => {
|
||||
}
|
||||
|
||||
export const postAdminAction = async (postAdminActionBody: PostAdminActionBody, options?: RequestInit): Promise<postAdminActionResponse> => {
|
||||
const formUrlEncoded = new URLSearchParams();
|
||||
formUrlEncoded.append(`action`, postAdminActionBody.action);
|
||||
const formData = new FormData();
|
||||
formData.append(`action`, postAdminActionBody.action);
|
||||
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) {
|
||||
formUrlEncoded.append(`restore_file`, postAdminActionBody.restore_file);
|
||||
formData.append(`restore_file`, postAdminActionBody.restore_file);
|
||||
}
|
||||
|
||||
const res = await fetch(getPostAdminActionUrl(),
|
||||
{
|
||||
...options,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...options?.headers },
|
||||
method: 'POST'
|
||||
,
|
||||
body:
|
||||
formUrlEncoded,
|
||||
formData,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ export * from './logEntry';
|
||||
export * from './loginRequest';
|
||||
export * from './loginResponse';
|
||||
export * from './logsResponse';
|
||||
export * from './messageResponse';
|
||||
export * from './operationType';
|
||||
export * from './postAdminActionBody';
|
||||
export * from './postAdminActionBodyAction';
|
||||
|
||||
11
frontend/src/generated/model/messageResponse.ts
Normal file
11
frontend/src/generated/model/messageResponse.ts
Normal 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;
|
||||
}
|
||||
@@ -19,52 +19,79 @@ export default function AdminPage() {
|
||||
});
|
||||
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
||||
|
||||
const handleBackupSubmit = (e: FormEvent) => {
|
||||
const handleBackupSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
const backupTypesList: string[] = [];
|
||||
if (backupTypes.covers) backupTypesList.push('COVERS');
|
||||
if (backupTypes.documents) backupTypesList.push('DOCUMENTS');
|
||||
|
||||
postAdminAction.mutate(
|
||||
{
|
||||
data: {
|
||||
action: 'BACKUP',
|
||||
backup_types: backupTypesList as any,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: response => {
|
||||
// Handle file download
|
||||
const url = window.URL.createObjectURL(new Blob([response.data]));
|
||||
const link = document.createElement('a');
|
||||
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);
|
||||
},
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('action', 'BACKUP');
|
||||
backupTypesList.forEach(value => formData.append('backup_types', value));
|
||||
|
||||
const response = await fetch('/api/v1/admin', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Backup failed: ' + response.statusText);
|
||||
}
|
||||
);
|
||||
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!restoreFile) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('restore_file', restoreFile);
|
||||
formData.append('action', 'RESTORE');
|
||||
|
||||
postAdminAction.mutate(
|
||||
{
|
||||
data: formData as any,
|
||||
data: {
|
||||
action: 'RESTORE',
|
||||
restore_file: restoreFile,
|
||||
},
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
|
||||
@@ -76,9 +76,9 @@ function DocumentCard({ doc }: DocumentCardProps) {
|
||||
<Activity size={20} />
|
||||
</Link>
|
||||
{doc.filepath ? (
|
||||
<Link to={`/documents/${doc.id}/file`}>
|
||||
<a href={`/api/v1/documents/${doc.id}/file`}>
|
||||
<Download size={20} />
|
||||
</Link>
|
||||
</a>
|
||||
) : (
|
||||
<Download size={20} className="text-gray-400" />
|
||||
)}
|
||||
|
||||
1
go.mod
1
go.mod
@@ -14,7 +14,6 @@ require (
|
||||
github.com/jarcoal/httpmock v1.3.1
|
||||
github.com/microcosm-cc/bluemonday v1.0.27
|
||||
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/sirupsen/logrus v1.9.3
|
||||
github.com/stretchr/testify v1.10.0
|
||||
|
||||
2
go.sum
2
go.sum
@@ -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/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/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/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pressly/goose/v3 v3.24.3 h1:DSWWNwwggVUsYZ0X2VitiAa9sKuqtBfe+Jr9zFGwWlM=
|
||||
|
||||
@@ -28,7 +28,7 @@ type server struct {
|
||||
func New(c *config.Config, assets fs.FS) *server {
|
||||
db := database.NewMgr(c)
|
||||
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
|
||||
mux := http.NewServeMux()
|
||||
|
||||
Reference in New Issue
Block a user