Files
relata/internal/ingest/service.go
T
2026-08-11 10:40:10 +01:00

606 lines
18 KiB
Go

// Package ingest accepts information into Relata and drives it through the
// processing pipeline.
//
// The governing rule is that the original is preserved. Whatever a user
// supplies is stored verbatim — bytes for uploads, text for pastes — before any
// parsing is attempted. If parsing later fails, the failure is recorded as a
// warning on the item and the original remains retrievable and reprocessable.
package ingest
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/andydixon/relata/internal/audit"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
"github.com/andydixon/relata/internal/observability"
"github.com/andydixon/relata/internal/parsing"
)
// Request is a single item to ingest.
type Request struct {
Title string
Content []byte
Filename string
// DeclaredMIME is what the client claimed. It is a hint only; the real type
// is determined from the content.
DeclaredMIME string
SourceType domain.SourceType
Metadata map[string]string
// CreatedAt is the user-asserted creation time of the information itself.
CreatedAt *time.Time
// AllowDuplicate must be set explicitly to store content that already
// exists byte-for-byte.
AllowDuplicate bool
}
// Result describes what ingestion produced.
type Result struct {
Item *domain.Item
// Duplicate is the existing item when identical content was found.
Duplicate *domain.Item
// Stored is false when an exact duplicate was refused.
Stored bool
JobID string
}
// Service ingests items.
type Service struct {
items domain.ItemRepository
jobs domain.JobRepository
cfg *config.Config
audit *audit.Recorder
metrics *observability.Metrics
// admit bounds concurrent ingestion so a burst of uploads cannot exhaust
// memory: each in-flight request holds its content in memory.
admit chan struct{}
}
// NewService builds the ingestion service.
func NewService(items domain.ItemRepository, jobRepo domain.JobRepository, cfg *config.Config,
rec *audit.Recorder, m *observability.Metrics) *Service {
n := cfg.Uploads.MaxConcurrentIngest
if n <= 0 {
n = 4
}
return &Service{
items: items,
jobs: jobRepo,
cfg: cfg,
audit: rec,
metrics: m,
admit: make(chan struct{}, n),
}
}
// ValidationError describes why an ingestion request was rejected.
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string { return e.Field + ": " + e.Message }
func (e *ValidationError) Unwrap() error { return domain.ErrInvalidInput }
// DuplicateError reports that identical content already exists.
type DuplicateError struct {
ExistingID string
ExistingTitle string
}
func (e *DuplicateError) Error() string {
if e.ExistingID == "" {
// A race we lost, where the winning item was no longer readable.
return "identical content already exists"
}
return fmt.Sprintf("identical content already exists as item %s (%q)", e.ExistingID, e.ExistingTitle)
}
func (e *DuplicateError) Unwrap() error { return domain.ErrDuplicate }
// Ingest validates, stores and queues an item for processing.
func (s *Service) Ingest(ctx context.Context, p *domain.Principal, req Request) (*Result, error) {
select {
case s.admit <- struct{}{}:
defer func() { <-s.admit }()
case <-ctx.Done():
return nil, ctx.Err()
}
if p == nil || p.WorkspaceID == "" {
return nil, domain.ErrWorkspaceMiss
}
if !p.Role.CanWrite() {
return nil, domain.ErrForbidden
}
item, err := s.build(ctx, p, req)
if err != nil {
s.countIngest(string(req.SourceType), "rejected")
return nil, err
}
// Exact duplicate detection, before anything is written.
existing, err := s.items.FindByContentHash(ctx, p.WorkspaceID, item.ContentHash)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, err
}
if existing != nil {
if !req.AllowDuplicate {
s.countIngest(string(req.SourceType), "duplicate")
return &Result{Duplicate: existing, Stored: false},
&DuplicateError{ExistingID: existing.ID, ExistingTitle: existing.Title}
}
// The user asked for it explicitly; record the link rather than hide it.
item.DuplicateOf = existing.ID
}
// Persist the original bytes for binary formats before the item row, so a
// row never claims a file that does not exist.
if item.MIMEType == "application/pdf" || isDOCX(item.MIMEType) {
path, err := s.storeOriginal(p.WorkspaceID, item.ID, req.Filename, req.Content)
if err != nil {
return nil, fmt.Errorf("store original file: %w", err)
}
item.OriginalStoredPath = path
}
// discard removes the stored original when the item will not be created.
discard := func() {
if item.OriginalStoredPath != "" {
_ = os.Remove(item.OriginalStoredPath)
item.OriginalStoredPath = ""
}
}
if err := s.items.Create(ctx, item); err != nil {
// A concurrent request stored identical content between the check above
// and this insert: the unique index on content_hash caught what the
// check could not.
if !errors.Is(err, domain.ErrDuplicate) {
discard()
return nil, err
}
winner, lookupErr := s.items.FindByContentHash(ctx, p.WorkspaceID, item.ContentHash)
if lookupErr != nil || winner == nil {
// The winner is not readable — deleted in the interim, or the
// lookup failed. Still a duplicate, just without a name to give.
discard()
s.countIngest(string(req.SourceType), "duplicate")
return &Result{Stored: false}, &DuplicateError{}
}
if req.AllowDuplicate {
// The caller asked for a second copy to be kept. Losing the race
// does not change that answer — it only reveals which item this one
// is a copy of. Recording that link puts the item outside the
// uniqueness constraint, so the retry succeeds.
item.DuplicateOf = winner.ID
if err := s.items.Create(ctx, item); err != nil {
discard()
return nil, err
}
} else {
discard()
s.countIngest(string(req.SourceType), "duplicate")
return &Result{Duplicate: winner, Stored: false},
&DuplicateError{ExistingID: winner.ID, ExistingTitle: winner.Title}
}
}
job := &domain.Job{
ID: ids.New(),
WorkspaceID: p.WorkspaceID,
Type: domain.JobParseItem,
Status: domain.JobPending,
ItemID: item.ID,
Priority: 10,
MaxAttempts: s.cfg.Workers.MaxAttempts,
IdempotencyKey: ids.Deterministic(p.WorkspaceID, item.ID, string(domain.JobParseItem), "initial"),
}
if err := s.jobs.Enqueue(ctx, job); err != nil {
// The item exists and is recoverable; surface the queueing failure
// rather than pretending processing has started.
return &Result{Item: item, Stored: true}, fmt.Errorf("queue item for processing: %w", err)
}
s.audit.Record(ctx, p, domain.AuditItemCreated, "item", item.ID, "success", map[string]string{
"source_type": string(item.SourceType),
"mime_type": item.MIMEType,
"size_bytes": fmt.Sprint(item.SizeBytes),
"duplicate": boolStr(item.DuplicateOf != ""),
})
s.countIngest(string(item.SourceType), "accepted")
return &Result{Item: item, Stored: true, JobID: job.ID, Duplicate: existing}, nil
}
func (s *Service) countIngest(sourceType, outcome string) {
if s.metrics != nil {
if sourceType == "" {
sourceType = "unknown"
}
s.metrics.IngestItems.WithLabelValues(sourceType, outcome).Inc()
}
}
// build validates a request and constructs the item record.
func (s *Service) build(ctx context.Context, p *domain.Principal, req Request) (*domain.Item, error) {
lim := s.cfg.Uploads
if len(req.Content) == 0 {
return nil, &ValidationError{Field: "content", Message: "no content was supplied"}
}
if int64(len(req.Content)) > lim.MaxUploadBytes {
return nil, &ValidationError{
Field: "content",
Message: fmt.Sprintf("content is %d bytes, larger than the %d byte limit", len(req.Content), lim.MaxUploadBytes),
}
}
// Determine the real type from the bytes. A client's declared type and the
// filename are hints; neither is trusted.
sniff, err := parsing.Sniff(req.Content, req.DeclaredMIME, req.Filename)
switch {
case errors.Is(err, parsing.ErrArchiveRejected):
return nil, &ValidationError{
Field: "content",
Message: "archive files are not accepted. Please upload the documents individually.",
}
case errors.Is(err, parsing.ErrExecutableRejected):
return nil, &ValidationError{
Field: "content",
Message: "executable content is not accepted.",
}
case err != nil:
return nil, &ValidationError{
Field: "content",
Message: fmt.Sprintf("this file type is not supported. Relata accepts plain text, Markdown, JSON, PDF and DOCX (detected: %s)",
sniff.MIME),
}
}
if !parsing.Supported(sniff.MIME) {
return nil, &ValidationError{
Field: "content",
Message: "this file type is not supported. Relata accepts plain text, Markdown, JSON, PDF and DOCX.",
}
}
title, err := s.cleanTitle(req.Title, req.Filename)
if err != nil {
return nil, err
}
meta, err := s.cleanMetadata(req.Metadata)
if err != nil {
return nil, err
}
sourceType := req.SourceType
if sourceType == "" {
sourceType = inferSourceType(sniff.MIME)
}
if !sourceType.Valid() {
return nil, &ValidationError{Field: "source_type", Message: "unrecognised source type"}
}
now := time.Now().UTC()
created := now
if req.CreatedAt != nil {
created = req.CreatedAt.UTC()
// A creation date far in the future is almost certainly a mistake and
// would distort every temporal signal computed from it.
if created.After(now.Add(24 * time.Hour)) {
return nil, &ValidationError{Field: "created_at", Message: "the creation date cannot be in the future"}
}
}
sum := sha256.Sum256(req.Content)
item := &domain.Item{
ID: ids.New(),
WorkspaceID: p.WorkspaceID,
Title: title,
SourceType: sourceType,
MIMEType: sniff.MIME,
SizeBytes: int64(len(req.Content)),
ContentHash: hex.EncodeToString(sum[:]),
Metadata: meta,
CreatedAt: created,
IngestedAt: now,
UpdatedAt: now,
Status: domain.StatusPending,
CreatedBy: p.UserID,
}
if fn := SanitiseFilename(req.Filename); fn != "" {
item.OriginalFilename = fn
}
// Text-shaped content is kept inline; binary content is kept on disk.
if isTextual(sniff.MIME) {
if !utf8.Valid(req.Content) {
return nil, &ValidationError{Field: "content", Message: "text content is not valid UTF-8"}
}
item.OriginalText = string(req.Content)
}
if sniff.Declared != "" && sniff.FromContent && sniff.Declared != sniff.MIME {
// Worth recording: it may be innocent, or it may be an attempt to
// smuggle one format past a check for another.
item.Warnings = append(item.Warnings, domain.ParseWarning{
Code: "mime_mismatch",
Message: "The file's actual type differs from the type the browser reported. Relata used the type detected from the file's contents.",
Detail: fmt.Sprintf("declared=%s detected=%s", sniff.Declared, sniff.MIME),
})
}
observability.LoggerFrom(ctx).Info("item accepted for ingestion",
"item_id", item.ID, "mime_type", item.MIMEType, "size_bytes", item.SizeBytes,
"source_type", string(item.SourceType))
return item, nil
}
func (s *Service) cleanTitle(title, filename string) (string, error) {
t := strings.TrimSpace(title)
if t == "" {
t = strings.TrimSpace(SanitiseFilename(filename))
}
if t == "" {
t = "Untitled item"
}
t = stripControl(t)
if utf8.RuneCountInString(t) > s.cfg.Uploads.MaxTitleLen {
return "", &ValidationError{
Field: "title",
Message: fmt.Sprintf("title must be at most %d characters", s.cfg.Uploads.MaxTitleLen),
}
}
return t, nil
}
// metadataKeyPattern restricts metadata keys to a safe, predictable shape.
//
// This is not cosmetic: keys become part of stored documents and are displayed
// and compared. Restricting them keeps a key from resembling a MongoDB operator
// or carrying markup.
var metadataKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.\-]{0,63}$`)
func (s *Service) cleanMetadata(in map[string]string) (map[string]string, error) {
if len(in) == 0 {
return nil, nil
}
lim := s.cfg.Uploads
if len(in) > lim.MaxMetadataKeys {
return nil, &ValidationError{
Field: "metadata",
Message: fmt.Sprintf("at most %d metadata entries are allowed", lim.MaxMetadataKeys),
}
}
out := make(map[string]string, len(in))
for k, v := range in {
key := strings.TrimSpace(k)
if !metadataKeyPattern.MatchString(key) {
return nil, &ValidationError{
Field: "metadata",
Message: fmt.Sprintf("metadata key %q is invalid; use letters, digits, dot, dash or underscore", truncate(key, 40)),
}
}
// A key beginning with $ or containing a dot has special meaning to
// MongoDB. The pattern above already excludes a leading $; dots are
// permitted in the key text but the value is always stored as a plain
// string inside a map, never interpreted as a path.
val := stripControl(v)
if len(val) > lim.MaxMetadataValueLen {
return nil, &ValidationError{
Field: "metadata",
Message: fmt.Sprintf("metadata value for %q exceeds %d characters", key, lim.MaxMetadataValueLen),
}
}
out[key] = val
}
return out, nil
}
// storeOriginal writes uploaded bytes to disk.
//
// The stored name is derived from the item's own UUID, never from the
// user-supplied filename, so a crafted name cannot influence the path at all.
// Files are written without any execute permission and the directory layout is
// per-workspace.
func (s *Service) storeOriginal(workspaceID, itemID, filename string, content []byte) (string, error) {
if !ids.Valid(itemID) || !ids.Valid(workspaceID) {
return "", fmt.Errorf("refusing to store a file under a non-UUID identifier")
}
dir := filepath.Join(s.cfg.Uploads.Dir, workspaceID)
if err := os.MkdirAll(dir, 0o750); err != nil {
return "", err
}
// The extension is taken from a fixed allow-list keyed on the *detected*
// type, not from the filename, so ".php" or ".sh" can never appear.
ext := ""
switch {
case strings.HasSuffix(strings.ToLower(SanitiseFilename(filename)), ".pdf"):
ext = ".pdf"
case strings.HasSuffix(strings.ToLower(SanitiseFilename(filename)), ".docx"):
ext = ".docx"
}
path := filepath.Join(dir, itemID+ext)
// Belt and braces: confirm the final path is inside the upload directory.
absDir, err := filepath.Abs(s.cfg.Uploads.Dir)
if err != nil {
return "", err
}
absPath, err := filepath.Abs(path)
if err != nil {
return "", err
}
if !strings.HasPrefix(absPath, absDir+string(os.PathSeparator)) {
return "", fmt.Errorf("refusing to write outside the upload directory")
}
// 0600: readable and writable by the service account only, never executable.
if err := os.WriteFile(absPath, content, 0o600); err != nil {
return "", err
}
return absPath, nil
}
// ReadOriginal returns the stored bytes of an uploaded file.
func (s *Service) ReadOriginal(item *domain.Item) ([]byte, error) {
if item.OriginalStoredPath == "" {
if item.OriginalText != "" {
return []byte(item.OriginalText), nil
}
return nil, domain.ErrNotFound
}
absDir, err := filepath.Abs(s.cfg.Uploads.Dir)
if err != nil {
return nil, err
}
// Re-check containment on read: the stored path came from the database,
// which is trusted, but a cheap check costs nothing and bounds the damage
// from a corrupted record.
if !strings.HasPrefix(item.OriginalStoredPath, absDir+string(os.PathSeparator)) {
return nil, fmt.Errorf("stored file path is outside the upload directory")
}
return os.ReadFile(item.OriginalStoredPath)
}
// DeleteOriginal removes an item's stored file.
func (s *Service) DeleteOriginal(item *domain.Item) error {
if item.OriginalStoredPath == "" {
return nil
}
absDir, err := filepath.Abs(s.cfg.Uploads.Dir)
if err != nil {
return err
}
if !strings.HasPrefix(item.OriginalStoredPath, absDir+string(os.PathSeparator)) {
return fmt.Errorf("refusing to delete outside the upload directory")
}
if err := os.Remove(item.OriginalStoredPath); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}
// SanitiseFilename reduces a user-supplied filename to a safe display name.
//
// Only the base name survives, so "../../etc/passwd" becomes "passwd". Path
// separators, control characters, leading dots and Windows reserved names are
// all removed. The result is used for display and for choosing an extension —
// never as a path component on its own.
func SanitiseFilename(name string) string {
if name == "" {
return ""
}
// Handle both separators: a Windows client may send backslashes.
name = strings.ReplaceAll(name, "\\", "/")
name = filepath.Base(name)
// filepath.Base returns "." or "/" for degenerate input.
if name == "." || name == ".." || name == "/" {
return ""
}
name = strings.Map(func(r rune) rune {
switch {
case r < 0x20 || r == 0x7f:
return -1
case r == '/' || r == '\\' || r == ':' || r == '*' || r == '?' ||
r == '"' || r == '<' || r == '>' || r == '|' || r == 0:
return '_'
}
return r
}, name)
// A leading dot would create a hidden file and can hide the real extension.
name = strings.TrimLeft(name, ". ")
name = strings.TrimRight(name, ". ")
// Windows reserved device names, which can cause surprising behaviour if
// the file is ever copied to such a system.
base := name
if i := strings.IndexByte(name, '.'); i > 0 {
base = name[:i]
}
switch strings.ToUpper(base) {
case "CON", "PRN", "AUX", "NUL",
"COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9",
"LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9":
name = "file_" + name
}
if len(name) > 200 {
name = name[:200]
}
return name
}
func inferSourceType(mime string) domain.SourceType {
switch mime {
case "application/json":
return domain.SourceTypeJSON
case "text/markdown", "text/x-markdown":
return domain.SourceTypeMarkdown
case "application/pdf", "application/vnd.openxmlformats-officedocument.wordprocessingml.document":
return domain.SourceTypeDocument
default:
return domain.SourceTypeNote
}
}
func isTextual(mime string) bool {
switch mime {
case "text/plain", "text/markdown", "text/x-markdown", "application/json", "text/json", "text/csv":
return true
}
return false
}
func isDOCX(mime string) bool {
return mime == "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
}
func stripControl(s string) string {
return strings.Map(func(r rune) rune {
if r < 0x20 && r != '\n' && r != '\t' || r == 0x7f {
return -1
}
return r
}, strings.TrimSpace(s))
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
for n > 0 && !utf8.RuneStart(s[n]) {
n--
}
return s[:n] + "…"
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}