Code sync, security fixes

This commit is contained in:
Andy Dixon
2026-08-11 10:40:10 +01:00
parent 4e00deedf8
commit 53dbc28383
141 changed files with 36194 additions and 0 deletions
+185
View File
@@ -0,0 +1,185 @@
package ingest
import (
"strings"
"testing"
)
func TestSanitiseFilenameStripsPathTraversal(t *testing.T) {
cases := map[string]string{
"../../../etc/passwd": "passwd",
"..\\..\\..\\windows\\system32\\cmd.exe": "cmd.exe",
"/etc/shadow": "shadow",
"foo/../../bar.txt": "bar.txt",
"normal.pdf": "normal.pdf",
"with spaces.docx": "with spaces.docx",
"unicode-ünïcödé.txt": "unicode-ünïcödé.txt",
}
for in, want := range cases {
if got := SanitiseFilename(in); got != want {
t.Errorf("SanitiseFilename(%q) = %q, want %q", in, got, want)
}
}
}
func TestSanitiseFilenameRemovesDangerousCharacters(t *testing.T) {
hostile := []string{
"file\x00.txt", "file\nname.txt", "file\rname.txt",
"a:b.txt", "a*b.txt", "a?b.txt", `a"b.txt`, "a<b>.txt", "a|b.txt",
}
for _, in := range hostile {
got := SanitiseFilename(in)
for _, bad := range []string{"\x00", "\n", "\r", "/", "\\", ":", "*", "?", `"`, "<", ">", "|"} {
if strings.Contains(got, bad) {
t.Errorf("SanitiseFilename(%q) = %q, which still contains %q", in, got, bad)
}
}
}
}
func TestSanitiseFilenameRefusesHiddenAndReservedNames(t *testing.T) {
// A leading dot creates a hidden file and can conceal the real extension.
for _, in := range []string{".hidden", "...", ". leading space.txt"} {
if got := SanitiseFilename(in); strings.HasPrefix(got, ".") {
t.Errorf("SanitiseFilename(%q) = %q, still hidden", in, got)
}
}
// Windows device names cause surprising behaviour if the file is ever
// copied to such a system.
for _, in := range []string{"CON", "PRN", "AUX", "NUL", "COM1", "LPT1", "con.txt", "nul.pdf"} {
got := SanitiseFilename(in)
if strings.EqualFold(got, in) {
t.Errorf("reserved device name %q was passed through unchanged", in)
}
}
}
func TestSanitiseFilenameHandlesDegenerateInput(t *testing.T) {
for _, in := range []string{"", ".", "..", "/", "//", " ", "...."} {
got := SanitiseFilename(in)
if strings.ContainsAny(got, "/\\") || got == ".." || got == "." {
t.Errorf("SanitiseFilename(%q) = %q, which is not a safe base name", in, got)
}
}
}
func TestSanitiseFilenameIsBounded(t *testing.T) {
long := strings.Repeat("a", 5000) + ".txt"
if got := SanitiseFilename(long); len(got) > 200 {
t.Fatalf("a %d-character name produced %d characters", len(long), len(got))
}
}
func TestSanitiseFilenameIsIdempotent(t *testing.T) {
for _, in := range []string{"../../etc/passwd", "CON", ".hidden", "normal.pdf", "a:b*c.txt"} {
once := SanitiseFilename(in)
if twice := SanitiseFilename(once); once != twice {
t.Errorf("not idempotent for %q: %q then %q", in, once, twice)
}
}
}
func FuzzSanitiseFilename(f *testing.F) {
f.Add("../../etc/passwd")
f.Add("normal.pdf")
f.Add("CON")
f.Add("\x00\xff")
f.Add(strings.Repeat("../", 100) + "x")
f.Fuzz(func(t *testing.T, name string) {
got := SanitiseFilename(name)
// Whatever the input, the result must be usable as a bare base name.
for _, forbidden := range []string{"/", "\\", "\x00"} {
if strings.Contains(got, forbidden) {
t.Fatalf("SanitiseFilename(%q) = %q, containing %q", name, got, forbidden)
}
}
if got == "." || got == ".." || strings.HasPrefix(got, ".") {
t.Fatalf("SanitiseFilename(%q) = %q, which is a relative or hidden path", name, got)
}
if len(got) > 200 {
t.Fatalf("SanitiseFilename(%q) produced %d characters", name, len(got))
}
})
}
func TestStripControlRemovesInvisibleCharacters(t *testing.T) {
got := stripControl(" hello\x00\x07world\x1b ")
if strings.ContainsAny(got, "\x00\x07\x1b") {
t.Fatalf("control characters survived: %q", got)
}
if got != "helloworld" {
t.Fatalf("stripControl produced %q", got)
}
// Newlines and tabs are legitimate in a pasted note and must survive.
if stripControl("line one\nline two\tindented") != "line one\nline two\tindented" {
t.Fatal("stripControl removed a legitimate newline or tab")
}
}
func TestMetadataKeyPatternRejectsOperatorShapedKeys(t *testing.T) {
valid := []string{"project", "department", "a", "a1", "a.b", "a-b", "a_b", strings.Repeat("k", 64)}
for _, k := range valid {
if !metadataKeyPattern.MatchString(k) {
t.Errorf("valid metadata key %q was rejected", k)
}
}
// A key MongoDB would treat specially, or that carries markup, must not
// reach storage.
invalid := []string{
"", "$ne", "$where", "$gt", ".leading", "-leading", "_leading",
"has space", "has\nnewline", "<script>", "a/b", "a\\b",
strings.Repeat("k", 65), "键", "a$b",
}
for _, k := range invalid {
if metadataKeyPattern.MatchString(k) {
t.Errorf("invalid metadata key %q was accepted", k)
}
}
}
func TestSourceTypeInference(t *testing.T) {
cases := map[string]string{
"application/json": "json",
"text/markdown": "markdown",
"application/pdf": "document",
"text/plain": "note",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "document",
}
for mime, want := range cases {
if got := string(inferSourceType(mime)); got != want {
t.Errorf("inferSourceType(%q) = %q, want %q", mime, got, want)
}
}
}
func TestTextualTypesAreStoredInline(t *testing.T) {
// Text is kept on the item; binary formats go to disk. Getting this wrong
// would either lose content or put arbitrary bytes in a database field.
for _, mime := range []string{"text/plain", "text/markdown", "application/json"} {
if !isTextual(mime) {
t.Errorf("%q should be stored inline", mime)
}
}
for _, mime := range []string{"application/pdf",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"} {
if isTextual(mime) {
t.Errorf("%q should be stored on disk, not inline", mime)
}
}
}
func TestTruncateRespectsRuneBoundaries(t *testing.T) {
s := strings.Repeat("é", 100) // two bytes each
got := truncate(s, 15)
for _, r := range got {
if r == '�' {
t.Fatalf("truncation split a rune: %q", got)
}
}
if len(got) > 20 {
t.Fatalf("truncate produced %d bytes for a limit of 15", len(got))
}
}
+379
View File
@@ -0,0 +1,379 @@
package ingest
import (
"context"
"errors"
"fmt"
"time"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/ids"
"github.com/andydixon/relata/internal/observability"
"github.com/andydixon/relata/internal/parsing"
"github.com/andydixon/relata/internal/relationships"
)
// DeletionPlan describes what deleting an item would remove.
//
// It is produced by the dry-run API so a user can see the blast radius before
// committing — particularly the entities that exist only because of this item
// and would therefore disappear with it.
type DeletionPlan struct {
ItemID string `json:"item_id"`
Title string `json:"title"`
Chunks int `json:"chunks"`
Vectors int64 `json:"vectors"`
Claims int `json:"claims"`
Relationships int `json:"relationships"`
OrphanedEntities []string `json:"orphaned_entities"`
SharedEntities []string `json:"shared_entities_retained"`
OriginalFile string `json:"original_file,omitempty"`
Jobs int `json:"queued_jobs"`
}
// PlanDeletion reports what would be removed, without changing anything.
func (p *Pipeline) PlanDeletion(ctx context.Context, workspaceID, itemID string) (*DeletionPlan, error) {
item, err := p.repos.Items.Get(ctx, workspaceID, itemID)
if err != nil {
return nil, err
}
plan := &DeletionPlan{ItemID: item.ID, Title: item.Title}
chunks, err := p.repos.Chunks.ListByItem(ctx, workspaceID, itemID)
if err != nil {
return nil, err
}
plan.Chunks = len(chunks)
plan.Vectors = int64(len(chunks))
claims, err := p.repos.Claims.ListByItem(ctx, workspaceID, itemID)
if err != nil {
return nil, err
}
plan.Claims = len(claims)
out, in, err := p.repos.Rels.ListForNode(ctx, workspaceID, domain.NodeItem, itemID, domain.RelationshipFilter{})
if err != nil {
return nil, err
}
plan.Relationships = len(out) + len(in)
ents, err := p.repos.Entities.ListByItem(ctx, workspaceID, itemID)
if err != nil {
return nil, err
}
for _, e := range ents {
// An entity referenced only by this item has no evidence left once the
// item is gone; one referenced elsewhere survives.
if len(e.ItemIDs) <= 1 {
plan.OrphanedEntities = append(plan.OrphanedEntities, e.CanonicalName)
} else {
plan.SharedEntities = append(plan.SharedEntities, e.CanonicalName)
}
}
if item.OriginalStoredPath != "" {
plan.OriginalFile = item.OriginalFilename
}
return plan, nil
}
// RequestDeletion queues an item for deletion and tombstones it immediately.
//
// Tombstoning first means the item stops appearing in search and on the
// dashboard straight away, even though the derived records are cleaned up
// asynchronously. The user sees the deletion take effect at once.
func (p *Pipeline) RequestDeletion(ctx context.Context, pr *domain.Principal, itemID string) (string, error) {
if pr == nil || !pr.Role.CanWrite() {
return "", domain.ErrForbidden
}
item, err := p.repos.Items.Get(ctx, pr.WorkspaceID, itemID)
if err != nil {
return "", err
}
if err := p.repos.Items.Tombstone(ctx, pr.WorkspaceID, item.ID); err != nil {
return "", err
}
job := &domain.Job{
ID: ids.New(),
WorkspaceID: pr.WorkspaceID,
Type: domain.JobDeleteItemData,
Status: domain.JobPending,
ItemID: item.ID,
Priority: 20, // deletion outranks analysis
MaxAttempts: p.cfg.Workers.MaxAttempts,
Payload: map[string]string{"actor": pr.UserID},
IdempotencyKey: ids.Deterministic(pr.WorkspaceID, item.ID, string(domain.JobDeleteItemData)),
}
if err := p.repos.Jobs.Enqueue(ctx, job); err != nil {
return "", err
}
p.audit.Record(ctx, pr, domain.AuditItemDeleted, "item", item.ID, "requested", map[string]string{
"title": item.Title,
})
return job.ID, nil
}
// HandleDelete removes an item's derived records and vectors.
//
// The order matters: vectors go first, because an orphaned vector is the one
// artefact that nothing else would ever clean up. Everything after it is
// reachable from MongoDB and could be swept later if a step failed.
func (p *Pipeline) HandleDelete(ctx context.Context, j *domain.Job) error {
log := observability.LoggerFrom(ctx).With("item_id", j.ItemID)
// Deletion is by filter, so it removes vectors even for chunks that are
// already gone from MongoDB.
if err := p.vectors.DeleteByItem(ctx, j.WorkspaceID, j.ItemID); err != nil {
return fmt.Errorf("delete vectors: %w", err)
}
if err := p.repos.Chunks.DeleteByItem(ctx, j.WorkspaceID, j.ItemID); err != nil {
return fmt.Errorf("delete chunks: %w", err)
}
if err := p.repos.Claims.DeleteByItem(ctx, j.WorkspaceID, j.ItemID); err != nil {
return fmt.Errorf("delete claims: %w", err)
}
// Strip this item from every entity, then remove entities left with no
// evidence at all. An entity still referenced elsewhere is retained.
orphans, err := p.repos.Entities.RemoveItemRefs(ctx, j.WorkspaceID, j.ItemID)
if err != nil {
return fmt.Errorf("update entity references: %w", err)
}
for _, id := range orphans {
if err := p.repos.Aliases.DeleteByEntity(ctx, j.WorkspaceID, id); err != nil {
log.Warn("could not delete aliases for an orphaned entity", "entity_id", id, "error", err.Error())
}
}
if len(orphans) > 0 {
if err := p.repos.Entities.DeleteMany(ctx, j.WorkspaceID, orphans); err != nil {
return fmt.Errorf("delete orphaned entities: %w", err)
}
}
relCount, err := p.repos.Rels.DeleteForItem(ctx, j.WorkspaceID, j.ItemID)
if err != nil {
return fmt.Errorf("delete relationships: %w", err)
}
if _, err := p.repos.Jobs.DeleteForItem(ctx, j.WorkspaceID, j.ItemID); err != nil {
log.Warn("could not remove queued jobs for the deleted item", "error", err.Error())
}
// Remove the stored original, then the item record itself.
item, err := p.repos.Items.Get(ctx, j.WorkspaceID, j.ItemID)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return err
}
if item != nil {
if err := p.ingest.DeleteOriginal(item); err != nil {
log.Warn("could not delete the stored original file", "error", err.Error())
}
if err := p.repos.Items.HardDelete(ctx, j.WorkspaceID, j.ItemID); err != nil && !errors.Is(err, domain.ErrNotFound) {
return err
}
}
log.Info("deleted item and its derived records",
"orphaned_entities", len(orphans), "relationships", relCount)
return nil
}
// RequestReprocess queues an item to be processed again.
//
// Reprocessing replaces derived records rather than adding to them: chunks and
// claims are swapped wholesale, entity observations merge by exact key, and
// relationships upsert on a dedupe key that leaves human review decisions
// untouched. That is what keeps a reprocess from multiplying data.
func (p *Pipeline) RequestReprocess(ctx context.Context, pr *domain.Principal, itemID, reason string) (string, error) {
if pr == nil || !pr.Role.CanWrite() {
return "", domain.ErrForbidden
}
item, err := p.repos.Items.Get(ctx, pr.WorkspaceID, itemID)
if err != nil {
return "", err
}
if item.Deleted {
return "", domain.ErrNotFound
}
runID := ids.New()
run := &domain.ProcessingRun{
ID: runID,
WorkspaceID: item.WorkspaceID,
ItemID: item.ID,
Reason: reasonOr(reason, "manual reprocess"),
StartedAt: time.Now().UTC(),
ParserVersion: parsing.Version,
EmbeddingModel: p.embedder.ModelID(),
AnalysisModel: p.analysis.ModelID(),
ScoreVersion: relationships.ScoreVersion,
}
if err := p.repos.Runs.Create(ctx, run); err != nil {
observability.LoggerFrom(ctx).Warn("could not open a processing run", "error", err.Error())
}
job := &domain.Job{
ID: ids.New(),
WorkspaceID: pr.WorkspaceID,
Type: domain.JobParseItem,
Status: domain.JobPending,
ItemID: item.ID,
RunID: runID,
Priority: 12,
MaxAttempts: p.cfg.Workers.MaxAttempts,
Payload: map[string]string{"reason": run.Reason},
// The run ID is part of the key, so a reprocess is never suppressed as
// a duplicate of the original ingest.
IdempotencyKey: ids.Deterministic(pr.WorkspaceID, item.ID, string(domain.JobParseItem), runID),
}
if err := p.repos.Jobs.Enqueue(ctx, job); err != nil {
return "", err
}
p.audit.Record(ctx, pr, domain.AuditItemReprocessed, "item", item.ID, "requested", map[string]string{
"reason": run.Reason,
"parser_version": parsing.Version,
"embedding_model": p.embedder.ModelID(),
"score_version": relationships.ScoreVersion,
})
return job.ID, nil
}
// HandleReprocess is the entry point for a queued reprocess request; it simply
// restarts the chain at parsing.
func (p *Pipeline) HandleReprocess(ctx context.Context, j *domain.Job) error {
return p.HandleParse(ctx, j)
}
// HandleReembedWorkspace re-embeds chunks whose vectors were produced by a
// different model.
//
// The job re-enqueues itself while work remains, so a large workspace is
// processed in bounded batches instead of one enormous job that would hold a
// lease for hours.
func (p *Pipeline) HandleReembedWorkspace(ctx context.Context, j *domain.Job) error {
log := observability.LoggerFrom(ctx)
model := p.embedder.ModelID()
if err := p.vectors.EnsureCollection(ctx, p.embedder.Dimensions()); err != nil {
return err
}
const batchSize = 64
chunks, err := p.repos.Chunks.ListUnembedded(ctx, j.WorkspaceID, model, batchSize)
if err != nil {
return err
}
if len(chunks) == 0 {
log.Info("workspace re-embedding complete", "model", model)
return nil
}
// Item metadata is needed for the vector payload.
itemIDs := make([]string, 0, len(chunks))
seen := map[string]bool{}
for _, c := range chunks {
if !seen[c.ItemID] {
seen[c.ItemID] = true
itemIDs = append(itemIDs, c.ItemID)
}
}
items, err := p.repos.Items.GetMany(ctx, j.WorkspaceID, itemIDs)
if err != nil {
return err
}
byID := make(map[string]*domain.Item, len(items))
for _, it := range items {
byID[it.ID] = it
}
texts := make([]string, len(chunks))
for i, c := range chunks {
texts[i] = c.Text
}
release, err := p.pool.AcquireAI(ctx)
if err != nil {
return err
}
vecs, err := p.embedder.Embed(ctx, texts)
release()
if err != nil {
return err
}
if len(vecs) != len(chunks) {
return fmt.Errorf("embedding provider returned %d vectors for %d chunks", len(vecs), len(chunks))
}
points := make([]domain.VectorPoint, 0, len(chunks))
ok := make([]string, 0, len(chunks))
for i, c := range chunks {
it := byID[c.ItemID]
if it == nil {
// The chunk's item is gone; drop the stale chunk rather than
// creating a vector that points at nothing.
continue
}
points = append(points, domain.VectorPoint{
ChunkID: c.ID,
WorkspaceID: c.WorkspaceID,
ItemID: c.ItemID,
SourceType: it.SourceType,
CreatedAt: it.CreatedAt,
EmbeddingModel: model,
Vector: vecs[i],
})
ok = append(ok, c.ID)
}
if err := p.vectors.Upsert(ctx, points); err != nil {
return err
}
if err := p.repos.Chunks.MarkEmbedded(ctx, j.WorkspaceID, ok, model); err != nil {
return err
}
_ = p.repos.Models.Observe(ctx, j.WorkspaceID, "embedding", model, p.embedder.Dimensions())
log.Info("re-embedded a batch", "count", len(ok), "model", model)
// Queue the next batch. A fresh idempotency key is required or the unique
// pending-job index would suppress it.
next := &domain.Job{
ID: ids.New(),
WorkspaceID: j.WorkspaceID,
Type: domain.JobReembedWS,
Status: domain.JobPending,
Priority: 3,
MaxAttempts: p.cfg.Workers.MaxAttempts,
IdempotencyKey: ids.Deterministic(j.WorkspaceID, string(domain.JobReembedWS), ids.New()),
}
return p.repos.Jobs.Enqueue(ctx, next)
}
// RequestReembed queues a workspace-wide re-embedding.
func (p *Pipeline) RequestReembed(ctx context.Context, pr *domain.Principal) (string, error) {
if pr == nil || pr.Role != domain.RoleAdmin {
return "", domain.ErrForbidden
}
job := &domain.Job{
ID: ids.New(),
WorkspaceID: pr.WorkspaceID,
Type: domain.JobReembedWS,
Status: domain.JobPending,
Priority: 3,
MaxAttempts: p.cfg.Workers.MaxAttempts,
IdempotencyKey: ids.Deterministic(pr.WorkspaceID, string(domain.JobReembedWS), "manual"),
}
if err := p.repos.Jobs.Enqueue(ctx, job); err != nil {
return "", err
}
p.audit.Record(ctx, pr, domain.AuditWorkspaceReembed, "workspace", pr.WorkspaceID, "requested",
map[string]string{"embedding_model": p.embedder.ModelID()})
return job.ID, nil
}
+539
View File
@@ -0,0 +1,539 @@
package ingest
import (
"context"
"errors"
"fmt"
"time"
"github.com/andydixon/relata/internal/analysis"
"github.com/andydixon/relata/internal/audit"
"github.com/andydixon/relata/internal/chunking"
"github.com/andydixon/relata/internal/config"
"github.com/andydixon/relata/internal/domain"
"github.com/andydixon/relata/internal/entities"
"github.com/andydixon/relata/internal/ids"
"github.com/andydixon/relata/internal/jobs"
"github.com/andydixon/relata/internal/observability"
"github.com/andydixon/relata/internal/parsing"
"github.com/andydixon/relata/internal/relationships"
)
// Repos bundles the repositories the pipeline writes to.
type Repos struct {
Items domain.ItemRepository
Chunks domain.ChunkRepository
Entities domain.EntityRepository
Aliases domain.AliasRepository
Claims domain.ClaimRepository
Rels domain.RelationshipRepository
Jobs domain.JobRepository
Runs domain.RunRepository
Models domain.ModelVersionRepository
}
// Pipeline executes the processing stages as background jobs.
//
// Each stage is a separate job type so that progress is visible in the queue
// and a failure is attributable to a specific stage. Stages hand off by
// enqueuing the next one, and every stage is idempotent: re-running it replaces
// its own output rather than adding to it.
type Pipeline struct {
repos Repos
vectors domain.VectorStore
embedder domain.Embedder
analysis *analysis.Service
entities *entities.Service
discover *relationships.Discoverer
ingest *Service
cfg *config.Config
pool *jobs.Pool
audit *audit.Recorder
}
// NewPipeline builds the pipeline.
func NewPipeline(repos Repos, vectors domain.VectorStore, embedder domain.Embedder,
an *analysis.Service, ent *entities.Service, disc *relationships.Discoverer,
ing *Service, cfg *config.Config, pool *jobs.Pool, rec *audit.Recorder) *Pipeline {
return &Pipeline{
repos: repos, vectors: vectors, embedder: embedder, analysis: an,
entities: ent, discover: disc, ingest: ing, cfg: cfg, pool: pool, audit: rec,
}
}
// Register wires the handlers into a worker pool.
func (p *Pipeline) Register(pool *jobs.Pool) {
pool.Register(domain.JobParseItem, p.HandleParse)
pool.Register(domain.JobChunkItem, p.HandleChunk)
pool.Register(domain.JobEmbedItem, p.HandleEmbed)
pool.Register(domain.JobExtractEntity, p.HandleExtractEntities)
pool.Register(domain.JobExtractClaims, p.HandleExtractClaims)
pool.Register(domain.JobDiscoverRels, p.HandleDiscoverRelationships)
pool.Register(domain.JobReprocessItem, p.HandleReprocess)
pool.Register(domain.JobDeleteItemData, p.HandleDelete)
pool.Register(domain.JobReembedWS, p.HandleReembedWorkspace)
}
// enqueueNext queues the following stage.
//
// The idempotency key includes the run ID, so a reprocess enqueues a fresh
// chain while a duplicate enqueue within the same run is a no-op.
func (p *Pipeline) enqueueNext(ctx context.Context, j *domain.Job, next domain.JobType, priority int) error {
nextJob := &domain.Job{
ID: ids.New(),
WorkspaceID: j.WorkspaceID,
Type: next,
Status: domain.JobPending,
ItemID: j.ItemID,
Payload: j.Payload,
RunID: j.RunID,
Priority: priority,
MaxAttempts: p.cfg.Workers.MaxAttempts,
IdempotencyKey: ids.Deterministic(j.WorkspaceID, j.ItemID, string(next), j.RunID),
}
return p.repos.Jobs.Enqueue(ctx, nextJob)
}
// loadItem fetches the job's item, treating a missing item as a permanent
// failure — it was deleted while queued, and retrying will not bring it back.
func (p *Pipeline) loadItem(ctx context.Context, j *domain.Job) (*domain.Item, error) {
if j.ItemID == "" {
return nil, fmt.Errorf("%w: job has no item", domain.ErrInvalidInput)
}
item, err := p.repos.Items.Get(ctx, j.WorkspaceID, j.ItemID)
if err != nil {
return nil, err
}
if item.Deleted {
return nil, fmt.Errorf("%w: item has been deleted", domain.ErrNotFound)
}
return item, nil
}
// ---------------------------------------------------------------------------
// Stage 1: parse
// ---------------------------------------------------------------------------
// HandleParse extracts text from the item's original content.
//
// A parse failure does not fail the job in the sense of losing the item: the
// original is retained, the failure is recorded as a warning, and the item is
// marked partial so a user can see what happened and reprocess later.
func (p *Pipeline) HandleParse(ctx context.Context, j *domain.Job) error {
item, err := p.loadItem(ctx, j)
if err != nil {
return err
}
log := observability.LoggerFrom(ctx).With("item_id", item.ID)
if err := p.repos.Items.SetStatus(ctx, item.WorkspaceID, item.ID, domain.StatusParsing, ""); err != nil {
return err
}
// Open a processing run so that what each generation of parser and model
// produced stays on the record.
runID := j.RunID
if runID == "" {
runID = ids.New()
j.RunID = runID
run := &domain.ProcessingRun{
ID: runID,
WorkspaceID: item.WorkspaceID,
ItemID: item.ID,
Reason: reasonOr(j.Payload["reason"], "ingest"),
StartedAt: time.Now().UTC(),
ParserVersion: parsing.Version,
EmbeddingModel: p.embedder.ModelID(),
AnalysisModel: p.analysis.ModelID(),
ScoreVersion: relationships.ScoreVersion,
}
if err := p.repos.Runs.Create(ctx, run); err != nil {
log.Warn("could not open a processing run", "error", err.Error())
}
}
content, err := p.ingest.ReadOriginal(item)
if err != nil {
return fmt.Errorf("read original content: %w", err)
}
doc, perr := parsing.Parse(parsing.Input{
Data: content,
MIMEType: item.MIMEType,
Filename: item.OriginalFilename,
MaxTextBytes: p.cfg.Uploads.MaxExtractedTextBytes,
MaxJSONDepth: p.cfg.Uploads.MaxJSONDepth,
})
if perr != nil {
// Record the failure and stop the chain, keeping the original intact.
for _, w := range doc.Warnings {
_ = p.repos.Items.AddWarning(ctx, item.WorkspaceID, item.ID, w)
}
item.ParserVersion = parsing.Version
item.Status = domain.StatusFailed
item.Error = "Text could not be extracted from this item. The original content is preserved and can be reprocessed."
if err := p.repos.Items.Update(ctx, item); err != nil {
return err
}
p.finishRun(ctx, runID, "failed", perr.Error(), nil)
log.Warn("parsing failed; the original is preserved", "error", perr.Error())
// Returning nil: the job did its work correctly. The item's failure is
// recorded as item state, not as an endlessly retried job.
return nil
}
item.Warnings = append(item.Warnings, doc.Warnings...)
item.ExtractedText = doc.Text
item.ParserVersion = doc.Version
item.Status = domain.StatusChunking
item.Error = ""
if err := p.repos.Items.Update(ctx, item); err != nil {
return err
}
_ = p.repos.Models.Observe(ctx, item.WorkspaceID, "parser", parsing.Version, 0)
log.Info("parsed item", "text_bytes", len(doc.Text), "blocks", len(doc.Blocks),
"warnings", len(doc.Warnings))
// The parsed document is not carried in the job payload — payloads never
// hold content — so the next stage re-parses from the preserved original.
return p.enqueueNext(ctx, j, domain.JobChunkItem, 9)
}
// ---------------------------------------------------------------------------
// Stage 2: chunk
// ---------------------------------------------------------------------------
// HandleChunk divides the extracted text into retrievable chunks.
func (p *Pipeline) HandleChunk(ctx context.Context, j *domain.Job) error {
item, err := p.loadItem(ctx, j)
if err != nil {
return err
}
log := observability.LoggerFrom(ctx).With("item_id", item.ID)
if err := p.repos.Items.SetStatus(ctx, item.WorkspaceID, item.ID, domain.StatusChunking, ""); err != nil {
return err
}
doc, err := p.reparse(item)
if err != nil {
return err
}
res := chunking.Split(doc, chunking.Options{
MaxChars: p.cfg.Chunking.MaxChars,
TargetChars: p.cfg.Chunking.TargetChars,
MinChars: p.cfg.Chunking.MinChars,
OverlapChars: p.cfg.Chunking.OverlapChars,
MaxChunks: p.cfg.Chunking.MaxPerItem,
MinSplitChars: p.cfg.Chunking.MinSplitChars,
})
if res.Truncated {
_ = p.repos.Items.AddWarning(ctx, item.WorkspaceID, item.ID, domain.ParseWarning{
Code: domain.WarnTruncatedChunks,
Message: "This item produced more sections than the configured limit, so its later content is not searchable.",
Detail: fmt.Sprintf("limit=%d", p.cfg.Chunking.MaxPerItem),
})
}
now := time.Now().UTC()
chunks := make([]*domain.Chunk, 0, len(res.Chunks))
for _, c := range res.Chunks {
chunks = append(chunks, &domain.Chunk{
// A deterministic ID keyed on item and sequence means re-chunking
// reuses the same Qdrant point IDs, so vectors are overwritten
// rather than orphaned.
ID: ids.DeterministicUUID(item.WorkspaceID, item.ID, fmt.Sprint(c.Sequence), c.Hash),
WorkspaceID: item.WorkspaceID,
ItemID: item.ID,
Sequence: c.Sequence,
Text: c.Text,
ContentHash: c.Hash,
StartOffset: c.Start,
EndOffset: c.End,
Page: c.Page,
Section: c.Section,
Heading: c.Heading,
CreatedAt: now,
})
}
// Chunk IDs change when content changes, so vectors for the previous
// generation must go before the new chunks are written.
if err := p.vectors.DeleteByItem(ctx, item.WorkspaceID, item.ID); err != nil {
log.Warn("could not clear previous vectors before re-chunking", "error", err.Error())
}
if err := p.repos.Chunks.ReplaceForItem(ctx, item.WorkspaceID, item.ID, chunks); err != nil {
return err
}
item.ChunkCount = len(chunks)
item.Status = domain.StatusEmbedding
if err := p.repos.Items.Update(ctx, item); err != nil {
return err
}
log.Info("chunked item", "chunks", len(chunks), "truncated", res.Truncated)
if len(chunks) == 0 {
// Nothing to embed or analyse. The item is complete, if empty.
return p.markReady(ctx, item, j)
}
return p.enqueueNext(ctx, j, domain.JobEmbedItem, 8)
}
// reparse re-derives the parsed document from the preserved original.
func (p *Pipeline) reparse(item *domain.Item) (*parsing.Document, error) {
content, err := p.ingest.ReadOriginal(item)
if err != nil {
return nil, fmt.Errorf("read original content: %w", err)
}
doc, err := parsing.Parse(parsing.Input{
Data: content,
MIMEType: item.MIMEType,
Filename: item.OriginalFilename,
MaxTextBytes: p.cfg.Uploads.MaxExtractedTextBytes,
MaxJSONDepth: p.cfg.Uploads.MaxJSONDepth,
})
if err != nil {
return nil, fmt.Errorf("%w: %v", domain.ErrUnsupported, err)
}
return doc, nil
}
// ---------------------------------------------------------------------------
// Stage 3: embed
// ---------------------------------------------------------------------------
// HandleEmbed generates and stores vectors for the item's chunks.
func (p *Pipeline) HandleEmbed(ctx context.Context, j *domain.Job) error {
item, err := p.loadItem(ctx, j)
if err != nil {
return err
}
log := observability.LoggerFrom(ctx).With("item_id", item.ID)
if err := p.repos.Items.SetStatus(ctx, item.WorkspaceID, item.ID, domain.StatusEmbedding, ""); err != nil {
return err
}
chunks, err := p.repos.Chunks.ListByItem(ctx, item.WorkspaceID, item.ID)
if err != nil {
return err
}
if len(chunks) == 0 {
return p.markReady(ctx, item, j)
}
texts := make([]string, len(chunks))
for i, c := range chunks {
texts[i] = c.Text
}
// Hold an AI slot for the duration of the provider call so that concurrent
// model usage stays within the configured limit.
release, err := p.pool.AcquireAI(ctx)
if err != nil {
return err
}
vecs, err := p.embedder.Embed(ctx, texts)
release()
if err != nil {
return fmt.Errorf("generate embeddings: %w", err)
}
if len(vecs) != len(chunks) {
return fmt.Errorf("embedding provider returned %d vectors for %d chunks", len(vecs), len(chunks))
}
model := p.embedder.ModelID()
points := make([]domain.VectorPoint, 0, len(chunks))
for i, c := range chunks {
points = append(points, domain.VectorPoint{
ChunkID: c.ID,
WorkspaceID: c.WorkspaceID,
ItemID: c.ItemID,
SourceType: item.SourceType,
CreatedAt: item.CreatedAt,
EmbeddingModel: model,
Vector: vecs[i],
})
}
if err := p.vectors.Upsert(ctx, points); err != nil {
return fmt.Errorf("store vectors: %w", err)
}
chunkIDs := make([]string, len(chunks))
for i, c := range chunks {
chunkIDs[i] = c.ID
}
if err := p.repos.Chunks.MarkEmbedded(ctx, item.WorkspaceID, chunkIDs, model); err != nil {
return err
}
item.EmbeddingModel = model
item.Status = domain.StatusAnalysing
if err := p.repos.Items.Update(ctx, item); err != nil {
return err
}
_ = p.repos.Models.Observe(ctx, item.WorkspaceID, "embedding", model, p.embedder.Dimensions())
log.Info("embedded item", "chunks", len(chunks), "model", model)
return p.enqueueNext(ctx, j, domain.JobExtractEntity, 7)
}
// ---------------------------------------------------------------------------
// Stage 4: entities
// ---------------------------------------------------------------------------
// HandleExtractEntities runs deterministic and model-assisted entity extraction.
func (p *Pipeline) HandleExtractEntities(ctx context.Context, j *domain.Job) error {
item, err := p.loadItem(ctx, j)
if err != nil {
return err
}
log := observability.LoggerFrom(ctx).With("item_id", item.ID)
if err := p.repos.Items.SetStatus(ctx, item.WorkspaceID, item.ID, domain.StatusAnalysing, ""); err != nil {
return err
}
chunks, err := p.repos.Chunks.ListByItem(ctx, item.WorkspaceID, item.ID)
if err != nil {
return err
}
n, err := p.entities.ExtractForItem(ctx, item, chunks, p.pool)
if err != nil {
// Model-assisted extraction is best-effort: the deterministic results
// are already stored, so a model failure degrades quality rather than
// stopping the pipeline.
if errors.Is(err, analysis.ErrDisabled) || errors.Is(err, analysis.ErrInvalidOutput) {
log.Warn("model-assisted entity extraction unavailable; deterministic results kept",
"error", err.Error())
} else {
return err
}
}
item.EntityCount = n
item.AnalysisModel = p.analysis.ModelID()
if err := p.repos.Items.Update(ctx, item); err != nil {
return err
}
if m := p.analysis.ModelID(); m != "" {
_ = p.repos.Models.Observe(ctx, item.WorkspaceID, "analysis", m, 0)
}
log.Info("extracted entities", "count", n)
return p.enqueueNext(ctx, j, domain.JobExtractClaims, 6)
}
// ---------------------------------------------------------------------------
// Stage 5: claims
// ---------------------------------------------------------------------------
// HandleExtractClaims extracts the statements the item makes.
func (p *Pipeline) HandleExtractClaims(ctx context.Context, j *domain.Job) error {
item, err := p.loadItem(ctx, j)
if err != nil {
return err
}
log := observability.LoggerFrom(ctx).With("item_id", item.ID)
chunks, err := p.repos.Chunks.ListByItem(ctx, item.WorkspaceID, item.ID)
if err != nil {
return err
}
n, err := p.entities.ExtractClaimsForItem(ctx, item, chunks, p.pool)
if err != nil {
if errors.Is(err, analysis.ErrDisabled) || errors.Is(err, analysis.ErrInvalidOutput) {
log.Warn("claim extraction unavailable", "error", err.Error())
} else {
return err
}
}
item.ClaimCount = n
item.Status = domain.StatusRelating
if err := p.repos.Items.Update(ctx, item); err != nil {
return err
}
log.Info("extracted claims", "count", n)
return p.enqueueNext(ctx, j, domain.JobDiscoverRels, 5)
}
// ---------------------------------------------------------------------------
// Stage 6: relationships
// ---------------------------------------------------------------------------
// HandleDiscoverRelationships finds and scores candidate relationships.
func (p *Pipeline) HandleDiscoverRelationships(ctx context.Context, j *domain.Job) error {
item, err := p.loadItem(ctx, j)
if err != nil {
return err
}
log := observability.LoggerFrom(ctx).With("item_id", item.ID)
if err := p.repos.Items.SetStatus(ctx, item.WorkspaceID, item.ID, domain.StatusRelating, ""); err != nil {
return err
}
stats, err := p.discover.DiscoverForItem(ctx, item, p.pool)
if err != nil {
return err
}
item.RelationshipSeen = stats.Stored
item.ScoreVersion = relationships.ScoreVersion
if err := p.repos.Items.Update(ctx, item); err != nil {
return err
}
log.Info("discovered relationships",
"candidates", stats.Candidates, "stored", stats.Stored, "model_calls", stats.ModelCalls)
p.finishRun(ctx, j.RunID, "succeeded", "", map[string]int{
"chunks": item.ChunkCount,
"entities": item.EntityCount,
"claims": item.ClaimCount,
"relationships": stats.Stored,
"candidates": stats.Candidates,
})
return p.markReady(ctx, item, j)
}
func (p *Pipeline) markReady(ctx context.Context, item *domain.Item, j *domain.Job) error {
status := domain.StatusReady
// An item that produced no searchable text is "partial": it exists and is
// preserved, but it will not be found by content.
if item.ChunkCount == 0 || item.HasWarning(domain.WarnLowExtractableText) {
status = domain.StatusPartial
}
if err := p.repos.Items.SetStatus(ctx, item.WorkspaceID, item.ID, status, ""); err != nil {
return err
}
if j != nil && j.RunID != "" {
p.finishRun(ctx, j.RunID, "succeeded", "", nil)
}
return nil
}
func (p *Pipeline) finishRun(ctx context.Context, runID, outcome, errMsg string, stats map[string]int) {
if runID == "" {
return
}
if err := p.repos.Runs.Finish(ctx, runID, outcome, errMsg, stats); err != nil {
observability.LoggerFrom(ctx).Debug("could not close processing run", "error", err.Error())
}
}
func reasonOr(v, def string) string {
if v == "" {
return def
}
return v
}
+605
View File
@@ -0,0 +1,605 @@
// 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"
}