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
|
## 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
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
120
api/v1/admin.go
120
api/v1/admin.go
@@ -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 val == "DOCUMENTS" {
|
||||||
} else if item == "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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -12,17 +13,19 @@ import (
|
|||||||
var _ StrictServerInterface = (*Server)(nil)
|
var _ StrictServerInterface = (*Server)(nil)
|
||||||
|
|
||||||
type Server struct {
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 => (
|
<div key={toast.id} className="pointer-events-auto">
|
||||||
<Toast key={toast.id} {...toast} />
|
<Toast {...toast} />
|
||||||
))}
|
</div>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
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 [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: () => {
|
||||||
|
|||||||
@@ -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
1
go.mod
@@ -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
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/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=
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user