CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Failing after 1m4s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 4s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 25s
CI / Web (dms-browser) (push) Failing after 1m31s
CI / Web (pdf-viewer) (push) Failing after 1m2s
CI / Web (shl) (push) Failing after 12s
Connect-Schnittstellen-Vertragstests / contract-tests (push) Successful in 10m0s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 45s
CI / Core-Modul (Build/Vet/Test) (pull_request) Failing after 1m4s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 3s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 25s
CI / Web (dms-browser) (pull_request) Failing after 1m33s
CI / Web (pdf-viewer) (pull_request) Failing after 1m4s
CI / Web (shl) (pull_request) Failing after 15s
Connect-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 11m18s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 10m25s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 1m0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 2m4s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 3m28s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 10m55s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 3s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 3s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
auditclient und policyclient aus der Liste in check-security-test-coverage.sh entfernt (Pakete liegen jetzt in common, dort durch das common-Gate gedeckt). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
189 lines
6.0 KiB
Go
189 lines
6.0 KiB
Go
// Package dms2importapi is the HTTP binding for DMS2-02's import
|
|
// assistant: upload a third-party export, get a field-mapping preview
|
|
// back, adjust it, then execute the import and see the result. Identity
|
|
// comes from X-User-Id/X-Tenant-Slug, the same transitional pattern
|
|
// connect/internal/statusapi already uses.
|
|
package dms2importapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/connect/internal/dms2import"
|
|
"gitea.perlbach24.de/scripte/nexarch/connect/internal/fieldmapping"
|
|
)
|
|
|
|
// connectorName/instance identify the field mapping saved for this
|
|
// assistant in fieldmapping.Store — one mapping per tenant, since a
|
|
// tenant realistically migrates from one third-party system at a time.
|
|
const (
|
|
connectorName = "dms2_import"
|
|
connectorInstanceID = "paperless_ngx"
|
|
maxUploadBytes int64 = 200 << 20 // 200 MiB: generous for a test/migration export, not unbounded
|
|
)
|
|
|
|
type Server struct {
|
|
mappings *fieldmapping.Store
|
|
runs *dms2import.Store
|
|
}
|
|
|
|
func NewServer(mappings *fieldmapping.Store, runs *dms2import.Store) *Server {
|
|
return &Server{mappings: mappings, runs: runs}
|
|
}
|
|
|
|
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("POST /dms2/import/preview", s.requireIdentity(s.handlePreview))
|
|
mux.HandleFunc("POST /dms2/import/execute", s.requireIdentity(s.handleExecute))
|
|
mux.HandleFunc("GET /dms2/import/runs/{id}", s.requireIdentity(s.handleGetRun))
|
|
}
|
|
|
|
func (s *Server) requireIdentity(next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Header.Get("X-User-Id") == "" {
|
|
writeError(w, http.StatusUnauthorized, "X-User-Id fehlt")
|
|
return
|
|
}
|
|
tenant := r.Header.Get("X-Tenant-Slug")
|
|
if tenant == "" {
|
|
writeError(w, http.StatusUnauthorized, "X-Tenant-Slug fehlt")
|
|
return
|
|
}
|
|
next(w, r, tenant)
|
|
}
|
|
}
|
|
|
|
type previewResponse struct {
|
|
DocumentCount int `json:"document_count"`
|
|
AvailableFields []string `json:"available_fields"`
|
|
SuggestedMapping []fieldmapping.Entry `json:"suggested_mapping"`
|
|
Schema []fieldmapping.FieldDef `json:"schema"`
|
|
}
|
|
|
|
func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request, _ string) {
|
|
data, ok := readUploadedZip(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
docs, err := dms2import.ParsePaperlessExport(data)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
fields := dms2import.DetectFields(docs)
|
|
writeJSON(w, http.StatusOK, previewResponse{
|
|
DocumentCount: len(docs),
|
|
AvailableFields: fields,
|
|
SuggestedMapping: dms2import.SuggestMapping(fields),
|
|
Schema: dms2import.Schema(),
|
|
})
|
|
}
|
|
|
|
type executeResponse struct {
|
|
RunID string `json:"run_id"`
|
|
DocumentCount int `json:"document_count"`
|
|
SuccessCount int `json:"success_count"`
|
|
ErrorCount int `json:"error_count"`
|
|
Errors []string `json:"errors"`
|
|
}
|
|
|
|
func (s *Server) handleExecute(w http.ResponseWriter, r *http.Request, tenantSlug string) {
|
|
data, ok := readUploadedZip(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
var entries []fieldmapping.Entry
|
|
if err := json.Unmarshal([]byte(r.FormValue("mapping")), &entries); err != nil {
|
|
writeError(w, http.StatusBadRequest, "mapping ist kein gueltiges JSON-Feld-Array")
|
|
return
|
|
}
|
|
|
|
schema := dms2import.Schema()
|
|
saved, err := s.mappings.Save(r.Context(), fieldmapping.Ref{
|
|
TenantSlug: tenantSlug, ConnectorName: connectorName, ConnectorInstanceID: connectorInstanceID,
|
|
}, entries, schema)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
if err := fieldmapping.ValidateForExecution(saved, schema); err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
docs, err := dms2import.ParsePaperlessExport(data)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
bundles, buildErrs := dms2import.BuildBundles(docs, saved)
|
|
|
|
run, err := s.runs.RecordRun(r.Context(), tenantSlug, dms2import.FormatPaperlessNGX, len(docs), len(bundles), buildErrs, saved.Version)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "import-lauf konnte nicht gespeichert werden")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, executeResponse{
|
|
RunID: run.ID,
|
|
DocumentCount: run.DocumentCount,
|
|
SuccessCount: run.SuccessCount,
|
|
ErrorCount: run.ErrorCount,
|
|
Errors: run.Errors,
|
|
})
|
|
}
|
|
|
|
func (s *Server) handleGetRun(w http.ResponseWriter, r *http.Request, tenantSlug string) {
|
|
id := r.PathValue("id")
|
|
run, err := s.runs.GetRun(r.Context(), tenantSlug, id)
|
|
if err != nil {
|
|
if err == dms2import.ErrRunNotFound {
|
|
writeError(w, http.StatusNotFound, "import-lauf nicht gefunden")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "import-lauf konnte nicht gelesen werden")
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, executeResponse{
|
|
RunID: run.ID,
|
|
DocumentCount: run.DocumentCount,
|
|
SuccessCount: run.SuccessCount,
|
|
ErrorCount: run.ErrorCount,
|
|
Errors: run.Errors,
|
|
})
|
|
}
|
|
|
|
func readUploadedZip(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
|
|
r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes)
|
|
if err := r.ParseMultipartForm(32 << 20); err != nil {
|
|
writeError(w, http.StatusBadRequest, "multipart-anfrage konnte nicht gelesen werden (evtl. zu gross)")
|
|
return nil, false
|
|
}
|
|
file, header, err := r.FormFile("file")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "feld 'file' fehlt")
|
|
return nil, false
|
|
}
|
|
defer file.Close()
|
|
if !strings.HasSuffix(strings.ToLower(header.Filename), ".zip") {
|
|
writeError(w, http.StatusBadRequest, "nur .zip-Exporte werden unterstuetzt")
|
|
return nil, false
|
|
}
|
|
data, err := io.ReadAll(file)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "datei konnte nicht gelesen werden")
|
|
return nil, false
|
|
}
|
|
return data, true
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
_ = json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
func writeError(w http.ResponseWriter, status int, msg string) {
|
|
writeJSON(w, status, map[string]string{"error": msg})
|
|
}
|