mirror of
https://github.com/napnap75/multiarch-docker-images.git
synced 2026-11-13 09:31:46 +01:00
433 lines
13 KiB
Go
433 lines
13 KiB
Go
// Routes.go implements the review-dashboard application routes of
|
|
// arch.md section 7.3: configuration metadata (US-31), PDF proxy (US-34),
|
|
// dry-run in its two exclusive modes (US-35) and the full correction (US-36).
|
|
package pbapp
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"ganigeorgiev.com/pocketbase/pocketbase/apis"
|
|
"ganigeorgiev.com/pocketbase/pocketbase/core"
|
|
"github.com/napnap75/archivist/internal/config"
|
|
"github.com/napnap75/archivist/internal/storage"
|
|
)
|
|
|
|
// BindRoutes registers the dashboard application routes. They are
|
|
// superuser-only: the dashboard authenticates through the standard
|
|
// PocketBase auth, but these routes bypass the record API and its hooks.
|
|
func BindRoutes(app core.App, cfg *config.Config, s3 *storage.Client) {
|
|
app.OnServe().BindFunc(func(e *core.ServeEvent) error {
|
|
if !e.Router.HasRoute(http.MethodGet, "/{path...}") {
|
|
e.Router.GET("/{path...}", apis.Static(os.DirFS("pb_public"), true))
|
|
}
|
|
e.Router.GET("/api/archivist/config", func(re *core.RequestEvent) error {
|
|
if !re.HasSuperuserAuth() {
|
|
return re.ForbiddenError("superusers only", nil)
|
|
}
|
|
return re.JSON(200, configMetadata(cfg))
|
|
})
|
|
e.Router.GET("/api/archivist/documents/{id}/pdf", func(re *core.RequestEvent) error {
|
|
return handlePdf(re, s3)
|
|
})
|
|
e.Router.POST("/api/archivist/documents/{id}/dry-run", func(re *core.RequestEvent) error {
|
|
return handleDryRun(re, cfg)
|
|
})
|
|
e.Router.POST("/api/archivist/documents/{id}/correct", func(re *core.RequestEvent) error {
|
|
return handleCorrect(re, cfg, s3)
|
|
})
|
|
return e.Next()
|
|
})
|
|
}
|
|
|
|
// configMetadata serves the read-only configuration the UI is driven by:
|
|
// declared fields, enum vocabularies, the fixed statuses, template names.
|
|
// Nothing technical leaks (no S3 credentials, no rules).
|
|
func configMetadata(cfg *config.Config) map[string]any {
|
|
return map[string]any{
|
|
"fields": cfg.FieldList,
|
|
"enums": enumVocabularies(cfg),
|
|
"statuses": []string{"pending", "processed", "validated"},
|
|
"templates": sortedTemplateNames(cfg),
|
|
}
|
|
}
|
|
|
|
func sortedTemplateNames(cfg *config.Config) []string {
|
|
out := make([]string, 0, len(cfg.Templates))
|
|
for name := range cfg.Templates {
|
|
out = append(out, name)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// enumVocabularies computes each enum field vocabulary from the constants
|
|
// the rules assign (US-31): the UI dropdowns offer exactly what the rule
|
|
// engine can set.
|
|
func enumVocabularies(cfg *config.Config) map[string][]string {
|
|
enums := map[string][]string{}
|
|
seen := map[string]map[string]bool{}
|
|
var walk func(rules []*config.Rule)
|
|
walk = func(rules []*config.Rule) {
|
|
for _, r := range rules {
|
|
for _, s := range r.Set {
|
|
if s.Eval != "" {
|
|
continue
|
|
}
|
|
f, ok := cfg.Fields[s.Field]
|
|
if !ok || f.Type != "enum" {
|
|
continue
|
|
}
|
|
v, _ := s.Value.(string)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if seen[s.Field] == nil {
|
|
seen[s.Field] = map[string]bool{}
|
|
}
|
|
if !seen[s.Field][v] {
|
|
seen[s.Field][v] = true
|
|
enums[s.Field] = append(enums[s.Field], v)
|
|
}
|
|
}
|
|
walk(r.Rules)
|
|
}
|
|
}
|
|
walk(cfg.Rules)
|
|
for _, values := range enums {
|
|
sort.Strings(values)
|
|
}
|
|
return enums
|
|
}
|
|
|
|
// handlePdf streams the document from S3; the browser never reaches the
|
|
// S3 cluster (US-34).
|
|
func handlePdf(re *core.RequestEvent, s3 *storage.Client) error {
|
|
if !re.HasSuperuserAuth() {
|
|
return re.ForbiddenError("superusers only", nil)
|
|
}
|
|
id := re.Request.PathValue("id")
|
|
record, err := re.App.FindRecordById("documents", id)
|
|
if err != nil {
|
|
return re.JSON(404, map[string]any{"message": "document not found"})
|
|
}
|
|
key := record.GetString("file_key")
|
|
if key == "" {
|
|
return re.JSON(404, map[string]any{"message": "document has no file"})
|
|
}
|
|
ctx := re.Request.Context()
|
|
if !s3.Exists(ctx, key) {
|
|
return re.JSON(404, map[string]any{"message": "object not found in S3", "key": key})
|
|
}
|
|
data, _, err := s3.Get(ctx, key)
|
|
if err != nil {
|
|
return re.JSON(500, map[string]any{"message": "S3 download failed", "error": err.Error()})
|
|
}
|
|
re.Response.Header().Set("Content-Disposition", "inline; filename=\""+fileName(key)+"\"")
|
|
return re.Blob(200, "application/pdf", data)
|
|
}
|
|
|
|
func fileName(key string) string {
|
|
if i := strings.LastIndex(key, "/"); i >= 0 {
|
|
return key[i+1:]
|
|
}
|
|
return key
|
|
}
|
|
|
|
// dryRunResponse is the single response shape of the two exclusive
|
|
// dry-run modes (US-35).
|
|
type dryRunResponse struct {
|
|
Fields map[string]any `json:"fields"`
|
|
Template string `json:"template"`
|
|
Rendered string `json:"rendered"`
|
|
Errors []string `json:"errors"`
|
|
}
|
|
|
|
func handleDryRun(re *core.RequestEvent, cfg *config.Config) error {
|
|
if !re.HasSuperuserAuth() {
|
|
return re.ForbiddenError("superusers only", nil)
|
|
}
|
|
id := re.Request.PathValue("id")
|
|
record, err := re.App.FindRecordById("documents", id)
|
|
if err != nil {
|
|
return re.JSON(404, map[string]any{"message": "document not found"})
|
|
}
|
|
pages := recordPages(record)
|
|
|
|
body, err := io.ReadAll(re.Request.Body)
|
|
if err != nil {
|
|
return re.JSON(400, map[string]any{"message": "unreadable body", "error": err.Error()})
|
|
}
|
|
|
|
// Mode A - no body: the pipeline proposal, the rule tree re-evaluated
|
|
// against the stored pages. User values never take part.
|
|
if len(bytes.TrimSpace(body)) == 0 {
|
|
fields, rendered, errs := cfg.Evaluate(pages)
|
|
return re.JSON(200, dryRunResponse{
|
|
Fields: fields,
|
|
Template: templateName(fields),
|
|
Rendered: rendered,
|
|
Errors: nonNil(errs),
|
|
})
|
|
}
|
|
|
|
// Mode B - field values: the template render of the merged record and
|
|
// user values, against the stored pages.
|
|
var submitted map[string]any
|
|
if err := json.Unmarshal(body, &submitted); err != nil {
|
|
return re.JSON(400, map[string]any{"message": "invalid JSON body", "error": err.Error()})
|
|
}
|
|
merged, convErrs, unknown, _ := mergeSubmitted(cfg, FieldsFromRecord(cfg, record), submitted)
|
|
if len(unknown) > 0 {
|
|
return re.JSON(400, map[string]any{"message": "unknown field(s)", "errors": unknown})
|
|
}
|
|
tplName, rendered, evalErrs := evaluateMerged(cfg, merged, pages)
|
|
if len(convErrs) > 0 {
|
|
rendered = ""
|
|
}
|
|
return re.JSON(200, dryRunResponse{
|
|
Fields: merged,
|
|
Template: tplName,
|
|
Rendered: rendered,
|
|
Errors: nonNil(append(convErrs, evalErrs...)),
|
|
})
|
|
}
|
|
|
|
// handleCorrect is the full correction of US-36: conversion, render,
|
|
// conflict-suffix resolution, S3 copy, verification, source deletion,
|
|
// record update to status validated, corrected event with the full diff -
|
|
// and a rollback leaving everything unchanged when any step fails.
|
|
func handleCorrect(re *core.RequestEvent, cfg *config.Config, s3 *storage.Client) error {
|
|
if !re.HasSuperuserAuth() {
|
|
return re.ForbiddenError("superusers only", nil)
|
|
}
|
|
id := re.Request.PathValue("id")
|
|
record, err := re.App.FindRecordById("documents", id)
|
|
if err != nil {
|
|
return re.JSON(404, map[string]any{"message": "document not found"})
|
|
}
|
|
pages := recordPages(record)
|
|
|
|
body, err := io.ReadAll(re.Request.Body)
|
|
if err != nil {
|
|
return re.JSON(400, map[string]any{"message": "unreadable body", "error": err.Error()})
|
|
}
|
|
var submitted map[string]any
|
|
if len(bytes.TrimSpace(body)) > 0 {
|
|
if err := json.Unmarshal(body, &submitted); err != nil {
|
|
return re.JSON(400, map[string]any{"message": "invalid JSON body", "error": err.Error()})
|
|
}
|
|
}
|
|
|
|
merged, convErrs, unknown, cleared := mergeSubmitted(cfg, FieldsFromRecord(cfg, record), submitted)
|
|
if len(convErrs) > 0 || len(unknown) > 0 {
|
|
return re.JSON(400, map[string]any{"message": "invalid values", "errors": append(convErrs, unknown...)})
|
|
}
|
|
tplName, rendered, evalErrs := evaluateMerged(cfg, merged, pages)
|
|
if len(evalErrs) > 0 {
|
|
return re.JSON(400, map[string]any{"message": "correction refused", "errors": evalErrs})
|
|
}
|
|
|
|
before := SnapshotFromRecord(cfg, record, true)
|
|
oldKey := record.GetString("file_key")
|
|
target := "files/" + rendered
|
|
|
|
ctx := re.Request.Context()
|
|
refiled := false
|
|
finalKey := oldKey
|
|
if target != oldKey {
|
|
finalKey, err = s3.ResolveConflict(ctx, target)
|
|
if err != nil {
|
|
return re.JSON(500, map[string]any{"message": "conflict resolution failed", "error": err.Error()})
|
|
}
|
|
if err := s3.Copy(ctx, oldKey, finalKey); err != nil {
|
|
return re.JSON(500, map[string]any{"message": "S3 copy failed", "error": err.Error()})
|
|
}
|
|
if !s3.Exists(ctx, finalKey) {
|
|
return re.JSON(500, map[string]any{"message": "target not verified after copy", "key": finalKey})
|
|
}
|
|
if err := s3.Remove(ctx, oldKey); err != nil {
|
|
_ = s3.Remove(ctx, finalKey) // best-effort rollback of the copy
|
|
return re.JSON(500, map[string]any{"message": "S3 source deletion failed", "error": err.Error()})
|
|
}
|
|
refiled = true
|
|
record.Set("file_key", finalKey)
|
|
}
|
|
for name := range cleared {
|
|
record.Set(name, nil)
|
|
}
|
|
for name, v := range merged {
|
|
record.Set(name, v)
|
|
}
|
|
record.Set("status", "validated")
|
|
after := SnapshotFromRecord(cfg, record, true)
|
|
|
|
// The record save and the corrected event commit together; on failure
|
|
// the S3 state is restored (US-36).
|
|
txErr := re.App.RunInTransaction(func(tx core.App) error {
|
|
if err := tx.Save(record); err != nil {
|
|
return err
|
|
}
|
|
return AppendEvent(tx, record.Id, "corrected", before, after, nil)
|
|
})
|
|
if txErr != nil {
|
|
if refiled {
|
|
_ = s3.Copy(ctx, finalKey, oldKey)
|
|
_ = s3.Remove(ctx, finalKey)
|
|
}
|
|
return re.JSON(500, map[string]any{"message": "correction failed; everything rolled back", "error": txErr.Error()})
|
|
}
|
|
return re.JSON(200, map[string]any{
|
|
"id": record.Id,
|
|
"file_key": record.GetString("file_key"),
|
|
"status": "validated",
|
|
"template": tplName,
|
|
"rendered": rendered,
|
|
"fields": merged,
|
|
})
|
|
}
|
|
|
|
// recordPages decodes the stored page texts of a document (US-13).
|
|
func recordPages(record *core.Record) []string {
|
|
var pages []string
|
|
if raw := record.GetString("pages"); raw != "" {
|
|
if err := json.Unmarshal([]byte(raw), &pages); err != nil {
|
|
return nil
|
|
}
|
|
}
|
|
return pages
|
|
}
|
|
|
|
// mergeSubmitted converts the submitted values to their native types and
|
|
// merges them over the record values. A null or empty string clears the
|
|
// field. It reports per-field conversion errors, unknown fields, and the
|
|
// cleared names (the caller must unset them on the record).
|
|
func mergeSubmitted(cfg *config.Config, merged map[string]any, submitted map[string]any) (map[string]any, []string, []string, map[string]bool) {
|
|
var convErrs, unknown []string
|
|
cleared := map[string]bool{}
|
|
for name, raw := range submitted {
|
|
f, ok := cfg.Fields[name]
|
|
if !ok {
|
|
unknown = append(unknown, "unknown field: "+name)
|
|
continue
|
|
}
|
|
v, clear, err := convertUserValue(f, raw)
|
|
if err != nil {
|
|
convErrs = append(convErrs, fmt.Sprintf("field %s: %v", name, err))
|
|
continue
|
|
}
|
|
if clear {
|
|
cleared[name] = true
|
|
delete(merged, name)
|
|
continue
|
|
}
|
|
merged[name] = v
|
|
}
|
|
return merged, convErrs, unknown, cleared
|
|
}
|
|
|
|
// convertUserValue converts one submitted JSON value to the declared field
|
|
// type (US-35 mode B).
|
|
func convertUserValue(f config.Field, raw any) (v any, clear bool, err error) {
|
|
if raw == nil {
|
|
return nil, true, nil
|
|
}
|
|
switch f.Type {
|
|
case "text", "enum":
|
|
s, ok := raw.(string)
|
|
if !ok {
|
|
return nil, false, fmt.Errorf("(%s): value is not a string", f.Type)
|
|
}
|
|
if s == "" {
|
|
return nil, true, nil
|
|
}
|
|
return s, false, nil
|
|
case "number":
|
|
switch t := raw.(type) {
|
|
case float64:
|
|
return t, false, nil
|
|
case string:
|
|
if t == "" {
|
|
return nil, true, nil
|
|
}
|
|
n, perr := strconv.ParseFloat(t, 64)
|
|
if perr != nil {
|
|
return nil, false, fmt.Errorf("(number): %q is not numeric", t)
|
|
}
|
|
return n, false, nil
|
|
default:
|
|
return nil, false, fmt.Errorf("(number): value is not numeric")
|
|
}
|
|
case "date":
|
|
s, ok := raw.(string)
|
|
if !ok {
|
|
return nil, false, fmt.Errorf("(date): value is not a string")
|
|
}
|
|
if s == "" {
|
|
return nil, true, nil
|
|
}
|
|
for _, layout := range []string{"2006-01-02", time.RFC3339} {
|
|
if d, perr := time.Parse(layout, s); perr == nil {
|
|
return d, false, nil
|
|
}
|
|
}
|
|
return nil, false, fmt.Errorf("(date): cannot parse %q", s)
|
|
case "boolean":
|
|
b, ok := raw.(bool)
|
|
if !ok {
|
|
return nil, false, fmt.Errorf("(boolean): value is not a boolean")
|
|
}
|
|
return b, false, nil
|
|
}
|
|
return nil, false, fmt.Errorf("unsupported field type %q", f.Type)
|
|
}
|
|
|
|
// evaluateMerged mirrors cfg.Evaluate on an already-built field map: the
|
|
// mandatory check plus the template render against the stored pages. The
|
|
// rendered name is empty when any error remains.
|
|
func evaluateMerged(cfg *config.Config, merged map[string]any, pages []string) (string, string, []string) {
|
|
var errs []string
|
|
for _, f := range cfg.FieldList {
|
|
if f.Mandatory {
|
|
if v, ok := merged[f.Name]; !ok || v == nil {
|
|
errs = append(errs, "mandatory field missing: "+f.Name)
|
|
}
|
|
}
|
|
}
|
|
tplName := templateName(merged)
|
|
rendered := ""
|
|
if tpl, ok := cfg.Templates[tplName]; !ok {
|
|
errs = append(errs, "unknown template: "+tplName)
|
|
} else if r, rerr := tpl.Render(merged, pages); rerr != nil {
|
|
errs = append(errs, rerr.Error())
|
|
} else {
|
|
rendered = r
|
|
}
|
|
if len(errs) > 0 {
|
|
rendered = ""
|
|
}
|
|
return tplName, rendered, errs
|
|
}
|
|
|
|
func templateName(fields map[string]any) string {
|
|
if v, ok := fields["template"].(string); ok && v != "" {
|
|
return v
|
|
}
|
|
return "default"
|
|
}
|
|
|
|
func nonNil(errs []string) []string {
|
|
if errs == nil {
|
|
return []string{}
|
|
}
|
|
return errs
|
|
}
|