writehook

package
v0.56.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 11, 2026 License: MIT Imports: 7 Imported by: 0

Documentation

Overview

Package writehook is the single post-write side-effect gate for every file-producing/mutating surface (manager, AI/MCP, ShareX, DAV, async ops worker).

A surface that writes/deletes/moves a file calls exactly ONE hook here; the hook fans out to the two cross-cutting side effects that used to be wired ad hoc per call site:

  • async antivirus scan enqueue ("Koru" ClamAV pipeline) — only for persisted file nodes (the scan job re-reads the node by id);
  • canonical webhook-v2 file event (file.uploaded / file.updated / file.deleted / file.moved / file.trashed) through the notify service, stamped with the originating surface in meta.origin.

The package is dependency-injected at bootstrap (Configure in api.BuildRouter) with the same nil-safe, package-level sink pattern as handlers.SetNotifySink / SetAntivirusEnqueue: unconfigured hooks are no-ops, so tests and unwired deployments never crash. It imports only auth/model/notify and quotastore's context keys — no handlers, db access, or storage — so any surface package (api/handlers, dav, …) can import it without a cycle.

Index

Constants

View Source
const (
	OriginManager = "manager" // browser SPA / manager + chunked upload
	OriginAI      = "ai"      // /api/ai REST + MCP tools (write, zip, unzip)
	OriginShareX  = "sharex"  // ShareX capture upload
	OriginDAV     = "dav"     // WebDAV surface
	OriginOps     = "ops"     // async ops worker (copy/move/delete)
	OriginS3      = "s3"      // S3-compatible endpoint (internal/s3api)
	OriginSFTP    = "sftp"    // SFTP endpoint (internal/sftpsrv)
	OriginFTP     = "ftp"     // FTPS endpoint (internal/ftpsrv)
	OriginNFS     = "nfs"     // NFSv3 endpoint (internal/nfssrv)
	// OriginOnlyOffice is the document server writing an edited office
	// document back through the save callback (internal/onlyoffice). It is a
	// surface of its own and not "manager": the bytes are assembled and
	// posted by the document server, not by the browser that opened the file,
	// and a subscriber deciding whether to re-run a pipeline needs to be able
	// to tell an office save from a drag-and-drop upload.
	OriginOnlyOffice = "onlyoffice"
	// OriginPlugin is an app plugin's action output (internal/wasmplugin),
	// written by the ops worker on behalf of the person who ran the action.
	OriginPlugin = "plugin"
)

Origin values for the `origin` parameter — the frozen set every surface must pick from (meta.origin on the emitted event).

Variables

This section is empty.

Functions

func BeforeOverwrite

func BeforeOverwrite(ctx context.Context, storageID int64, rel string) error

BeforeOverwrite runs the configured guard. Safe to call unconditionally from any write surface; a nil guard is a no-op returning nil.

func Configure

func Configure(av func(ctx context.Context, n *model.Node), s notify.Service)

Configure installs the process-wide dependencies. Call once at boot (api.BuildRouter). Either argument may be nil to disable that side effect (no ClamAV binary / no notify service).

func ConfigureAfterWrite added in v0.48.0

func ConfigureAfterWrite(f AfterWriteFunc)

ConfigureAfterWrite installs the process-wide after-write observer. Call once at boot; nil disables it.

func ConfigureOverwriteGuard

func ConfigureOverwriteGuard(g OverwriteGuardFunc)

ConfigureOverwriteGuard installs the process-wide pre-write guard. Call once at boot, after the versioning service exists. nil disables it.

func ConfigureSaveScan

func ConfigureSaveScan(av func(ctx context.Context, n *model.Node))

ConfigureSaveScan installs the debounced scan sink OnFileSaved uses. Call once at boot, beside Configure. nil is legal and means "no debounced sink": OnFileSaved then falls back to the immediate one, because a missing sink must degrade to scanning too often, never to not scanning at all.

func EmitWritten

func EmitWritten(ctx context.Context, storageID int64, node *model.Node, origin string, kind WriteKind, meta ...map[string]any)

EmitWritten is OnFileWritten without the antivirus enqueue — the same split protocolsync draws between Write and WriteRows, for the same reason: a surface that has already scheduled the scan itself must not get a second one.

