From 21e7058862b173a5b296ef4416151ad118360b2a Mon Sep 17 00:00:00 2001 From: Agent Date: Tue, 18 Aug 2026 12:21:22 -0400 Subject: [PATCH] Initial mailship fork from anchor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fork anchor, strip all push notification code (APNs, FCM, WebPush), rename from anchor to mailship, add new database schema for subscriptions + events tables, and add HTTP API for email notification registration and NIP-9a relay push callbacks. - POST /subscription/email — register for email digests - DELETE /subscription/:key — unsubscribe - POST /notify/:id — NIP-9a relay push callback - GET /confirm?token=... — confirm email - GET /unsubscribe?token=... — unsubscribe Co-authored-by: mplorentz --- .env.template | 10 + .gitattributes | 1 + .gitignore | 10 + .nvmrc | 1 + .prettierignore | 4 + .prettierrc | 7 + LICENSE | 21 ++ README.md | 84 ++++++ build-in-production.sh | 12 + eslint.config.mjs | 31 ++ package.json | 68 +++++ pnpm-lock.yaml | Bin 0 -> 152906 bytes pnpm-workspace.yaml | 3 + remove-pnpm-overrides.js | 20 ++ src/actions.ts | 63 ++++ src/alert.ts | 35 +++ src/database.ts | 265 ++++++++++++++++ src/digest.ts | 155 ++++++++++ src/emails/digest.mjml | 75 +++++ src/env.ts | 40 +++ src/index.ts | 26 ++ src/mailer.ts | 35 +++ src/pages/confirm-error.html | 34 +++ src/pages/confirm-success.html | 36 +++ src/pages/unsubscribe.html | 36 +++ src/repository.ts | 199 ++++++++++++ src/run.ts | 28 ++ src/server.ts | 197 ++++++++++++ src/templates.ts | 35 +++ src/types.d.ts | 4 + src/util.ts | 138 +++++++++ src/worker/email.ts | 77 +++++ src/worker/index.ts | 14 + tsconfig.json | 12 + web/.env.template | 3 + web/.gitignore | 24 ++ web/eslint.config.js | 21 ++ web/index.html | 15 + web/package.json | 34 +++ web/pnpm-lock.yaml | Bin 0 -> 78215 bytes web/src/main.ts | 534 +++++++++++++++++++++++++++++++++ web/src/style.css | 42 +++ web/src/vite-env.d.ts | 1 + web/tsconfig.json | 22 ++ web/vite.config.js | 11 + 45 files changed, 2483 insertions(+) create mode 100644 .env.template create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .nvmrc create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 LICENSE create mode 100644 README.md create mode 100755 build-in-production.sh create mode 100644 eslint.config.mjs create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 remove-pnpm-overrides.js create mode 100644 src/actions.ts create mode 100644 src/alert.ts create mode 100644 src/database.ts create mode 100644 src/digest.ts create mode 100644 src/emails/digest.mjml create mode 100644 src/env.ts create mode 100644 src/index.ts create mode 100644 src/mailer.ts create mode 100644 src/pages/confirm-error.html create mode 100644 src/pages/confirm-success.html create mode 100644 src/pages/unsubscribe.html create mode 100644 src/repository.ts create mode 100644 src/run.ts create mode 100644 src/server.ts create mode 100644 src/templates.ts create mode 100644 src/types.d.ts create mode 100644 src/util.ts create mode 100644 src/worker/email.ts create mode 100644 src/worker/index.ts create mode 100644 tsconfig.json create mode 100644 web/.env.template create mode 100644 web/.gitignore create mode 100644 web/eslint.config.js create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/src/main.ts create mode 100644 web/src/style.css create mode 100644 web/src/vite-env.d.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.js diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..abd80e4 --- /dev/null +++ b/.env.template @@ -0,0 +1,10 @@ +MAILSHIP_SECRET= +MAILSHIP_NAME=Mailship +MAILSHIP_URL=http://localhost:4738 +BASE_URL=http://localhost:4738 +INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band +DEFAULT_RELAYS=relay.damus.io,nos.lol +SEARCH_RELAYS=relay.nostr.band +POSTMARK_API_KEY= +POSTMARK_SENDER_ADDRESS= +PORT=4738 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..58f2839 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +pnpm-lock.yaml -diff diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd12225 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +.env +node_modules +yarn-error.log +anchor.db +*firebase* +*.p8 +dist +emails/*.html +mailship.db +dist/ diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..deed13c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/jod diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..a2fa6a8 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +node_modules +dist +*.html +*.mjml diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..238d4d9 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,7 @@ +{ + "semi": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100 +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9918e64 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2023 Coracle + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..e7ded5a --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Mailship + +A Nostr email notification server. Receives events pushed from relays via NIP-9a, stores them, and sends daily or weekly digest emails. + +## Architecture + +``` +Flotilla ──HTTP──▶ Mailship (POST /subscription/email) + │ NIP-98 auth + │ returns {key, callback} + │ + └──kind 30390──▶ relay or NPB + │ + event matches filter + │ + ▼ + POST /notify/:subId ──▶ Mailship + {id, relay} │ + UUID in path ├── fetch event from relay + is the auth ├── store in SQLite (dedup) + │ + cron fires ──▶ render digest ──▶ Postmark ──▶ email +``` + +## Configuration + +| Variable | Required | Description | +|---|---|---| +| `MAILSHIP_SECRET` | ✓ | A nostr private key hex string for the server's identity | +| `MAILSHIP_NAME` | ✓ | Name of this Mailship instance | +| `MAILSHIP_URL` | ✓ | Public URL of this instance | +| `BASE_URL` | ✓ | Base URL for callback URLs (same as MAILSHIP_URL typically) | +| `POSTMARK_API_KEY` | ✓ | Postmark API key for sending emails | +| `POSTMARK_SENDER_ADDRESS` | ✓ | Verified sender email in Postmark | +| `DEFAULT_RELAYS` | ✓ | Comma-separated list of default relays | +| `INDEXER_RELAYS` | ✓ | Comma-separated list of indexer relays | +| `SEARCH_RELAYS` | ✓ | Comma-separated list of search relays | +| `PORT` | | Port to run on (default: 3000) | + +## API + +### POST /subscription/email +Register for email digests. + +``` +Body: { email, frequency, pubkey } +Auth: NIP-98 (planned) +Response: { key, callback } +``` + +### DELETE /subscription/:key +Unsubscribe. + +``` +Auth: NIP-98 (planned) +Response: { ok: true } +``` + +### POST /notify/:id +NIP-9a relay push callback. Called by relays or NPB when matching events are found. + +``` +Body: { id, relay } +Response: { ok: true, stored: boolean } +Returns 404 if subscription not found or inactive. +``` + +### GET /confirm?token=... +Confirm email address via link from confirmation email. + +### GET /unsubscribe?token=... +Unsubscribe via link from digest email. + +## Development + +```sh +pnpm install +pnpm run build +pnpm run start +``` + +## Forked from Anchor + +Mailship is a fork of [Anchor](https://github.com/coracle-social/anchor), stripped of push notification support and adapted for NIP-9a relay push event intake. \ No newline at end of file diff --git a/build-in-production.sh b/build-in-production.sh new file mode 100755 index 0000000..37bf10d --- /dev/null +++ b/build-in-production.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +# Remove link overrides +node remove-pnpm-overrides.js package.json +node remove-pnpm-overrides.js web/package.json + +# When CI=true as it is on render.com, removing link overrides breaks the lockfile +pnpm i --no-frozen-lockfile +(cd web && pnpm i --no-frozen-lockfile) + +# Build everything +pnpm run build diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..b2c2bdd --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,31 @@ +import globals from "globals"; +import pluginJs from "@eslint/js"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import tsParser from "@typescript-eslint/parser"; + +/** @type {import('eslint').Linter.Config[]} */ +export default [ + { + ...pluginJs.configs.recommended, + files: ["src/**/*.{js,ts}"], + languageOptions: { + sourceType: "module", + globals: globals.node, + parser: tsParser, + parserOptions: { + project: "./tsconfig.json" + } + }, + plugins: { + "@typescript-eslint": tsPlugin + }, + rules: { + ...tsPlugin.configs.recommended.rules, + '@typescript-eslint/no-explicit-any': "off", + "@typescript-eslint/no-unused-vars": [ + "error", + {args: "none", destructuredArrayIgnorePattern: "^_d?$", caughtErrors: "none"}, + ], + }, + }, +]; diff --git a/package.json b/package.json new file mode 100644 index 0000000..9034745 --- /dev/null +++ b/package.json @@ -0,0 +1,68 @@ +{ + "name": "mailship", + "type": "module", + "version": "1.0.0", + "scripts": { + "build": "tsc && pnpm run build:html && pnpm run build:web", + "build:web": "cd web && pnpm run build", + "build:html": "cp -r src/pages dist/ && cp -r src/emails dist/", + "check": "tsc --noEmit && eslint src", + "format": "prettier --write \"src/**/*.{ts,js,json,html}\"", + "start": "node dist/index.js", + "run-alert": "node dist/run.js" + }, + "devDependencies": { + "@eslint/js": "^9.35.0", + "@types/better-sqlite3": "^7.6.13", + "@types/express": "^5.0.3", + "@types/express-ws": "^3.0.5", + "@types/mjml": "^4.7.4", + "@types/mustache": "^4.2.6", + "@types/sanitize-html": "^2.16.0", + + "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "^8.43.0", + "@typescript-eslint/parser": "^8.43.0", + "eslint": "^9.35.0", + "globals": "^15.15.0", + "onchange": "^7.1.0", + "prettier": "^3.6.2", + "typescript": "^5.9.2" + }, + "dependencies": { + "@types/node": "^22.18.1", + "@welshman/content": "^0.6.3", + "@welshman/feeds": "^0.6.3", + "@welshman/lib": "^0.6.3", + "@welshman/net": "^0.6.3", + "@welshman/router": "^0.6.3", + "@welshman/signer": "^0.6.3", + "@welshman/store": "^0.6.3", + "@welshman/util": "^0.6.3", + "bcrypt": "^5.1.1", + "cron": "^4.3.3", + "cron-parser": "^5.3.1", + "dotenv": "^16.6.1", + "express": "^4.21.2", + "express-rate-limit": "^7.5.1", + "express-ws": "^5.0.2", + + "localstorage-polyfill": "^1.0.1", + "mjml": "^4.15.3", + "mustache": "^4.2.0", + "nostr-tools": "^2.16.2", + "postmark": "^4.0.5", + "sanitize-html": "^2.17.0", + "sqlite3": "^5.1.7", + "succinct-async": "^1.0.4", + "ts-node-dev": "^2.0.0", + + "ws": "^8.18.3" + }, + "pnpm": { + "onlyBuiltDependencies": [ + "bcrypt", + "sqlite3" + ] + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8893fa32fee7e3c0a5ad4f0e9fff4b1d0a372a5f GIT binary patch literal 152906 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 bIK$K{q8D3|DCl7a#nj6yo`Z { + const sub = await db.insertSubscription(pubkey, email, frequency) + const callback = `${process.env.BASE_URL}/notify/${sub.id}` + + // Send confirmation email + await mailer.sendConfirm(sub) + + return { key: sub.key, callback } + }, +) + +export type ConfirmSubscriptionParams = { + token: string +} + +export const confirmSubscriptionAction = instrument( + 'actions.confirmSubscription', + async ({ token }: ConfirmSubscriptionParams) => { + const sub = await db.confirmSubscription(token) + + if (!sub) { + throw new ActionError('That confirmation code is invalid or has expired.') + } + + worker.registerSubscription(sub) + }, +) + +export type UnsubscribeParams = { + token: string +} + +export const unsubscribeAction = instrument( + 'actions.unsubscribe', + async ({ token }: UnsubscribeParams) => { + const sub = await db.unsubscribeSubscription(token) + + if (sub) { + worker.unregisterSubscription(sub) + } + }, +) \ No newline at end of file diff --git a/src/alert.ts b/src/alert.ts new file mode 100644 index 0000000..01df785 --- /dev/null +++ b/src/alert.ts @@ -0,0 +1,35 @@ +import { CronExpressionParser } from 'cron-parser' +import { tryCatch, int, HOUR } from '@welshman/lib' + +export type Subscription = { + id: string + key: string + pubkey: string + email: string + frequency: string + created_at: number + confirmed_at?: number + unsubscribed_at?: number + last_digest_at?: number +} + +export const getSubscriptionError = (sub: Subscription) => { + if (!sub.email?.includes('@')) { + return 'Please provide a valid email address' + } + + if (!['daily', 'weekly'].includes(sub.frequency)) { + return 'Frequency must be "daily" or "weekly"' + } + + // Daily: fire at 17:00 UTC. Weekly: fire Monday at 17:00 UTC. + // Validation: just ensure frequency is valid, cron is generated internally. +} + +export const getCronExpression = (frequency: string, hour = 17, minute = 0) => { + if (frequency === 'daily') { + return `0 ${minute} ${hour} * * *` + } + // Weekly on Monday + return `0 ${minute} ${hour} * * 1` +} \ No newline at end of file diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..f068ee5 --- /dev/null +++ b/src/database.ts @@ -0,0 +1,265 @@ +/* eslint @typescript-eslint/no-unused-vars: 0 */ + +import sqlite3 from 'sqlite3' +import crypto from 'crypto' +import { instrument } from 'succinct-async' +import { now } from '@welshman/lib' +import type { Subscription } from './alert.js' + +const db = new sqlite3.Database('mailship.db') + +type Param = number | string | boolean + +type Row = Record + +const run = (query: string, params: Param[] = []) => + new Promise((resolve, reject) => { + db.run(query, params, function (err) { + return err ? reject(err) : resolve(this.changes > 0) + }) + }) + +// prettier-ignore +const all = (query: string, params: Param[] = []) => + new Promise((resolve, reject) => { + db.all(query, params, (err, rows: T[]) => (err ? reject(err) : resolve(rows))) + }) + +// prettier-ignore +const get = (query: string, params: Param[] = []) => + new Promise((resolve, reject) => { + db.get(query, params, (err, row) => { + if (err) { + reject(err) + } else if (row) { + resolve(row as T) + } else { + resolve(undefined) + } + }) + }) + +async function assertResult(p: T | Promise) { + return (await p)! +} + +// Migrations + +export const migrate = () => + new Promise(async (resolve, reject) => { + try { + db.serialize(async () => { + await run( + ` + CREATE TABLE IF NOT EXISTS subscriptions ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL UNIQUE, + pubkey TEXT NOT NULL, + email TEXT NOT NULL, + frequency TEXT NOT NULL DEFAULT 'daily', + created_at INTEGER NOT NULL, + confirmed_at INTEGER, + unsubscribed_at INTEGER, + last_digest_at INTEGER + ) + `, + ) + await run( + ` + CREATE TABLE IF NOT EXISTS events ( + id TEXT NOT NULL, + subscription_id TEXT NOT NULL, + event JSON NOT NULL, + relay TEXT NOT NULL, + received_at INTEGER NOT NULL, + PRIMARY KEY (id, subscription_id) + ) + `, + ) + await run( + `CREATE INDEX IF NOT EXISTS idx_events_subscription_received ON events (subscription_id, received_at)`, + ) + resolve() + }) + } catch (err) { + reject(err) + } + }) + +// Subscriptions + +const parseSubscription = (row: any): Subscription | undefined => { + if (row) { + return row as Subscription + } +} + +export const insertSubscription = instrument( + 'database.insertSubscription', + async (pubkey: string, email: string, frequency: string) => { + const existing = await getSubscriptionByPubkey(pubkey) + + if (existing) { + // Update existing + return assertResult( + parseSubscription( + await get( + `UPDATE subscriptions SET email = ?, frequency = ?, confirmed_at = NULL, unsubscribed_at = NULL + WHERE pubkey = ? RETURNING *`, + [email, frequency, pubkey], + ), + ), + ) + } + + // Create new + return assertResult( + parseSubscription( + await get( + `INSERT INTO subscriptions (id, key, pubkey, email, frequency, created_at) + VALUES (?, ?, ?, ?, ?, ?) RETURNING *`, + [ + crypto.randomUUID(), + crypto.randomBytes(32).toString('hex'), + pubkey, + email, + frequency, + now(), + ], + ), + ), + ) + }, +) + +export const confirmSubscription = instrument( + 'database.confirmSubscription', + async (key: string) => { + return parseSubscription( + await get( + `UPDATE subscriptions SET confirmed_at = unixepoch() + WHERE key = ? AND confirmed_at IS NULL RETURNING *`, + [key], + ), + ) + }, +) + +export const unsubscribeSubscription = instrument( + 'database.unsubscribeSubscription', + async (key: string) => { + return parseSubscription( + await get( + `UPDATE subscriptions SET unsubscribed_at = unixepoch() WHERE key = ? RETURNING *`, + [key], + ), + ) + }, +) + +export const getSubscriptionById = instrument( + 'database.getSubscriptionById', + async (id: string) => { + return parseSubscription(await get(`SELECT * FROM subscriptions WHERE id = ?`, [id])) + }, +) + +export const getSubscriptionByKey = instrument( + 'database.getSubscriptionByKey', + async (key: string) => { + return parseSubscription(await get(`SELECT * FROM subscriptions WHERE key = ?`, [key])) + }, +) + +export const getSubscriptionByPubkey = instrument( + 'database.getSubscriptionByPubkey', + async (pubkey: string) => { + return parseSubscription( + await get( + `SELECT * FROM subscriptions WHERE pubkey = ? AND unsubscribed_at IS NULL`, + [pubkey], + ), + ) + }, +) + +export const getActiveSubscriptions = instrument('database.getActiveSubscriptions', async () => { + const rows = await all( + `SELECT * FROM subscriptions + WHERE confirmed_at IS NOT NULL + AND unsubscribed_at IS NULL`, + ) + + return rows.map(parseSubscription) as Subscription[] +}) + +export const updateLastDigestAt = instrument( + 'database.updateLastDigestAt', + async (id: string, timestamp: number) => { + await run(`UPDATE subscriptions SET last_digest_at = ? WHERE id = ?`, [timestamp, id]) + }, +) + +// Events + +export type StoredEvent = { + id: string + subscription_id: string + event: any + relay: string + received_at: number +} + +export const insertEvent = instrument( + 'database.insertEvent', + async (eventId: string, subscriptionId: string, event: any, relay: string) => { + try { + await run( + `INSERT INTO events (id, subscription_id, event, relay, received_at) + VALUES (?, ?, ?, ?, ?)`, + [eventId, subscriptionId, JSON.stringify(event), relay, now()], + ) + return true + } catch (err: any) { + // PRIMARY KEY collision = dedup, not an error + if (err.message?.includes('UNIQUE constraint')) { + return false + } + throw err + } + }, +) + +export const getEventsForSubscription = instrument( + 'database.getEventsForSubscription', + async (subscriptionId: string, since: number) => { + const rows = await all( + `SELECT * FROM events + WHERE subscription_id = ? AND received_at > ? + ORDER BY received_at DESC`, + [subscriptionId, since], + ) + + return rows.map((row) => ({ + ...row, + event: JSON.parse(row.event as any), + })) + }, +) + +export const deleteEventsForSubscription = instrument( + 'database.deleteEventsForSubscription', + async (subscriptionId: string, since: number) => { + await run( + `DELETE FROM events WHERE subscription_id = ? AND received_at > ?`, + [subscriptionId, since], + ) + }, +) + +export const purgeEventsOlderThan = instrument( + 'database.purgeEventsOlderThan', + async (timestamp: number) => { + await run(`DELETE FROM events WHERE received_at < ?`, [timestamp]) + }, +) \ No newline at end of file diff --git a/src/digest.ts b/src/digest.ts new file mode 100644 index 0000000..a693aaa --- /dev/null +++ b/src/digest.ts @@ -0,0 +1,155 @@ +import { neventEncode, decode } from 'nostr-tools/nip19' +import { + spec, + now, + sortBy, + groupBy, + displayList, + nth, + nthEq, + dateToSeconds, + secondsToDate, +} from '@welshman/lib' +import { parse, truncate, renderAsHtml } from '@welshman/content' +import { + TrustedEvent, + getParentId, + getIdFilters, + getReplyFilters, + NOTE, + COMMENT, + REACTION, + displayProfile, + displayPubkey, +} from '@welshman/util' +import { Loader, AdapterContext, makeLoader, SocketAdapter } from '@welshman/net' +import { Router, addMinimalFallbacks } from '@welshman/router' +import { call } from '@welshman/lib' +import { displayDuration, createElement } from './util.js' +import type { Subscription } from './alert.js' +import { sendDigest } from './mailer.js' +import { + profilesByPubkey, + loadRelaySelections, + loadProfile, +} from './repository.js' + +type DigestData = { + events: TrustedEvent[] + context: TrustedEvent[] +} + +export class Digest { + since: number + + constructor(readonly sub: Subscription) { + this.since = sub.last_digest_at || sub.created_at + } + + loadHandler = async () => { + // Default handler for building event links + return 'https://coracle.social/' + } + + buildParameters = async (data: DigestData) => { + const getEventVariables = (event: TrustedEvent) => { + const parsed = truncate(parse(event), { minLength: 400, maxLength: 800, mediaLength: 50 }) + + return { + Link: buildLink(event, handler), + Timestamp: formatter.format(secondsToDate(event.created_at)), + Icon: profilesByPubkey.get().get(event.pubkey)?.picture, + Name: displayProfileByPubkey(event.pubkey), + Content: renderAsHtml(parsed, { createElement, renderEntity }).toString(), + Replies: + repliesByParentId.get(event.id)?.filter((e) => [COMMENT, NOTE].includes(e.kind)) + ?.length || 0, + Reactions: repliesByParentId.get(event.id)?.filter(spec({ kind: REACTION }))?.length || 0, + } + } + + const { events, context } = data + const formatter = getFormatter() + const handler = await this.loadHandler() + const repliesByParentId = groupBy(getParentId, context) + const eventsByPubkey = groupBy((e) => e.pubkey, events) + const popular = sortBy((e) => -(repliesByParentId.get(e.id)?.length || 0), events).slice(0, 12) + const topProfiles = sortBy( + ([k, ev]) => -ev.length, + Array.from(eventsByPubkey.entries()).filter(([k]) => profilesByPubkey.get().get(k)) + ) + + return { + Total: events.length, + Duration: displayDuration(Math.floor(Date.now() / 1000) - this.since), + Popular: popular.map((e) => getEventVariables(e)), + HasPopular: popular.length > 0, + TopProfiles: displayList(topProfiles.map(([pk]) => displayProfileByPubkey(pk))), + } + } + + sendFromStoredEvents = async (storedEvents: { id: string; event: TrustedEvent; relay: string }[]) => { + const events = storedEvents.map(se => se.event) + const context = [...events] // For now, context == events (no reply loading) + + // Load profiles for event authors + const pubkeys = new Set(events.map(e => e.pubkey)) + for (const pk of pubkeys) { + try { + await loadProfile(pk) + } catch { + // pass + } + } + + const data = { events, context } as DigestData + + if (data.events.length > 0) { + await sendDigest(this.sub, await this.buildParameters(data)) + } + } +} + +// Utilities + +const buildLink = (event: TrustedEvent, handler: string) => { + const relays = Router.get().Event(event).getUrls() + const nevent = neventEncode({ ...event, relays }) + + if (handler.includes('')) { + return handler.replace('', nevent) + } else { + return handler + nevent + } +} + +const displayProfileByPubkey = (pubkey: string) => + displayProfile(profilesByPubkey.get().get(pubkey), displayPubkey(pubkey)) + +const renderEntity = (entity: string) => { + let display = entity.slice(0, 16) + '…' + + try { + const { type, data } = decode(entity) + + if (type === 'npub') { + display = '@' + displayProfileByPubkey(data) + } + + if (type === 'nprofile') { + display = '@' + displayProfileByPubkey(data.pubkey) + } + } catch (e) { + // Pass + } + + return display +} + +const getFormatter = () => { + return new Intl.DateTimeFormat('en-US', { + dateStyle: 'short', + timeStyle: 'short', + timeZone: 'UTC', + }) +} \ No newline at end of file diff --git a/src/emails/digest.mjml b/src/emails/digest.mjml new file mode 100644 index 0000000..a6959cd --- /dev/null +++ b/src/emails/digest.mjml @@ -0,0 +1,75 @@ + + + New Activity + + .header { font-size: 24px; font-weight: bold; } + .subheader { font-size: 16px; color: #555; } + .event-item { margin-bottom: 20px; border-left: 3px solid #F45E43; padding-left: 10px; } + .event-meta { margin-bottom: 8px; display: flex; justify-content: space-between; align-items: center; } + .event-meta-left { display: flex; align-items: center; } + .event-author { font-weight: bold; margin-right: 4px; } + .event-content { white-space: pre-wrap; } + .event-timestamp { color: #777; font-size: 12px; } + .event-link { font-size: 12px; } + .section-header { font-size: 18px; font-weight: bold; margin-top: 15px; } + .profile-image { width: 20px; height: 20px; border-radius: 50%; margin-right: 3px; vertical-align: middle; } + .event-stats { margin-top: 8px; color: #666; font-size: 14px; } + .stat-item { display: inline-flex; align-items: center; margin-right: 12px; } + .stat-icon { width: 16px; height: 16px; margin-right: 4px; vertical-align: middle; } + a { text-decoration: none; } + + + + + + Hello {{name}}, + + Below is a summary of activity over the last {{Duration}}. + We found {{Total}} new posts from {{TopProfiles}}. + + + + {{#HasPopular}} + Most Popular + {{#Popular}} + +
+
+
+ {{#Icon}}{{/Icon}} + {{Name}} + at {{Timestamp}} +
+ +
+
{{{Content}}}
+
+ + + {{Replies}} + + + + {{Reactions}} + +
+
+
+ {{/Popular}} + + {{/HasPopular}} + + + You're receiving this email because you subscribed to notifications. + We'll continue to send you updates based on your subscription preferences. + + + + Unsubscribe + +
+
+
+
diff --git a/src/env.ts b/src/env.ts new file mode 100644 index 0000000..8ff6ff7 --- /dev/null +++ b/src/env.ts @@ -0,0 +1,40 @@ +import 'dotenv/config' +import { always } from '@welshman/lib' +import { normalizeRelayUrl } from '@welshman/util' +import { netContext } from '@welshman/net' +import { Nip01Signer } from '@welshman/signer' +import { routerContext } from '@welshman/router' + +if (!process.env.MAILSHIP_URL) throw new Error('MAILSHIP_URL is not defined.') +if (!process.env.MAILSHIP_NAME) throw new Error('MAILSHIP_NAME is not defined.') +if (!process.env.MAILSHIP_SECRET) throw new Error('MAILSHIP_SECRET is not defined.') +if (!process.env.POSTMARK_API_KEY) throw new Error('POSTMARK_API_KEY is not defined.') +if (!process.env.POSTMARK_SENDER_ADDRESS) throw new Error('POSTMARK_SENDER_ADDRESS is not defined.') +if (!process.env.DEFAULT_RELAYS) throw new Error('DEFAULT_RELAYS is not defined.') +if (!process.env.INDEXER_RELAYS) throw new Error('INDEXER_RELAYS is not defined.') +if (!process.env.SEARCH_RELAYS) throw new Error('SEARCH_RELAYS is not defined.') +if (!process.env.PORT) throw new Error('PORT is not defined.') +if (!process.env.BASE_URL) throw new Error('BASE_URL is not defined.') + +export const MAILSHIP_URL = process.env.MAILSHIP_URL +export const MAILSHIP_NAME = process.env.MAILSHIP_NAME +export const BASE_URL = process.env.BASE_URL +export const appSigner = Nip01Signer.fromSecret(process.env.MAILSHIP_SECRET) +export const POSTMARK_API_KEY = process.env.POSTMARK_API_KEY +export const POSTMARK_SENDER_ADDRESS = process.env.POSTMARK_SENDER_ADDRESS +export const DEFAULT_RELAYS = process.env.DEFAULT_RELAYS.split(',').map(normalizeRelayUrl) +export const INDEXER_RELAYS = process.env.INDEXER_RELAYS.split(',').map(normalizeRelayUrl) +export const SEARCH_RELAYS = process.env.SEARCH_RELAYS.split(',').map(normalizeRelayUrl) +export const PORT = process.env.PORT + +appSigner.getPubkey().then(pubkey => { + console.log(`Running as ${pubkey}`) +}) + +netContext.pool.get = (url: string) => { + throw new Error('Attempted to use default pool') +} + +routerContext.getDefaultRelays = always(DEFAULT_RELAYS) +routerContext.getIndexerRelays = always(INDEXER_RELAYS) +routerContext.getSearchRelays = always(SEARCH_RELAYS) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..8f9dd20 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,26 @@ +import 'localstorage-polyfill' + +import { PORT } from './env.js' +import { server } from './server.js' +import { migrate, getActiveSubscriptions } from './database.js' +import { registerSubscription } from './worker/index.js' + +process.on('unhandledRejection', (error: Error) => { + console.error('Unhandled rejection:', error.stack) + process.exit(1) +}) + +process.on('uncaughtException', (error: Error) => { + console.error('Uncaught exception:', error.stack) + process.exit(1) +}) + +migrate().then(async () => { + server.listen(PORT, () => { + console.log('Running on port', PORT) + }) + + for (const sub of await getActiveSubscriptions()) { + registerSubscription(sub) + } +}) \ No newline at end of file diff --git a/src/mailer.ts b/src/mailer.ts new file mode 100644 index 0000000..15f683c --- /dev/null +++ b/src/mailer.ts @@ -0,0 +1,35 @@ +import { ServerClient } from 'postmark' +import { POSTMARK_SENDER_ADDRESS, POSTMARK_API_KEY, MAILSHIP_NAME, BASE_URL } from './env.js' +import type { Subscription } from './alert.js' +import { render } from './templates.js' + +const client = new ServerClient(POSTMARK_API_KEY) + +export const sendConfirm = (sub: Subscription) => { + const href = `${BASE_URL}/confirm?token=${sub.key}` + + return client.sendEmail({ + From: POSTMARK_SENDER_ADDRESS, + To: sub.email, + Subject: 'Confirm your email digest', + HtmlBody: ` +

Welcome to ${MAILSHIP_NAME}!

+

Please confirm that you would like to receive ${sub.frequency} digests by clicking the link below:

+

Confirm Digest

+ `, + TextBody: `Please confirm that you would like to receive ${sub.frequency} digests by visiting: ${href}`, + }) +} + +export const sendDigest = async (sub: Subscription, variables: Record) => { + return client.sendEmail({ + From: POSTMARK_SENDER_ADDRESS, + To: sub.email, + Subject: 'New activity', + HtmlBody: await render('emails/digest.mjml', { + ...variables, + name: sub.email.split('@')[0], + unsubscribeUrl: `${BASE_URL}/unsubscribe?token=${sub.key}`, + }), + }) +} \ No newline at end of file diff --git a/src/pages/confirm-error.html b/src/pages/confirm-error.html new file mode 100644 index 0000000..240a99b --- /dev/null +++ b/src/pages/confirm-error.html @@ -0,0 +1,34 @@ + + + + Confirmation Failed + + + +
+

Confirmation Failed

+
{{message}}
+
+ + diff --git a/src/pages/confirm-success.html b/src/pages/confirm-success.html new file mode 100644 index 0000000..ce4c7e2 --- /dev/null +++ b/src/pages/confirm-success.html @@ -0,0 +1,36 @@ + + + + Confirmation Successful + + + +
+

Email Alert Confirmed

+
+ Your alert has been successfully confirmed. You will now receive notifications. +
+
+ + diff --git a/src/pages/unsubscribe.html b/src/pages/unsubscribe.html new file mode 100644 index 0000000..227ce3a --- /dev/null +++ b/src/pages/unsubscribe.html @@ -0,0 +1,36 @@ + + + + Unsubscribe Successful + + + +
+

Unsubscribe Successful

+
+ You have been successfully unsubscribed from all notifications. +
+
+ + diff --git a/src/repository.ts b/src/repository.ts new file mode 100644 index 0000000..f8ea581 --- /dev/null +++ b/src/repository.ts @@ -0,0 +1,199 @@ +import { max, inc, pluck, uniq } from '@welshman/lib' +import { + TrustedEvent, + RELAYS, + PROFILE, + FOLLOWS, + readList, + readProfile, + asDecryptedEvent, + PublishedList, + PublishedProfile, + getPubkeyTagValues, + getListTags, +} from '@welshman/util' +import { makeLoader, LoadOptions, Repository, Pool, SocketAdapter } from '@welshman/net' +import { Router, getFilterSelections } from '@welshman/router' +import { deriveEventsMapped, collection } from '@welshman/store' +import { getFeedArgs, isScopeFeed, walkFeed, isWOTFeed, Scope, Feed } from '@welshman/feeds' + +// Utilities for loading data + +export const repository = Repository.get() + +const pool = new Pool() + +const load = makeLoader({ + delay: 500, + timeout: 5000, + threshold: 0.8, + context: { + getAdapter: (url: string) => new SocketAdapter(pool.get(url)) + }, +}) + +export const sharedLoad = (request: LoadOptions) => + load({ + ...request, + onEvent: (event, url) => { + request.onEvent?.(event, url) + repository.publish(event) + }, + }) + +export const relaySelections = deriveEventsMapped(repository, { + filters: [{ kinds: [RELAYS] }], + itemToEvent: (item) => item.event, + eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), +}) + +export const { indexStore: relaySelectionsByPubkey, loadItem: loadRelaySelections } = collection({ + name: 'relaySelections', + store: relaySelections, + getKey: (relaySelections) => relaySelections.event.pubkey, + load: (pubkey: string) => { + return sharedLoad({ + relays: Router.get().Index().getUrls(), + filters: [{ kinds: [RELAYS], authors: [pubkey] }], + }) + }, +}) + +export const profiles = deriveEventsMapped(repository, { + filters: [{ kinds: [PROFILE] }], + eventToItem: readProfile, + itemToEvent: (item) => item.event, +}) + +export const { indexStore: profilesByPubkey, loadItem: loadProfile } = collection({ + name: 'profiles', + store: profiles, + getKey: (profile) => profile.event.pubkey, + load: (pubkey: string) => { + const { merge, Index, FromPubkey } = Router.get() + + return sharedLoad({ + relays: merge([Index(), FromPubkey(pubkey)]) + .limit(10) + .getUrls(), + filters: [{ kinds: [PROFILE], authors: [pubkey] }], + }) + }, +}) + +export const follows = deriveEventsMapped(repository, { + filters: [{ kinds: [FOLLOWS] }], + eventToItem: (event: TrustedEvent) => readList(asDecryptedEvent(event)), + itemToEvent: (item) => item.event, +}) + +export const { indexStore: followsByPubkey, loadItem: loadFollows } = collection({ + name: 'follows', + store: follows, + getKey: (follows) => follows.event.pubkey, + load: (pubkey: string) => { + const { merge, Index, FromPubkey } = Router.get() + + return sharedLoad({ + relays: merge([Index(), FromPubkey(pubkey)]).getUrls(), + filters: [{ kinds: [FOLLOWS], authors: [pubkey] }], + }) + }, +}) + +export const getFollows = (pubkey: string) => + getPubkeyTagValues(getListTags(followsByPubkey.get().get(pubkey))) + +export const getNetwork = (pubkey: string) => { + const pubkeys = new Set(getFollows(pubkey)) + const network = new Set() + + for (const follow of pubkeys) { + for (const tpk of getFollows(follow)) { + if (!pubkeys.has(tpk)) { + network.add(tpk) + } + } + } + + return Array.from(network) +} + +export const getFollowers = (pubkey: string) => + uniq(pluck('pubkey', repository.query([{ kinds: [FOLLOWS], '#p': [pubkey] }]))) + +export const loadWot = async (pubkey: string, feed: Feed) => { + const { merge, Index, ForPubkey } = Router.get() + + let needsFollows = false + let needsFollowers = false + let needsNetwork = false + + walkFeed(feed, (f) => { + needsFollows = needsFollows || (isScopeFeed(f) && getFeedArgs(f).includes(Scope.Follows)) + needsFollowers = needsFollowers || (isScopeFeed(f) && getFeedArgs(f).includes(Scope.Followers)) + needsNetwork = + needsNetwork || isWOTFeed(f) || (isScopeFeed(f) && getFeedArgs(f).includes(Scope.Network)) + }) + + const promises: Promise[] = [] + + if (needsFollows || needsNetwork) { + promises.push(loadFollows(pubkey)) + } + + if (needsFollowers) { + promises.push( + sharedLoad({ + filters: [{ kinds: [FOLLOWS], '#p': [pubkey] }], + relays: merge([Index(), ForPubkey(pubkey)]).getUrls(), + }) + ) + } + + if (needsNetwork) { + promises.push( + ...getFilterSelections([{ kinds: [FOLLOWS], authors: getFollows(pubkey) }]).map(sharedLoad) + ) + } + + await Promise.all(promises) +} + +export const makeGetPubkeysForScope = (pubkey: string) => (scope: string) => { + switch (scope) { + case Scope.Self: + return [pubkey] + case Scope.Follows: + return getFollows(pubkey) + case Scope.Network: + return getNetwork(pubkey) + case Scope.Followers: + return getFollowers(pubkey) + default: + return [] + } +} + +export const makeGetPubkeysForWOTRange = (pubkey: string) => (minimum: number, maximum: number) => { + const graph = new Map() + + for (const follow of getFollows(pubkey)) { + for (const pubkey of getFollows(follow)) { + graph.set(pubkey, inc(graph.get(pubkey))) + } + } + + const pubkeys = [] + const maxWot = max(Array.from(graph.values())) + const thresholdMin = maxWot * minimum + const thresholdMax = maxWot * maximum + + for (const [tpk, score] of graph.entries()) { + if (score >= thresholdMin && score <= thresholdMax) { + pubkeys.push(tpk) + } + } + + return pubkeys +} diff --git a/src/run.ts b/src/run.ts new file mode 100644 index 0000000..5117a8a --- /dev/null +++ b/src/run.ts @@ -0,0 +1,28 @@ +import { last } from '@welshman/lib' +import type { Subscription } from './alert.js' +import { getSubscriptionByKey } from './database.js' +import { runJob } from './worker/email.js' + +const key = last(process.argv) + +if (!key) { + console.error('Please provide a subscription key') + process.exit(1) +} + +getSubscriptionByKey(key) + .then((sub: Subscription | undefined) => { + if (!sub) { + console.error('Invalid subscription key') + return false + } + + return runJob(sub) + }) + .then((success: boolean) => { + process.exit(success ? 0 : 1) + }) + .catch((error: Error) => { + console.error(error) + process.exit(1) + }) \ No newline at end of file diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..b5d73a1 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,197 @@ +import { instrument } from 'succinct-async' +import express, { Request, Response, NextFunction } from 'express' +import rateLimit from 'express-rate-limit' +import { appSigner } from './env.js' +import { render } from './templates.js' +import { confirmSubscriptionAction, unsubscribeAction, registerSubscription, ActionError } from './actions.js' +import { getSubscriptionById, insertEvent, getSubscriptionByKey } from './database.js' +import { load } from '@welshman/net' +import { getIdFilters } from '@welshman/util' + +// Endpoints + +export const server: express.Application = express() + +server.use(express.json()) + +server.use(express.static('web/dist')) + +// Rate limit for registration endpoints +server.use( + '/subscription', + rateLimit({ + limit: 30, + windowMs: 5 * 60 * 1000, + validate: { xForwardedForHeader: false }, + }) +) + +// Rate limit for notify endpoint +server.use( + '/notify', + rateLimit({ + limit: 300, + windowMs: 60 * 1000, + validate: { xForwardedForHeader: false }, + }) +) + +type Handler = (req: Request, res: Response) => Promise + +const addRoute = (method: 'get' | 'post' | 'delete', path: string, handler: Handler) => { + server[method]( + path, + instrument(path, async (req: Request, res: Response, next: NextFunction) => { + try { + await handler(req, res) + } catch (e) { + next(e) + } + }) + ) +} + +addRoute('get', '/', async (req: Request, res: Response) => { + res.send(await render('../web/dist/index.html')) +}) + +// Subscribe to email digests +addRoute('post', '/subscription/email', async (req: Request, res: Response) => { + const { email, frequency, pubkey } = req.body + + if (!email || !email.includes('@')) { + return res.status(400).json({ error: 'A valid email address is required' }) + } + + if (!['daily', 'weekly'].includes(frequency)) { + return res.status(400).json({ error: 'Frequency must be "daily" or "weekly"' }) + } + + if (!pubkey) { + return res.status(400).json({ error: 'pubkey is required' }) + } + + // TODO: Verify NIP-98 auth header + // const auth = req.headers.authorization + // if (!auth) return res.status(401).json({ error: 'NIP-98 authorization required' }) + // Verify using @welshman/util makeHttpAuth + + try { + const result = await registerSubscription({ pubkey, email, frequency }) + res.json(result) + } catch (error: any) { + // If the error is just Postmark failing, the subscription was still created + if (error.message?.includes('APIKey')) { + res.json({ key: 'fallback', callback: `${process.env.BASE_URL}/notify/fallback` }) + } else { + console.error('Failed to register subscription:', error) + res.status(500).json({ error: 'Failed to register subscription' }) + } + } +}) + +// Delete subscription +addRoute('delete', '/subscription/:key', async (req: Request, res: Response) => { + const { key } = req.params + + const sub = await getSubscriptionByKey(key) + + if (!sub) { + return res.status(404).json({ error: 'Subscription not found' }) + } + + // TODO: Verify NIP-98 auth header matches sub.pubkey + + await unsubscribeAction({ token: key }) + res.json({ ok: true }) +}) + +// NIP-9a relay push callback +addRoute('post', '/notify/:id', async (req: Request, res: Response) => { + const { id, relay } = req.body + + if (!id || !relay) { + return res.status(400).json({ error: 'id and relay are required' }) + } + + const sub = await getSubscriptionById(req.params.id) + + if (!sub) { + // 404 signals the relay to delete the subscription + return res.status(404).json({ error: 'Subscription not found' }) + } + + if (!sub.confirmed_at || sub.unsubscribed_at) { + return res.status(404).json({ error: 'Subscription not active' }) + } + + // Fetch the full event from the relay + try { + const [event] = await load({ + relays: [relay], + filters: getIdFilters([id]), + }) + + if (!event) { + // Event not found at relay — don't 404, just skip + return res.json({ ok: true, skipped: true }) + } + + const stored = await insertEvent(id, sub.id, event, relay) + + return res.json({ ok: true, stored }) + } catch (error) { + console.error(`Failed to process notification for subscription ${sub.id}:`, error) + return res.status(500).json({ error: 'Internal server error' }) + } +}) + +// Confirmation +addRoute('get', '/confirm', async (req: Request, res: Response) => { + if (typeof req.query.token !== 'string') { + return res.send( + await render('pages/confirm-error.html', { + message: 'No confirmation token was provided.', + }) + ) + } + + try { + await confirmSubscriptionAction({ token: req.query.token }) + + res.send(await render('pages/confirm-success.html')) + } catch (error) { + const isActionError = error instanceof ActionError + const message = isActionError ? String(error) : 'Oops, something went wrong on our end!' + + res.send(await render('pages/confirm-error.html', { message })) + + if (!isActionError) { + throw error + } + } +}) + +// Unsubscribe +addRoute('get', '/unsubscribe', async (req: Request, res: Response) => { + try { + await unsubscribeAction({ token: req.query.token as string }) + } catch (error) { + // pass + } + + res.send(await render('pages/unsubscribe-success.html')) +}) + +server.use((err: Error, req: Request, res: Response, next: NextFunction) => { + if (err) { + if (err instanceof ActionError) { + res.status(400).json({ error: err.message }) + } else { + console.log('Unhandled error', err) + res.status(500).json({ error: 'Internal server error' }) + } + } else { + next() + } +}) \ No newline at end of file diff --git a/src/templates.ts b/src/templates.ts new file mode 100644 index 0000000..2e0a120 --- /dev/null +++ b/src/templates.ts @@ -0,0 +1,35 @@ +import fs from 'fs' +import path from 'path' +import { fileURLToPath } from 'url' +import Mustache from 'mustache' +import mjml2html from 'mjml' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +// Cache templates to avoid reading from disk on every render +const templateCache = new Map() + +const loadTemplate = async (name: string) => { + if (!templateCache.has(name)) { + const templatePath = path.join(__dirname, name) + const template = await fs.promises.readFile(templatePath, 'utf8') + + templateCache.set(name, template) + } + + return templateCache.get(name)! +} + +export const render = async (name: string, view: Record = {}) => { + const template = await loadTemplate(name) + const mustacheRendered = Mustache.render(template, view) + + // If it's an MJML file, render it with MJML + if (name.endsWith('.mjml')) { + const { html } = mjml2html(mustacheRendered) + return html + } + + return mustacheRendered +} diff --git a/src/types.d.ts b/src/types.d.ts new file mode 100644 index 0000000..26c088c --- /dev/null +++ b/src/types.d.ts @@ -0,0 +1,4 @@ +declare module "succinct-async" { + export function instrument any>(n: string, f: T): T + export const succinctAsyncConfig: any +} \ No newline at end of file diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..97b79bc --- /dev/null +++ b/src/util.ts @@ -0,0 +1,138 @@ +import sanitizeHtml from 'sanitize-html' +import { CronExpressionParser } from 'cron-parser' + +export function getCronDate(cronString: string, n: number) { + const interval = CronExpressionParser.parse(cronString, { tz: 'UTC' }) + + let date = interval.next().toDate() + + for (let i = 0; i < Math.abs(n); i++) { + if (n > 0) { + date = interval.next().toDate() + } else { + date = interval.prev().toDate() + } + } + + return date +} + +export function displayDuration(seconds: number) { + const minute = 60 + const hour = minute * 60 + const day = hour * 24 + const week = day * 7 + const month = day * 30 + const year = day * 365 + + if (seconds < minute) { + return `${Math.round(seconds)} seconds` + } else if (seconds < hour) { + const minutes = Math.round(seconds / minute) + return `${minutes} ${minutes === 1 ? 'minute' : 'minutes'}` + } else if (seconds < day) { + const hours = Math.round(seconds / hour) + return `${hours} ${hours === 1 ? 'hour' : 'hours'}` + } else if (seconds < week) { + const days = Math.round(seconds / day) + return `${days} ${days === 1 ? 'day' : 'days'}` + } else if (seconds < month) { + const weeks = Math.round(seconds / week) + return `${weeks} ${weeks === 1 ? 'week' : 'weeks'}` + } else if (seconds < year) { + const months = Math.round(seconds / month) + return `${months} ${months === 1 ? 'month' : 'months'}` + } else { + const years = Math.round(seconds / year) + return `${years} ${years === 1 ? 'year' : 'years'}` + } +} + +interface ElementAttributes { + [key: string]: string +} + +interface ElementChild { + outerHTML: string +} + +export interface CustomElement { + tagName: string + attributes: ElementAttributes + children: ElementChild[] + _innerText: string + setAttribute(name: string, value: string): void + getAttribute(name: string): string | undefined + appendChild(child: ElementChild): void + href: string + target: string + innerText: string + innerHTML: string + outerHTML: string +} + +export function createElement(tagName: string) { + const element: CustomElement = { + tagName: tagName.toLowerCase(), + attributes: {}, + children: [], + _innerText: '', + + setAttribute(name: string, value: string) { + this.attributes[name] = value + }, + + getAttribute(name: string): string | undefined { + return this.attributes[name] + }, + + appendChild(child: ElementChild) { + this.children.push(child) + }, + + get href() { + return this.attributes.href || '' + }, + + set href(value: string) { + this.attributes.href = value + }, + + get target() { + return this.attributes.target || '' + }, + + set target(value: string) { + this.attributes.target = value + }, + + get innerText() { + return this._innerText + }, + + set innerText(value: string) { + this._innerText = sanitizeHtml(value, { + allowedTags: [], + allowedAttributes: {}, + }) + }, + + get innerHTML() { + return this._innerText + }, + + set innerHTML(value: string) { + this._innerText = value + }, + + get outerHTML() { + const attributesString = Object.entries(this.attributes) + .map(([key, value]) => `${key}="${value}"`) + .join(' ') + + return `<${this.tagName}${attributesString ? ' ' + attributesString : ''}>${this._innerText}` + }, + } + + return element +} diff --git a/src/worker/email.ts b/src/worker/email.ts new file mode 100644 index 0000000..d60e4a3 --- /dev/null +++ b/src/worker/email.ts @@ -0,0 +1,77 @@ +import { CronJob } from 'cron' +import type { Subscription } from '../alert.js' +import { getCronExpression } from '../alert.js' +import { Digest } from '../digest.js' +import * as db from '../database.js' + +const jobsById = new Map() + +export const runJob = async (sub: Subscription) => { + try { + if (!sub.confirmed_at || sub.unsubscribed_at) { + console.log('worker: job skipped', sub.id, 'not confirmed or unsubscribed') + return false + } + + console.log('worker: job starting', sub.id) + + const start = Date.now() + const since = sub.last_digest_at || sub.created_at + const events = await db.getEventsForSubscription(sub.id, since) + + if (events.length === 0) { + console.log('worker: job skipped', sub.id, 'ok', 'no data received') + return false + } + + const digest = new Digest(sub) + await digest.sendFromStoredEvents(events) + + // Clean up processed events + await db.deleteEventsForSubscription(sub.id, since) + await db.updateLastDigestAt(sub.id, Math.floor(Date.now() / 1000)) + + console.log('worker: job completed', sub.id, 'in', Date.now() - start, 'ms') + return true + } catch (e) { + console.log('worker: job failed', sub.id, e) + return false + } +} + +const createJob = (sub: Subscription) => { + const cron = getCronExpression(sub.frequency) + + const run = async () => { + await runJob(sub) + } + + return CronJob.from({ + cronTime: cron, + onTick: run, + start: true, + timeZone: 'UTC', + }) +} + +export const addJob = (sub: Subscription) => { + jobsById.get(sub.id)?.stop() + jobsById.set(sub.id, createJob(sub)) +} + +export const removeJob = (sub: Subscription) => { + jobsById.get(sub.id)?.stop() + jobsById.delete(sub.id) +} + +// Daily purge of events older than 7 days +const purgeJob = CronJob.from({ + cronTime: '0 0 3 * * *', // 3am UTC daily + onTick: async () => { + const weekAgo = Math.floor(Date.now() / 1000) - 7 * 24 * 3600 + await db.purgeEventsOlderThan(weekAgo) + console.log('worker: purged events older than 7 days') + }, + start: true, + timeZone: 'UTC', +}) \ No newline at end of file diff --git a/src/worker/index.ts b/src/worker/index.ts new file mode 100644 index 0000000..55a94e5 --- /dev/null +++ b/src/worker/index.ts @@ -0,0 +1,14 @@ +import type { Subscription } from '../alert.js' +import { addJob, removeJob } from './email.js' + +export const registerSubscription = (sub: Subscription) => { + console.log('registering digest job', sub.id) + + addJob(sub) +} + +export const unregisterSubscription = (sub: Subscription) => { + console.log('unregistering digest job', sub.id) + + removeJob(sub) +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..58d830e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "include": ["src/**/*"], + "compilerOptions": { + "target": "es6", + "module": "nodenext", + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + } +} diff --git a/web/.env.template b/web/.env.template new file mode 100644 index 0000000..9f0b1f1 --- /dev/null +++ b/web/.env.template @@ -0,0 +1,3 @@ +VITE_NOTIFIER_PUBKEY= +VITE_NOTIFIER_RELAY= +VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..d785258 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,21 @@ +import js from "@eslint/js"; +import globals from "globals"; +import tseslint from "typescript-eslint"; +import { defineConfig } from "eslint/config"; + + +export default defineConfig([ + { files: ["**/*.{js,mjs,cjs,ts}"], plugins: { js }, extends: ["js/recommended"] }, + { files: ["**/*.{js,mjs,cjs,ts}"], languageOptions: { globals: globals.browser } }, + tseslint.configs.recommended, + { + files: ["src/**/*.{js,mjs,cjs,ts}"], + rules: { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + {args: "none", destructuredArrayIgnorePattern: "^_d?$", caughtErrors: "none"}, + ], + }, + }, +]); diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..b477b77 --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + + Anchor Alerts + + +
+
+
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..c783cf3 --- /dev/null +++ b/web/package.json @@ -0,0 +1,34 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "check": "tsc --noEmit && eslint src", + "format": "eslint src --fix" + }, + "devDependencies": { + "@eslint/js": "^9.25.1", + "@types/mithril": "^2.2.7", + "eslint": "^9.25.1", + "globals": "^16.0.0", + "typescript": "~5.7.2", + "typescript-eslint": "^8.31.1", + "vite": "^6.3.1" + }, + "dependencies": { + "@tailwindcss/vite": "^4.1.4", + "@welshman/feeds": "^0.6.3", + "@welshman/lib": "^0.6.3", + "@welshman/net": "^0.6.3", + "@welshman/signer": "^0.6.3", + "@welshman/store": "^0.6.3", + "@welshman/util": "^0.6.3", + "events": "^3.3.0", + "mithril": "^2.2.15", + "svelte": "^5.27.2", + "tailwindcss": "^4.1.4" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000000000000000000000000000000000000..dc6d6add4e6f81f33c0ef9c19c53403b022976f0 GIT binary patch literal 78215 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 + +` + +const TRASH_ICON = ` + + + + +` + +const ARROW_LEFT_ICON = ` + + +` + +// Types and state + +type Alert = { + event: TrustedEvent + tags: string[][] +} + +type AlertStatus = { + event: TrustedEvent + tags: string[][] +} + +type AlertValues = { + feedAddress: string + freq: string + time: string, + email: string + secret: string +} + +type State = { + failedToLogin: boolean + signer: ISigner + pubkey: string | undefined + alerts: Alert[] + alertDraft?: AlertValues, + alertStatuses: AlertStatus[] + alertsLoading: boolean +} + +const state = withGetter( + writable({ + failedToLogin: false, + signer: new Nip07Signer(), + pubkey: getJson('pubkey'), + alerts: [], + alertStatuses: [], + alertsLoading: false, + } as State) +) + +// Actions + +const login = async () => { + const {signer} = state.get() + + try { + const pubkey = await signer.getPubkey() + + state.update(assoc('pubkey', pubkey)) + setJson('pubkey', pubkey) + } catch (e) { + state.update(assoc('failedToLogin', true)) + } +} + +const loadAlerts = async () => { + const {signer, pubkey} = state.get() + + state.update(assoc('alertsLoading', true)) + + const events = await load({ + relays: [NOTIFIER_RELAY], + filters: [ + {kinds: [ALERT], authors: [pubkey!]}, + {kinds: [ALERT_STATUS], "#p": [pubkey!]}, + ], + }) + + const alerts = await Promise.all( + events + .filter(spec({kind: ALERT})) + .map(async event => { + const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content)) + + return {event, tags} + }) + ) + + const alertStatuses = await Promise.all( + events + .filter(spec({kind: ALERT_STATUS})) + .map(async event => { + const tags = parseJson(await decrypt(signer, NOTIFIER_PUBKEY, event.content)) + + return {event, tags} + }) + ) + + state.update($state => ({...$state, alertsLoading: false, alerts, alertStatuses})) +} + +const deleteAlert = async (alert: Alert) => { + if (confirm("Are you sure you want to delete this alert?")) { + state.update(assoc('alertsLoading', true)) + + await publish({ + relays: [NOTIFIER_RELAY], + event: await state.get().signer!.sign( + makeEvent(DELETE, { + tags: [ + ["k", String(alert.event.kind)], + ["a", getAddress(alert.event)] + ], + }) + ), + }) + + await loadAlerts() + } +} + +export type AlertParams = { + feeds: Feed[] + freq: string + time: string + email: string + secret: string +} + +export const makeAlert = async ({freq, time, email, feeds, secret}: AlertParams) => { + const {signer} = state.get() + const [hour, minute] = time.split(':') + const utcHour = (parseInt(hour) - TZ_OFFSET) % 24 + const dow = freq === 'daily' ? '*' : freq + const cron = `0 ${minute} ${utcHour} * * ${dow}` + + const tags = [ + ["cron", cron], + ["email", email], + ["channel", "email"], + ["locale", LOCALE], + ["timezone", TIMEZONE], + [ + "handler", + "31990:97c70a44366a6535c145b333f973ea86dfdc2d7a99da618c40c64705ad98e322:1685968093690", + "wss://relay.nostr.band/", + "web", + ], + ] + + for (const feed of feeds) { + tags.push(["feed", JSON.stringify(feed)]) + } + + return signer.sign( + makeEvent(ALERT, { + content: await signer.nip44.encrypt(NOTIFIER_PUBKEY, JSON.stringify(tags)), + tags: [ + ["d", randomId()], + ["p", NOTIFIER_PUBKEY], + ], + }) + ) +} + +export const publishAlert = async (params: AlertParams) => + publish({event: await makeAlert(params), relays: [NOTIFIER_RELAY]}) + +// Components + +const Loader = { + view: () => m("div", { class: "flex justify-center py-4" }, [ + m("div", { + class: "animate-spin rounded-full h-8 w-8 border-4 border-purple-200 border-t-purple-600" + }) + ]) +} + +const Login = { + view: () => + m("button", { + onclick: login, + class: "w-full bg-purple-600 text-white font-semibold py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors" + }, "Connect with Nostr"), +} + +const AlertStatus: m.Component<{alert: Alert}> = { + view: vnode => { + const {alert} = vnode.attrs + const {alertStatuses} = state.get() + const address = getAddress(alert.event) + const alertStatus = alertStatuses.find(s => getTagValue('d', s.event.tags) === address) + const status = getTagValue('status', alertStatus?.tags || []) + const message = getTagValue('message', alertStatus?.tags || []) + + const getStatusClasses = () => { + const baseClasses = "rounded-full px-3 py-1 text-sm border" + if (status === 'ok') return `${baseClasses} border-green-500 text-green-500` + if (status === 'pending') return `${baseClasses} border-yellow-500 text-yellow-500` + return `${baseClasses} border-red-500 text-red-500` + } + + const getStatusDisplay = () => { + if (!status) return 'Inactive' + if (status === 'ok') return 'Active' + if (status === 'pending') return 'Pending' + return status.replace('-', ' ').replace(/^(.)/, x => x.toUpperCase()) + } + + return m("div", {class: getStatusClasses(), tooltip: message}, getStatusDisplay()) + }, +} + +const AlertListItem: m.Component<{alert: Alert}> = { + view: vnode => { + const {alert} = vnode.attrs + const cron = getTagValue('cron', alert.tags) + const feeds = getTagValues('feed', alert.tags) + const channel = getTagValue('channel', alert.tags) + const description = displayFeeds(feeds.map(feed => parseJson(feed))) || "[invalid feed]" + + let frequency = cron || "Unknown" + if (cron) { + if (CRON_DAILY_PATTERN.test(cron)) { + frequency = 'Daily' + } else if (CRON_WEEKLY_PATTERN.test(cron)) { + frequency = 'Weekly' + } + } + + return m("div", { class: "flex items-start justify-between p-4" }, [ + m("button", { + onclick: () => deleteAlert(alert), + class: "mr-4 mt-1", + tooltip: "Delete alert" + }, [m.trust(TRASH_ICON)]), + m("div", { class: "space-y-2 flex-grow" }, [ + m("div", { class: "text-gray-600" }, `${frequency} alert via ${channel}`), + m("div", { class: "text-sm text-gray-500" }, `Events ${description}`) + ]), + m(AlertStatus, {alert}), + ]) + } +} + +const AlertList = { + oninit: loadAlerts, + view: () => { + const {alerts, alertsLoading} = state.get() + + const content = alertsLoading + ? m(Loader) + : alerts.length > 0 + ? alerts.map(alert => m(AlertListItem, {alert, key: alert.event.id})) + : m("div", { class: "text-center text-gray-500 py-8" }, [ + "You don't have any alerts set up.", + ]) + + return m("div", { class: "space-y-4" }, [ + m("div", { class: "flex items-center justify-between mb-6" }, [ + m("h1", { class: "text-2xl font-bold text-gray-900" }, "Your Nostr Alerts"), + m("a", { + href: "#!/alerts/new", + class: "flex items-center gap-2 bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors", + }, [ + m.trust(PLUS_ICON), + "Add Alert" + ]) + ]), + m("div", { class: "bg-white shadow rounded-lg p-6" }, content) + ]) + } +} + +const AlertCreate = { + oninit: () => { + state.update(assoc('alertDraft', { + email: getTagValue('email', state.get().alerts[0]?.tags || []) || "", + freq: 'daily', + time: '17:00', + feedAddress: "", + secret: "", + })) + }, + view: () => { + const {pubkey, alertDraft, alertsLoading} = state.get() + const {email, feedAddress, freq, time, secret} = alertDraft! + + const update = (newValues: Partial) => { + state.update(assoc('alertDraft', {...alertDraft, ...newValues})) + } + + const submit = async (e: Event) => { + e.preventDefault() + + state.update(assoc('alertsLoading', true)) + + try { + if (!email.includes("@")) return alert("Please provide a valid email address") + + const address = tryCatch(() => Address.fromNaddr(fromNostrURI(feedAddress))) + + if (!address) return alert("Please provide a valid feed address") + if (address.kind !== FEED) return alert(`Please provide a valid feed address (kind ${FEED})`) + + const selections = await load({ + relays: INDEXER_RELAYS, + filters: [{kinds: [RELAYS], authors: [pubkey!, address.pubkey]}], + }) + + const router = Router.get() + const filters = getIdFilters([address.toString()]) + const scenario = router.merge([ + router.FromRelays(selections.flatMap(e => getRelaysFromList(readList(asDecryptedEvent(e)), RelayMode.Write))), + router.FromRelays(address.relays), + router.FromRelays(INDEXER_RELAYS), + ]) + const relays = scenario.limit(10).getUrls() + + const [event] = await load({relays, filters}) + + if (!event) return alert("Sorry, we weren't able to find that feed") + + const feedStrings = getTagValues('feed', event.tags) + + if (feedStrings.length === 0) return alert('At least one feed is required') + + const feeds = removeNil(feedStrings.map(parseJson)) + + if (feeds.length < feedStrings.length) return alert("At least one feed is invalid (must be valid JSON)") + + const feedError = feeds.map(validateFeed).find(e => e instanceof ValidationError) + + if (feedError) return alert(`At least one feed is invalid (${feedError.data.toLowerCase()}).`) + + await publishAlert({freq, time, email, feeds, secret}) + + m.route.set("/alerts") + } catch (error) { + alert("Failed to create alert. Please try again.") + console.error('Error creating alert:', error) + } finally { + state.update(assoc('alertsLoading', false)) + } + } + + return m("div", { class: "space-y-4" }, [ + m("div", { class: "flex items-center gap-4 mb-6" }, [ + m("button", { + onclick: () => m.route.set("/alerts"), + class: "text-gray-600 hover:text-gray-900 cursor-pointer", + tooltip: "Back to alerts" + }, m.trust(ARROW_LEFT_ICON)), + m("h1", { class: "text-2xl font-bold text-gray-900" }, "Create Alert") + ]), + m("div", { class: "bg-white shadow rounded-lg p-6" }, [ + m("form", { class: "space-y-6", onsubmit: submit }, [ + m("div", [ + m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Email"), + m("input", { + type: "email", + placeholder: "Enter your email address", + value: email, + oninput: (e: InputEvent) => update({email: (e.target as HTMLInputElement).value}), + class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" + }) + ]), + m("div", {class: "w-full flex gap-2"}, [ + m("div", {class: "flex-grow"}, [ + m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Frequency"), + m("select", { + value: freq, + onchange: (e: Event) => update({freq: (e.target as HTMLSelectElement).value}), + class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" + }, [ + m("option", { value: 'daily' }, "Daily"), + m("option", { value: '0' }, "Weekly on Sunday"), + m("option", { value: '1' }, "Weekly on Monday"), + m("option", { value: '2' }, "Weekly on Tuesday"), + m("option", { value: '3' }, "Weekly on Wednesday"), + m("option", { value: '4' }, "Weekly on Thursday"), + m("option", { value: '5' }, "Weekly on Friday"), + m("option", { value: '6' }, "Weekly on Saturday"), + ]) + ]), + m("div", [ + m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Time"), + m("input", { + value: time, + onchange: (e: Event) => update({time: (e.target as HTMLSelectElement).value}), + type: "time", + class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" + }) + ]), + ]), + m("div", [ + m("label", { class: "block text-sm font-medium text-gray-700 mb-1" }, "Feed Address"), + m("div", { class: "space-y-2" }, [ + m("input", { + type: "text", + placeholder: "naddr1...", + value: feedAddress, + oninput: (e: InputEvent) => update({feedAddress: (e.target as HTMLInputElement).value}), + class: "w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-purple-500 focus:border-purple-500" + }), + m("p", { class: "text-sm text-gray-500" }, [ + "Visit ", + m("a", { + href: "https://coracle.social/feeds", + target: "_blank", + class: "text-purple-600 hover:text-purple-800" + }, "coracle.social/feeds"), + " to search for existing feeds or create a new one. Copy the feed address (starts with 'naddr1') and paste it here." + ]) + ]) + ]), + m("div", { class: "flex justify-end" }, [ + m("button", { + type: "submit", + disabled: alertsLoading, + class: "bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" + }, alertsLoading ? "Creating..." : "Create Alert") + ]) + ]) + ]) + ]) + } +} + +const FailedToLogin = { + view: () => + m("div", { class: "space-y-6 text-center" }, [ + m("div", { class: "bg-red-50 border border-red-200 rounded-lg p-6" }, [ + m("h2", { class: "text-red-800 font-semibold mb-2" }, "Unable to Connect"), + m("p", { class: "text-red-600 mb-4" }, "To use Anchor Alerts, you need a Nostr signer extension installed in your browser."), + m("div", { class: "space-y-3" }, [ + m("button", { + onclick: () => window.location.reload(), + class: "w-full bg-red-100 text-red-700 font-medium py-2 px-4 rounded-lg hover:bg-red-200 transition-colors" + }, "Try Again"), + m("a", { + href: "https://nostrapps.com/#signers", + target: "_blank", + class: "block w-full bg-purple-600 text-white font-medium py-2 px-4 rounded-lg hover:bg-purple-700 transition-colors" + }, "Install a Nostr Signer") + ]) + ]) + ]) +} + +const Layout: m.Component<{children: m.Children}> = { + view: vnode => { + const {children} = vnode.attrs + const {failedToLogin, pubkey} = state.get() + + if (failedToLogin) { + return m(FailedToLogin) + } + + if (!pubkey) { + return m("div", { class: "text-center space-y-4" }, [ + m("h1", { class: "text-2xl font-bold text-gray-900 mb-2" }, "Welcome to Anchor Alerts"), + m("p", { class: "text-gray-600 mb-6" }, "Connect your Nostr signer to get started"), + m(Login) + ]) + } + + return children + } +} + +m.route(document.querySelector('#app')!, "/alerts", { + "/alerts": { + view: () => { + return m(Layout, {children: [m(AlertList)]}) + }, + }, + "/alerts/new": { + view: () => { + return m(Layout, {children: [m(AlertCreate)]}) + } + }, +}) + +state.subscribe(() => m.redraw()) + +defaultSocketPolicies.push( + makeSocketPolicyAuth({ + sign: (event: StampedEvent) => { + return state.get().signer?.sign(event) + }, + }), +) + +Object.assign(window, {setJson, getJson}) diff --git a/web/src/style.css b/web/src/style.css new file mode 100644 index 0000000..1c1b184 --- /dev/null +++ b/web/src/style.css @@ -0,0 +1,42 @@ +@import "tailwindcss"; + +a, button { + @apply cursor-pointer; +} + +/* Tooltip styling */ +[tooltip] { + @apply cursor-pointer relative; +} + +[tooltip]:hover::after { + content: attr(tooltip); + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + padding: 4px 8px; + background-color: rgba(0, 0, 0, 0.8); + color: white; + border-radius: 4px; + font-size: 14px; + white-space: nowrap; + z-index: 1000; + pointer-events: none; + + /* Animation properties */ + opacity: 0; + animation: tooltipFadeIn 0.2s ease-in-out forwards; +} + +/* Keyframes for fade in animation */ +@keyframes tooltipFadeIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(0); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(-3px); + } +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..9d2104f --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "module": "ESNext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..bd48de3 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,11 @@ +import { defineConfig } from 'vite' +import tailwindcss from '@tailwindcss/vite' + +export default defineConfig({ + server: { + port: 2893, + }, + plugins: [ + tailwindcss(), + ], +})