notify

package
v0.45.0 Latest Latest
Warning

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

Go to latest
Published: Sep 25, 2026 License: MIT Imports: 18 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"
)

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"
	// 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.

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