⚠ Callers that want the debounced scan should reach for OnFileSaved above rather than this, which schedules NO scan at all. The one direct caller left is the text editor (api/handlers/save_text.go), which owns its own pair of enqueue sinks in the handlers package; it gets the same event either way.

func EmitWrittenFor added in v0.51.0

func EmitWrittenFor(ctx context.Context, storageID int64, node *model.Node, origin string, kind WriteKind, userIDs []int64, meta ...map[string]any)

EmitWrittenFor is EmitWritten told to the people named rather than to the request's user: one bell row each, the webhook once (with the first). For a write no request user made - the document server's save of a document several people edited (onlyoffice callback_format.go), whose callback names the editors. No names: EmitWritten.

func ObserveWrites added in v0.48.0

func ObserveWrites(name string, f WriteObserver)

ObserveWrites installs the after-write observer called name, replacing the one installed under that name before — so a router built twice in one process (every test binary) does not run it twice. nil removes it. Named rather than single-slot so that independent observers never overwrite each other's registration.

func OnFileDeleted

func OnFileDeleted(ctx context.Context, storageID int64, path, name, origin string, meta ...map[string]any)

OnFileDeleted emits one `file.deleted` event (permanent removal — trash purge or a hard delete on drivers without move support). For a soft delete into the trash use OnFileTrashed instead.

func OnFileMoved

func OnFileMoved(ctx context.Context, storageID int64, oldPath, newPath, name, origin string, meta ...map[string]any)

OnFileMoved emits one `file.moved` event. The event's node points at the new location; meta carries from/to (+ any extra pairs, e.g. {"rename": true}).

func OnFileSaved

func OnFileSaved(ctx context.Context, storageID int64, node *model.Node, origin string, kind WriteKind, meta ...map[string]any)

OnFileSaved is OnFileWritten for a write that is one save inside an ONGOING editing session: same event, DEBOUNCED scan.

⚠ The divergence is in the SCAN, never in the event. A person editing a file saves it repeatedly, and one ClamAV pass per save would spend a full scan on a file nobody has finished writing — so the save schedules one scan per file per editing window and the saves that arrive inside the window are absorbed into it (queue.AntivirusScanner.EnqueueAfterSave). Nobody gets a different EVENT out of this: a webhook subscriber must not be able to tell an editor save from an upload of the same bytes.

⚠⚠ Use it only where more saves really are coming. A surface that writes a whole file once wants OnFileWritten: deferring THAT scan buys nothing and costs the file up to a full window unscanned. The two callers today are the browser's text editor (a Ctrl+S burst) and an OnlyOffice force-save (an interim save with the document still open); an OnlyOffice save that arrives because the session ENDED is a final state and takes OnFileWritten.

func OnFileTrashed

func OnFileTrashed(ctx context.Context, storageID int64, path, name, trashPath, origin string, meta ...map[string]any)

OnFileTrashed emits one `file.trashed` event (soft delete — the file was renamed into `.filex-trash/` and is restorable). Not part of the frozen three-function contract but the manager parity event for every soft delete; trashPath is the in-trash location.

func OnFileWritten

func OnFileWritten(ctx context.Context, storageID int64, node *model.Node, origin string, kind WriteKind, meta ...map[string]any)

OnFileWritten is the single post-write gate: emit the canonical write event (kind decides file.uploaded vs file.updated, see WriteKind) and enqueue an antivirus scan for the freshly written node (persisted nodes only — the scan job re-fetches by id, so an id-less transient node is skipped).

node may be a transient (unsaved, ID==0) row when the DB mirror could not be upserted — the event still fires because the bytes ARE on storage; only the scan is skipped. nil node / directory nodes no-op. meta is optional extra event metadata (e.g. {"chunked": true}).

func OnFileWrittenWithoutNotification added in v0.44.2

func OnFileWrittenWithoutNotification(ctx context.Context, node *model.Node)

OnFileWrittenWithoutNotification keeps the non-notification half of the post-write gate. Composite operations use it for their internal writes and emit one operation-level event afterwards; an extraction of 500 files must still scan all 500, but must not send 500 "new file" notifications.

func OnUploadFailed

func OnUploadFailed(ctx context.Context, storageID int64, userID int64, p, name, origin, reason string, meta ...map[string]any)

