Help us shape the future of Go! Take the 2026 Go Developer Survey

notify

package
v0.48.0 Latest Latest
Warning

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

Go to latest
Published: Sep 28, 2026 License: MIT Imports: 19 Imported by: 0

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

View Source
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).

View Source
const (
	TargetNone  = model.TargetNone
	TargetFile  = model.TargetFile
	TargetDir   = model.TargetDir
	TargetShare = model.TargetShare
	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

func ForEveryone(event string) bool

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.

func Signature

func Signature(secret string, body []byte) string

Signature computes the X-Filex-Signature header value for a payload signed with secret: "sha256=" + hex(HMAC-SHA256(secret, body)). Exported so receivers/tests can verify deliveries.

Types

type ActorRef

type ActorRef struct {
	ID    int64  `json:"id,omitempty"`
	Email string `json:"email,omitempty"`
}

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:

  1. 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).
  2. 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
)

func (Bell) Admits

func (b Bell) Admits(event string) bool

Admits reports whether b reads a broadcast of this event at all — the Go twin of the SQL filter, for a caller holding one row rather than a query (marking a row read by its id). Which of the admitted rows a reader keeps is still the per-row pass in the HTTP layer.

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"`
	Share *ShareRef `json:"share,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"

	// An installed app following the source it came from
	// (wasmplugin/updates.go). Operator alarms like the two above: an app is
	// the platform operator's to manage, so nobody else is told. Meta carries
	// `plugin` (its name), `plugin_label_<lang>` (its label in every language
	// it has), `version` (the version found or reached) and, per event,
	// `from`, `added`, `error`.
	//
	// EventAppUpdated: an administrator's approved change of an app's
	// version — an upgrade, or going back (meta `rollback`). Since 0.48
	// nothing updates itself (wasmplugin/versions.go afterUpgrade).
	EventAppUpdated EventType = "app_updated"
	// EventAppUpdateAvailable: a newer version waits for the administrator.
	// Once per version.
	EventAppUpdateAvailable EventType = "app_update_available"
	// EventAppUpdateNeedsApproval: a newer version asks for permissions the
	// app was not granted (meta `added`), or a language pack now brings a
	// module (meta `adds_module`). Nothing moves until an administrator
	// reviews it. Once per version.
	EventAppUpdateNeedsApproval EventType = "app_update_needs_approval"
	// EventAppUpdateFailed: an automatic update was tried and undone — the
	// app runs the version it had (meta `error`). Written only by filex 0.47,
	// which updated apps by itself; kept so its rows still read.
	EventAppUpdateFailed EventType = "app_update_failed"
	// EventPluginUpdateAvailable: a binary STORAGE plugin's source has a
	// newer version for this platform (plugin/updates.go). Meta `plugin`,
	// `version`. Once per version; nothing is installed until an
	// administrator reviews it.
	EventPluginUpdateAvailable EventType = "plugin_update_available"
)

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"
	EventShareCreated     EventType = "share.created"
	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"
	// EventE2EPasswordChanged fires when an encrypted folder's password was
	// changed — or reset with its recovery key — in the web UI, which
	// announces it after writing the new key file (the change happens in the
	// browser; the server sees no password). It goes to the folder's OWNER,
	// who may not be the person who changed it: a change you did not make is
	// how you learn somebody else holds your password or recovery key. Meta
	// carries {storage, folder, via: "password"|"recovery_key", rekey} and,
	// when the caller was signed in, actor_email.
	EventE2EPasswordChanged EventType = "e2e.password_changed"
	// 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 packages/core/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.

func New

func New(store db.Store, cfg Config) Service

New returns a Service backed by the given store.

type Severity

type Severity string

Severity classifies an event's urgency. Used by the UI for color coding and webhook receivers for filtering.

const (
	SeverityInfo     Severity = "info"
	SeverityWarning  Severity = "warning"
	SeverityError    Severity = "error"
	SeverityCritical Severity = "critical"
)

Severity values. Free-form strings are accepted by the store but the UI only knows these four.

type ShareRef

type ShareRef struct {
	Token string `json:"token"`
	Path  string `json:"path,omitempty"`
}

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

func AppTarget(plugin, view, section string) *Target

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 DirTarget

func DirTarget(p string) *Target

DirTarget addresses one folder by its path inside its storage.

func FileTarget

func FileTarget(p string) *Target

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

func ParentDirTarget(p string) *Target

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

func ShareTarget(token string) *Target

ShareTarget addresses a public share link by its token.

func TrashTarget

func TrashTarget(origPath string) *Target

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"
)

Jump to

Keyboard shortcuts

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