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
- func BeforeOverwrite(ctx context.Context, storageID int64, rel string) error
- func Configure(av func(ctx context.Context, n *model.Node), s notify.Service)
- func ConfigureAfterWrite(f AfterWriteFunc)
- func ConfigureOverwriteGuard(g OverwriteGuardFunc)
- func ConfigureSaveScan(av func(ctx context.Context, n *model.Node))
- func EmitWritten(ctx context.Context, storageID int64, node *model.Node, origin string, ...)
- func EmitWrittenFor(ctx context.Context, storageID int64, node *model.Node, origin string, ...)
- func ObserveWrites(name string, f WriteObserver)
- func OnFileDeleted(ctx context.Context, storageID int64, path, name, origin string, ...)
- func OnFileMoved(ctx context.Context, storageID int64, oldPath, newPath, name, origin string, ...)
- func OnFileSaved(ctx context.Context, storageID int64, node *model.Node, origin string, ...)
- func OnFileTrashed(ctx context.Context, storageID int64, path, name, trashPath, origin string, ...)
- func OnFileWritten(ctx context.Context, storageID int64, node *model.Node, origin string, ...)
- func OnFileWrittenWithoutNotification(ctx context.Context, node *model.Node)
- func OnUploadFailed(ctx context.Context, storageID int64, userID int64, ...)
- func OnUploadFailedFor(ctx context.Context, storageID int64, userIDs []int64, ...)
- func OverwriteGuarded() bool
- type AfterWriteFunc
- type OverwriteGuardFunc
- type WriteKind
- type WriteObserver
Constants ¶
const ( OriginManager = "manager" // browser SPA / manager + chunked upload OriginAI = "ai" // /api/ai REST + MCP tools (write, zip, unzip) 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 ¶
BeforeOverwrite runs the configured guard. Safe to call unconditionally from any write surface; a nil guard is a no-op returning nil.
func Configure ¶
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 ¶
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
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 ¶
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 )
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).