Documentation
¶
Overview ¶
Package notify is filex' notification subsystem.
Two delivery channels:
- Webhook: a single configurable POST endpoint that always receives a generic JSON body. Retry 3× exponential backoff.
- In-app: per-user bell + history + read/unread, surfaced via the /api/notifications/... endpoints. Persisted in the notifications table so they survive a server restart.
Both channels fan out from one Service.Send call. The webhook URL + optional bearer token come from FILEX_WEBHOOK_URL / FILEX_WEBHOOK_TOKEN (.env bootstrap) or DB-stored override (notification_settings tabular override is per-user; the webhook URL itself is global, stored in the settings table). v0.1 reads them from config; the admin panel exposes PATCH /admin/api/notifications/webhook-config to change them at runtime.
Index ¶
- Constants
- func BroadcastAudience(n *model.Notification) string
- func ForEveryone(event string) bool
- func NamesAFile(meta json.RawMessage) bool
- func Signature(secret string, body []byte) string
- type ActorRef
- type Bell
- type Config
- type Event
- type EventType
- type NodeRef
- type Service
- type Severity
- type ShareRef
- type Target
- type TargetDeliveryStatus
- type TargetKind
- type WebhookStatus
Constants ¶
const ( // AudienceEveryone — every bell (a notice for everybody that names no file). AudienceEveryone = "everyone" // AudienceViewers — administrators, and the members who can see the file it // names. AudienceViewers = "viewers" // AudienceAdmins — administrators only. AudienceAdmins = "admins" // AudienceNobody — no bell at all: routine file activity recorded without // the person who did it. It is kept here, in the history, and nowhere else. AudienceNobody = "nobody" )
The admin history's Scope column for a broadcast: who it reaches, said by the same rule the bells apply (model.Notification.Audience).
const ( TargetNone = model.TargetNone TargetFile = model.TargetFile TargetDir = model.TargetDir TargetTrash = model.TargetTrash TargetApp = model.TargetApp )
The six target kinds, re-exported so an emitter never imports model just to name one.
Variables ¶
This section is empty.
Functions ¶
func BroadcastAudience ¶
func BroadcastAudience(n *model.Notification) string
BroadcastAudience says who a broadcast of this row's kind reaches. "" for a row addressed to somebody — that row's scope is its owner.
func ForEveryone ¶
ForEveryone reports whether a broadcast of this event is meant for every person (everyoneEvents) — the kind a member keeps even when it names no file.
func NamesAFile ¶
func NamesAFile(meta json.RawMessage) bool
NamesAFile reports whether a stored row names a file — whether it carries a node with a name or a path. What a member may be shown of such a row is decided by that file, never by the kind alone.
Types ¶
type ActorRef ¶
ActorRef identifies who triggered the event, best-effort (webhook v2 payload `actor` object). Anonymous surfaces (public drop) omit it.
type Bell ¶
type Bell int
Who reads a notification — the ONE audience rule, for every door a row goes out through: the bell and its badge, the full list behind it, the browser notification and the desktop toast (both read GET /api/notifications), the admin history's Scope column, and "mark read".
A row ADDRESSED to somebody is theirs and nobody else's. A BROADCAST (no user) is decided in two layers:
- By kind, here, in SQL (Bell → model.BroadcastFilter), so a bell's page is filled with rows its reader can be shown rather than cut first and emptied after (PR #42, Berk Başarır: 60 operator rows once left a member `{"items":[],"total":0}` under a badge of 1).
- Per row, in the HTTP layer (handlers/notifications.go → visibleTo): the tenant the file's storage belongs to, and for a member the explorer's own "may they see this path" (acl.Set.CanSee).
The kinds, and why:
- Routine file activity (personalEvents) is always addressed to whoever did it; a broadcast of one is an actor that got lost on the way (the ops worker, before PR #42), not a message. No bell reads it; the admin history keeps it.
- Operator alarms (operatorEvents, event.go) are administrators' at ANY address — the v0.43.0 rule is by event (Bell.never).
- A notice meant for everybody (everyoneEvents: the admin page's test, an app's instance-wide notice) reaches every bell — unless it names a file, and then only the bells of those who can see that file.
- Every other broadcast is for administrators: a drop or share notice with no owner carries the link's bearer token, an escrow notice with no owner is addressed to admins by its emitter.
const ( // AdminBell is the bell of an admin nobody confines — a single-tenant // install's admin, or the supertenant's: every broadcast except routine // file activity nobody was addressed by (personalEvents). AdminBell Bell = iota // TenantAdminBell is a tenant admin's: only the broadcasts that name a // file (fileBroadcastEvents), because only those can be placed in a // tenant. An operator alarm names no storage and would be dropped after // it had already taken a place on the page. TenantAdminBell // MemberBell is everybody else's: only memberBroadcastEvents. MemberBell )
type Config ¶
type Config struct {
WebhookURL string
WebhookToken string
HTTPTimeout time.Duration
RetryBackoffs []time.Duration // attempt delays; default {1s,3s,9s}
}
Config bootstraps a Service. WebhookURL and WebhookToken are optional — leave empty to skip webhook delivery (in-app still records the event).
type Event ¶
type Event struct {
Event EventType `json:"event"`
Severity Severity `json:"severity"`
Title string `json:"title"`
Body string `json:"body"`
Meta map[string]any `json:"meta,omitempty"`
TS time.Time `json:"ts"`
// At mirrors TS under the webhook-v2 documented field name; Send
// fills it from TS so the wire payload always carries `at`.
At time.Time `json:"at"`
// Structured webhook-v2 payload objects (all optional). Node points
// at the file/folder the event concerns, Share at the public link,
// Actor at the triggering user. Send also folds them into the
// persisted meta_json so the in-app history keeps the context.
Node *NodeRef `json:"node,omitempty"`
Actor *ActorRef `json:"actor,omitempty"`
// Target is where a click on this notification goes. Emitters set it
// with FileTarget/DirTarget/ShareTarget/ParentDirTarget; Send fills in
// the storage NAME from Node.StorageID and, failing that, downgrades the
// target to TargetNone rather than shipping half an address. It is
// always non-nil on the wire, so a receiver can switch on `target.kind`
// without a nil check.
Target *Target `json:"target,omitempty"`
// UserID, when non-nil, scopes the in-app notification to a single
// user. Otherwise the row is broadcast, and who reads it is bell.go's
// rule: by kind, then — for a member — only when the file it names is one
// the member can see (handlers/notifications.go). The webhook delivery is
// unaffected.
UserID *int64 `json:"-"`
}
Event is the in-memory shape that subsystems hand to Service.Send.
On the wire (webhook payload) and in the DB it is encoded as JSON — the field tags below match the documented public contract.
type EventType ¶
type EventType string
EventType is a stable string id for a kind of event. Kept as a typed alias rather than an enum so consumers can introduce new types without touching this package.
const ( EventReplicaFail EventType = "replica_fail" EventReplicaFailSpike EventType = "replica_fail_spike" EventReplicaReconcileDone EventType = "replica_reconcile_done" EventReplicaStatusReport EventType = "replica_status_report" EventPrimaryReadFail EventType = "primary_read_fail" EventQuotaNearFull EventType = "quota_near_full" EventQuotaFull EventType = "quota_full" EventQueueStuck EventType = "queue_stuck" EventAuthFailSpike EventType = "auth_fail_spike" EventDiskFull EventType = "disk_full" // EventUpdateAvailable fires ONCE per newly published release — the // "already announced" mark is persisted, so a restart loop cannot turn it // into a stream. EventUpdateAvailable EventType = "update_available" // EventUpdateApplied fires after a self-upgrade replaced the binary. The // version moving is exactly the kind of change an operator must find in // the log even if nobody was watching when it happened. EventUpdateApplied EventType = "update_applied" )
Canonical events fired by filex itself. Subsystems may emit other types; the webhook payload echoes whatever is given.
⚠ DECLARED IS NOT EMITTED. Seven of the operational alerts below have no producer anywhere in the tree: replica_fail_spike, quota_near_full, quota_full, queue_stuck, auth_fail_spike, disk_full, update_applied. A webhook target may still name one — MatchesEvent accepts it — so the subscription saves and then waits forever, which reads exactly like a subsystem with no problems. They are kept (rather than deleted) because the alarms are wanted; docs/NOTIFICATIONS.md carries an "Emitted" column so an operator is not misled meanwhile. Wire the producer before removing a name from that list.
const ( EventFileUploaded EventType = "file.uploaded" // EventFileUpdated fires when a write REPLACED the bytes of a file that // already existed, where file.uploaded means the write created it. Both // come from the same post-write gate (internal/writehook), so every write // surface — editor save, browser re-upload, WebDAV PUT, S3 PutObject, // SFTP/FTPS/NFS, the AI/MCP surface — splits them the same way. // // ⚠ Adding it NARROWS file.uploaded: a subscriber that watched // file.uploaded to see edits stops hearing them and has to subscribe to // file.updated as well. That is deliberate — an event called "uploaded" // that also fires for an in-place edit cannot be filtered by anyone. EventFileUpdated EventType = "file.updated" // EventFileUploadFailed fires when bytes filex already acknowledged could // not be written to the storage driver — the staged upload path answers the // client before it transfers, so a failure after that point is invisible // unless it is announced (issue #16). EventFileUploadFailed EventType = "file.upload_failed" EventFileDeleted EventType = "file.deleted" EventFileMoved EventType = "file.moved" EventFileTrashed EventType = "file.trashed" EventDropReceived EventType = "drop.received" /* koru:k2 av */ // EventFileInfected fires when the async ClamAV scan flags an // uploaded file; the payload carries the node plus a `signature` // meta field, and the file is quarantined into trash. EventFileInfected EventType = "file.infected" /* calisma:d3 comments */ // EventCommentAdded fires when a user comments on a file/folder // node (v0.6 "Çalışma" (Work)). The payload carries the node (path/name), // the actor, and meta {comment_id, body (first 200 chars)}. EventCommentAdded EventType = "comment.added" // Archive events describe the completed user operation rather than each // low-level file write it performed. Extraction can create hundreds of // files; one completion event is useful, hundreds of "new file" alerts are // not. EventArchiveCreated EventType = "archive.created" EventArchiveExtracted EventType = "archive.extracted" // EventE2EEscrowUsed fires when an encrypted folder was opened with the // operator's ESCROW key rather than its owner's passphrase. Not the // recovery key — that one the owner holds; escrow means somebody else's // key was used on their folder, which is why the owner is told. The payload // carries the node plus meta {escrow_kid, storage, folder} and, when the // caller was authenticated, actor_email. // // It was written as an inline notify.EventType("e2e.escrow_used") in the // handler, which is why it never appeared in the admin UI's list: an // event that is not a constant here is invisible to everything that reads // this block. catalog_test.go now refuses that shape. EventE2EEscrowUsed EventType = "e2e.escrow_used" // EventPluginNotice is an app plugin (internal/wasmplugin) speaking to // people through the notify_send host function: a signature request, a // finished conversion, anything the plugin's author phrased. The row's // Title/Body are the plugin's English text; meta carries `plugin` (its // name), `title_en`/`title_tr`/`body_en`/`body_tr` for the reader's // language, and `job` when sent from a queued action. EventPluginNotice EventType = "plugin.notice" // EventAdminTest is the admin notifications page's "Send test // notification": one broadcast through the bell and every webhook, so an // operator can see the plumbing work. It names nothing and is meant for // every bell (bell.go → everyoneEvents); e2e 109 seeds a non-admin's bell // with it. EventAdminTest EventType = "admin_test" )
Canonical file/share events (webhook v2 — "Bağlan" (Connect) wave). Emitted asynchronously from the API mutation handlers; webhook targets filter on these names via their per-target events allow-list.
⚠ This const block is the SINGLE SOURCE OF TRUTH for the subscribable event catalogue. The rule is mechanical: an EventType whose value contains a dot is a webhook-v2 event an operator can tick in the admin UI, and the admin UI's list must match this block exactly. Two tests hold that:
- catalog_test.go here refuses an inline notify.EventType("x.y") anywhere in the backend, so a new event cannot be born outside this block (that is how "e2e.escrow_used" existed for a release without ever reaching the UI);
- web/tests/webhooks/eventCatalog.test.ts parses this file and fails when web/src/lib/webhookEvents.ts drifts from it, or when an event is missing an operator-readable label in en.json or tr.json.
The older underscore-named alerts above (replica_fail, disk_full, …) are operational alarms from the pre-v2 global webhook; MatchesEvent will still filter on them if a target names one, but they are not part of the catalogue the UI offers.
type NodeRef ¶
type NodeRef struct {
StorageID int64 `json:"storage_id"`
Path string `json:"path"`
Name string `json:"name"`
Size int64 `json:"size,omitempty"`
}
NodeRef identifies the file/folder an event is about (webhook v2 payload `node` object).
type Service ¶
type Service interface {
// Send fans the event out to (a) the in-app history table and
// (b) every configured webhook destination: the legacy global
// webhook plus each enabled, event-matching webhook_targets row.
// Errors from webhook delivery are recorded against the row, not
// bubbled up — webhook failure should never break the originating
// request.
Send(ctx context.Context, e Event) (id int64, err error)
// List + Mark + Settings just delegate to the store; they're on
// the Service interface so handlers don't have to know about the
// store. Pass userID nil for admin-global views; bell (which
// broadcasts a per-user read takes) is ignored there.
List(ctx context.Context, userID *int64, bell Bell, onlyUnread bool, limit, offset int) ([]*model.Notification, int64, error)
UnreadCount(ctx context.Context, userID *int64, bell Bell) (int64, error)
// ListVisible is a per-user read whose BROADCASTS the caller judges one by
// one after the store (keep — a member's grants, a tenant): the reader's
// own rows are counted in SQL and only the broadcasts their bell admits are
// walked, so the total is exact and a page is cut where the reader sees
// it. keep nil is List. keepOwn, when set, judges the reader's own rows too
// (a folder-confined token reads only its folder's), walked the same way.
ListVisible(ctx context.Context, userID int64, bell Bell, onlyUnread bool, limit, offset int, keep, keepOwn func(*model.Notification) bool) ([]*model.Notification, int64, error)
// History is the admin-global list with each broadcast's read state as
// readerID has it (0: the rows' own column).
History(ctx context.Context, readerID int64, onlyUnread bool, limit, offset int) ([]*model.Notification, int64, error)
// MarkRead and MarkAllRead stamp rows ADDRESSED to userID only. A
// broadcast is read per reader: MarkBroadcastsRead marks single ones,
// after the caller has checked the reader may see them, and
// MarkAllBroadcastsRead reads every broadcast up to now for the reader —
// ones their bell does not show included, which only ever changes what
// that reader sees.
MarkRead(ctx context.Context, id int64, userID *int64) error
MarkAllRead(ctx context.Context, userID *int64) error
MarkBroadcastsRead(ctx context.Context, readerID int64, ids []int64) error
MarkAllBroadcastsRead(ctx context.Context, readerID int64) error
GetSettings(ctx context.Context, userID int64) (*model.NotificationSettings, error)
UpsertSettings(ctx context.Context, s *model.NotificationSettings) error
// SetWebhook is invoked by the admin handler when the operator
// changes the webhook URL/token at runtime. Pass empty values to
// disable webhook delivery without taking the in-app channel down.
SetWebhook(url, bearerToken string)
// WebhookConfig returns the currently effective webhook URL and a
// flag indicating whether a token is set (the token itself is
// never echoed back).
WebhookConfig() (url string, tokenSet bool)
// TestTarget synchronously fires a sample event at one webhook
// target (single attempt, no retries) and returns the outcome.
// Backs the admin "Test" button; the result is also recorded as
// the target's in-memory last-delivery status.
TestTarget(ctx context.Context, target *model.WebhookTarget) TargetDeliveryStatus
// TargetStatuses returns the last-delivery status per target id.
// In-memory only (process lifetime) — the schema stays at the
// frozen webhook_targets contract, so per-target status is not
// persisted; the notifications table remains the durable audit.
TargetStatuses() map[int64]TargetDeliveryStatus
// Wait blocks until any in-flight webhook deliveries return. Used
// by tests; production calls Stop() instead which cancels the
// dispatch context AND waits.
Wait()
// Stop cancels in-flight dispatches and waits for them to finish.
Stop()
}
Service is the public façade subsystems use to emit events. Send persists to DB synchronously and dispatches the webhook in a background goroutine — callers do not block on the upstream POST.
type Severity ¶
type Severity string
Severity classifies an event's urgency. Used by the UI for color coding and webhook receivers for filtering.
type ShareRef ¶
type ShareRef struct {
}
ShareRef identifies the share link an event is about (webhook v2 payload `share` object).
type Target ¶
type Target = model.NotificationTarget
Target is the typed "where does a click on this notification go" — re-exported from model so subsystems only ever import notify.
⚠ There is exactly ONE of these per notification and exactly one resolver per surface reading it. `node`/`share` above stay what they always were: descriptive context for a webhook receiver. The target is the ADDRESS, and it is a separate field because the two are not the same thing — a `file.trashed` event describes the file at its original path and has to open the copy in the trash, and `share.created` carries both a node and a share while only one of them is the thing to open.
func AppTarget ¶
AppTarget addresses one of an app plugin's home pages, optionally at a section of it. The plugin and view are checked by the caller (the host's notify_send answers only for the app's own `home` views).
func FileTarget ¶
FileTarget addresses one file by its path inside its storage. The click opens the file's FOLDER with the file selected — the folder is derived by the client, never stored, so a rename of the parent cannot leave a target pointing at a folder and a file that disagree.
func ParentDirTarget ¶
ParentDirTarget addresses the FOLDER a path sits in — the honest target for an event about something that is no longer there (a permanent delete).
func ShareTarget ¶
ShareTarget addresses a public share link by its token.
func TrashTarget ¶
TrashTarget addresses the Trash view with the item that was deleted FROM origPath selected — the target of a soft delete and of an antivirus quarantine. origPath is where the file lived, never its `.filex-trash/…` key: the Trash view lists items by where they came from, and the key is a path no surface serves (syspath.Sealed).
type TargetDeliveryStatus ¶
type TargetDeliveryStatus struct {
Status string `json:"status"` // "sent" | "failed"
Error string `json:"error,omitempty"`
At time.Time `json:"at"`
}
TargetDeliveryStatus is the outcome of the most recent delivery (or admin test fire) to one webhook target.
type TargetKind ¶
type TargetKind = model.NotificationTargetKind
TargetKind re-exports model.NotificationTargetKind.
type WebhookStatus ¶
type WebhookStatus string
WebhookStatus enumerates the lifecycle of a single webhook attempt chain. Persisted to notifications.webhook_status and surfaced in the admin UI.
const ( // WebhookStatusPending — the webhook attempt has not yet been made. WebhookStatusPending WebhookStatus = "pending" // WebhookStatusSent — the upstream returned 2xx within the budget. WebhookStatusSent WebhookStatus = "sent" // WebhookStatusFailed — exhausted retries; the operator should // investigate. WebhookStatusFailed WebhookStatus = "failed" // WebhookStatusSkipped — no webhook URL configured. The in-app // row still exists. WebhookStatusSkipped WebhookStatus = "skipped" )