OnUploadFailed emits one `file.upload_failed` event: the bytes did NOT reach the storage driver and the user has to be told.

⚠ It is the counterpart of OnFileWritten, and it exists because the staged upload path acknowledges the bytes long before it writes them. The client gets a 202 the moment the last chunk lands, the transfer happens afterwards in the ops worker, and until issue #16 a transfer that failed there produced one slog.Warn line and nothing else: the browser had already drawn a green tick, the file was listed at its full size, and the only way to find out it was never stored was to read the server log. An upload the user was told succeeded and that did not is exactly what a notification is for.

userID scopes the bell entry to the person who uploaded, because the failure surfaces on a worker goroutine where there is no request user for emit to infer one from.

func OnUploadFailedFor added in v0.51.0

func OnUploadFailedFor(ctx context.Context, storageID int64, userIDs []int64, p, name, origin, title, reason string, meta ...map[string]any)

OnUploadFailedFor is OnUploadFailed for several people: one bell row each, the webhook once (with the first). title is the row's own stored title, its fallback ("" leaves it to the server); what every reader is told is said by the server from the facts (internal/notify say.go).

func OverwriteGuarded

func OverwriteGuarded() bool

OverwriteGuarded reports whether a pre-write guard is installed.

It exists for the one surface whose own API already offers to snapshot the bytes it is about to replace: POST /api/files/versions/restore takes a `snapshot_current` flag. With the guard installed, BeforeOverwrite takes that same snapshot, and doing both would record the identical content twice and spend a version slot on it. With the guard DISABLED (FILEX_VERSIONS_ON_OVERWRITE=0) the flag has to keep working, or an operator who turned the automatic guard off would silently lose the explicit one too.

Types

type AfterWriteFunc added in v0.48.0

type AfterWriteFunc func(ctx context.Context, storageID int64, node *model.Node, origin string, replaced bool)

AfterWriteFunc observes a finished file write (replaced: a file was there before). It cannot refuse anything — the bytes have landed.

type OverwriteGuardFunc

type OverwriteGuardFunc func(ctx context.Context, storageID int64, rel string) error

OverwriteGuardFunc runs BEFORE a destructive write and may refuse it.

The mirror image of OnFileWritten: that hook fans out the side effects of a write that already happened; this one is the last moment at which the bytes about to be replaced still exist. `rel` is the storage-relative path of the file that is about to be written, in whatever spelling the caller has — leading slash optional, since pathkey.Hash cleans the path it is given.

Returning an error MUST abort the write. Versioning is the only guard today, and losing history is not a reason to lose the file: if the snapshot cannot be taken, the surface answers 503 rather than overwriting unrecoverably.

type WriteKind

type WriteKind int

WriteKind says whether the bytes that just landed created the file or replaced a file that was already there. It is what decides between notify.EventFileUploaded and notify.EventFileUpdated, and it is a REQUIRED argument of OnFileWritten rather than something the gate infers, so that a new write surface cannot join the fan-out without answering the question.

⚠ Every surface must derive it from the fact it ALREADY has — the cache-row lookup it does anyway (`existing != nil`), or, where the row is written before the bytes move (the staged upload path), a driver Stat of the target taken before the write. Do not invent a second notion of "did this exist".

const (
	// Created — nothing was addressable at this path before the write.
	// ⚠ Also the honest answer when the surface genuinely cannot tell (the
	// DB mirror was unreachable, so there is no row to compare against): it
	// is the value the event carried before file.updated existed, so an
	// unknown degrades to the old behaviour rather than to a wrong claim
	// that a file was edited.
	Created WriteKind = iota
	// Replaced — a file already existed at this path and its bytes changed.
	Replaced
)

func (WriteKind) Event

func (k WriteKind) Event() notify.EventType

Event maps the kind onto the canonical event name.

func (WriteKind) String

func (k WriteKind) String() string

String makes the kind readable in logs and test failures.

type WriteObserver added in v0.48.0

type WriteObserver func(ctx context.Context, storageID int64, node *model.Node, origin string, replaced bool)

WriteObserver sees a finished file write (replaced: a file was there before). It cannot refuse anything — the bytes have landed. Today the observers are E2E bookkeeping: the server's own audit row for a rewritten single encrypted file (e2e/fxewatch).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL