commit 21e7058862b173a5b296ef4416151ad118360b2a Author: Agent Date: Tue Aug 18 12:21:22 2026 -0400 Initial mailship fork from anchor 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 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 0000000..8893fa3 Binary files /dev/null and b/pnpm-lock.yaml differ diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..c51f223 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + bcrypt: true + sqlite3: true diff --git a/remove-pnpm-overrides.js b/remove-pnpm-overrides.js new file mode 100644 index 0000000..07a47d2 --- /dev/null +++ b/remove-pnpm-overrides.js @@ -0,0 +1,20 @@ +// This script is necessary for installing stuff on a host, since our links don't exist there. + +import fs from "fs" + +const pkgName = process.argv[2] + +if (!pkgName?.endsWith('package.json')) { + console.log("File passed was not a package.json file") + process.exit(1) +} + +const pkg = JSON.parse(fs.readFileSync(pkgName, "utf8")) + +if (pkg.pnpm && pkg.pnpm.overrides) { + delete pkg.pnpm.overrides + fs.writeFileSync(pkgName, JSON.stringify(pkg, null, 2) + "\n") + console.log(`Removed pnpm.overrides from ${pkgName}`) +} else { + console.log(`No pnpm.overrides found in ${pkgName}`) +} diff --git a/src/actions.ts b/src/actions.ts new file mode 100644 index 0000000..459cd01 --- /dev/null +++ b/src/actions.ts @@ -0,0 +1,63 @@ +import { instrument } from 'succinct-async' +import type { Subscription } from './alert.js' +import { getCronExpression } from './alert.js' +import * as mailer from './mailer.js' +import * as worker from './worker/index.js' +import * as db from './database.js' + +export class ActionError extends Error { + toString() { + return this.message + } +} + +export type RegisterSubscriptionParams = { + pubkey: string + email: string + frequency: string +} + +export const registerSubscription = instrument( + 'actions.registerSubscription', + async ({ pubkey, email, frequency }: RegisterSubscriptionParams) => { + 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 0000000..dc6d6ad Binary files /dev/null and b/web/pnpm-lock.yaml differ diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..14bba78 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,534 @@ +import './style.css' + +import m from "mithril" +import {writable} from 'svelte/store' +import {getJson, removeNil, spec, parseJson, setJson, assoc, randomId, TIMEZONE, tryCatch, LOCALE} from '@welshman/lib' +import {withGetter} from '@welshman/store' +import {Router} from '@welshman/router' +import {validateFeed, ValidationError, displayFeeds, Feed} from '@welshman/feeds' +import {getAddress, getRelaysFromList, RelayMode, readList, asDecryptedEvent, normalizeRelayUrl, getTagValue, getTagValues, makeEvent, DELETE, TrustedEvent, StampedEvent, FEED, Address, getIdFilters, fromNostrURI, RELAYS} from '@welshman/util' +import {load, publish, defaultSocketPolicies, makeSocketPolicyAuth} from '@welshman/net' +import type {ISigner} from '@welshman/signer' +import {Nip07Signer, decrypt} from '@welshman/signer' + +// Constants + +const NOTIFIER_PUBKEY = import.meta.env.VITE_NOTIFIER_PUBKEY + +const NOTIFIER_RELAY = normalizeRelayUrl(import.meta.env.VITE_NOTIFIER_RELAY) + +const INDEXER_RELAYS = import.meta.env.VITE_INDEXER_RELAYS.split(',').map(normalizeRelayUrl) + +const ALERT = 32830 + +const ALERT_STATUS = 32831 + +const TZ_OFFSET = parseInt(TIMEZONE.split(':')[0]!) + +const CRON_DAILY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* \*$/ + +const CRON_WEEKLY_PATTERN = /^0 \d{1,2} \d{1,2} \* \* 1$/ + +const PLUS_ICON = ` + + +` + +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(), + ], +})