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
This commit is contained in:
commit
21e7058862
45 changed files with 2483 additions and 0 deletions
10
.env.template
Normal file
10
.env.template
Normal file
|
|
@ -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
|
||||
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
pnpm-lock.yaml -diff
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.env
|
||||
node_modules
|
||||
yarn-error.log
|
||||
anchor.db
|
||||
*firebase*
|
||||
*.p8
|
||||
dist
|
||||
emails/*.html
|
||||
mailship.db
|
||||
dist/
|
||||
1
.nvmrc
Normal file
1
.nvmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
lts/jod
|
||||
4
.prettierignore
Normal file
4
.prettierignore
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
node_modules
|
||||
dist
|
||||
*.html
|
||||
*.mjml
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -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.
|
||||
84
README.md
Normal file
84
README.md
Normal file
|
|
@ -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.
|
||||
12
build-in-production.sh
Executable file
12
build-in-production.sh
Executable file
|
|
@ -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
|
||||
31
eslint.config.mjs
Normal file
31
eslint.config.mjs
Normal file
|
|
@ -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"},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
68
package.json
Normal file
68
package.json
Normal file
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
pnpm-lock.yaml
Normal file
BIN
pnpm-lock.yaml
Normal file
Binary file not shown.
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
allowBuilds:
|
||||
bcrypt: true
|
||||
sqlite3: true
|
||||
20
remove-pnpm-overrides.js
Normal file
20
remove-pnpm-overrides.js
Normal file
|
|
@ -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}`)
|
||||
}
|
||||
63
src/actions.ts
Normal file
63
src/actions.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
},
|
||||
)
|
||||
35
src/alert.ts
Normal file
35
src/alert.ts
Normal file
|
|
@ -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`
|
||||
}
|
||||
265
src/database.ts
Normal file
265
src/database.ts
Normal file
|
|
@ -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<string, any>
|
||||
|
||||
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 = <T=Row>(query: string, params: Param[] = []) =>
|
||||
new Promise<T[]>((resolve, reject) => {
|
||||
db.all(query, params, (err, rows: T[]) => (err ? reject(err) : resolve(rows)))
|
||||
})
|
||||
|
||||
// prettier-ignore
|
||||
const get = <T=Row>(query: string, params: Param[] = []) =>
|
||||
new Promise<T | undefined>((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<T>(p: T | Promise<T>) {
|
||||
return (await p)!
|
||||
}
|
||||
|
||||
// Migrations
|
||||
|
||||
export const migrate = () =>
|
||||
new Promise<void>(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<StoredEvent>(
|
||||
`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])
|
||||
},
|
||||
)
|
||||
155
src/digest.ts
Normal file
155
src/digest.ts
Normal file
|
|
@ -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('<bech32>')) {
|
||||
return handler.replace('<bech32>', 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',
|
||||
})
|
||||
}
|
||||
75
src/emails/digest.mjml
Normal file
75
src/emails/digest.mjml
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
<mjml>
|
||||
<mj-head>
|
||||
<mj-title>New Activity</mj-title>
|
||||
<mj-style inline="inline">
|
||||
.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; }
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body>
|
||||
<mj-section background-color="#f0f0f0" padding="20px">
|
||||
<mj-column>
|
||||
<mj-text css-class="header">Hello {{name}},</mj-text>
|
||||
<mj-text css-class="subheader">
|
||||
Below is a summary of activity over the last {{Duration}}.
|
||||
We found {{Total}} new posts from {{TopProfiles}}.
|
||||
</mj-text>
|
||||
<mj-divider border-color="#cccccc" />
|
||||
|
||||
{{#HasPopular}}
|
||||
<mj-text css-class="section-header">Most Popular</mj-text>
|
||||
{{#Popular}}
|
||||
<mj-text>
|
||||
<div class="event-item">
|
||||
<div class="event-meta">
|
||||
<div class="event-meta-left">
|
||||
{{#Icon}}<img src="{{Icon}}" class="profile-image" />{{/Icon}}
|
||||
<span class="event-author">{{Name}}</span>
|
||||
<span class="event-timestamp">at {{Timestamp}}</span>
|
||||
</div>
|
||||
<div class="event-link">
|
||||
<a href="{{Link}}">View post</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-content">{{{Content}}}</div>
|
||||
<div class="event-stats">
|
||||
<span class="stat-item">
|
||||
<img class="stat-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAASFBMVEUAAAAiIkQdJ04bJkwbKU0dJkscKEwbJ00cKEsdJkwcJ0wbKEscJkwcJ0scJ00bJ00dJk0bJ0wcJkwcKE0cJ0wcJ0wcJ0z///8ZxKH0AAAAF3RSTlMADxovOD1AQlJXW19ld4iWoKizwenq9eQPMKMAAAABYktHRBcL1piPAAAAOElEQVQY02NgoBZg40fls4qw4+WziAnxggA3I1xAmA8EBEU5YEaKsoFpZlEmNBEBHjRruTip4wsADUgCEGuuAxQAAAAASUVORK5CYII=" />
|
||||
{{Replies}}
|
||||
</span>
|
||||
<span class="stat-item">
|
||||
<img class="stat-icon" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAn1BMVEUAAAAAAIAAAFUzMzMrK1UkJEkVK1UkJEkiIkQgIFAeLUscK0cbKFEbJEkaI08aK00ZKUocJUobKU0bKEwaKEsaJ04bJ0sbJ0sdJ0wcJ00cJk0cJkwcJk0bKEwbKEscKEwcJ00cJ0wcJ0wcJ0sbJk0bJ0wcJ0wcJ0sbJ0wdJ0wcJ00cKE0cJ0wbJ0wbJ0scJ0wcJ0wcJ0wcJ0wcJ0z///++FK4PAAAANHRSTlMAAgMFBgcMDg8QERITHB0eHzc4OTo7QVVhY2RleHl6h4iJiouMnq+wsbK+wcLD3+Dh4vHyB17REwAAAAFiS0dENKmx6f0AAACTSURBVBgZBcEJQoJAAADAIUhQkjxKJZdKyzPLXfj/35oBPDZNAYCXy5BSf14CHr7um5KyjfsMfP9MgPq2h9f7BKBOC1w2ALydKIaSLtAFqj43+0MIhID4bBYBIDWKoQJg3OecW9aH1eqwZnvEMtZG79drN1KnOexuNfD0+wGyXWwrxtv0mQEWpz6l/jgHQD6d5sA/VqYMPJkJl3UAAAAASUVORK5CYII=" />
|
||||
{{Reactions}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</mj-text>
|
||||
{{/Popular}}
|
||||
<mj-divider border-color="#cccccc" />
|
||||
{{/HasPopular}}
|
||||
|
||||
<mj-text>
|
||||
You're receiving this email because you subscribed to notifications.
|
||||
We'll continue to send you updates based on your subscription preferences.
|
||||
</mj-text>
|
||||
|
||||
<mj-button background-color="#F45E43" color="#ffffff" href="{{unsubscribeUrl}}">
|
||||
Unsubscribe
|
||||
</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
40
src/env.ts
Normal file
40
src/env.ts
Normal file
|
|
@ -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)
|
||||
26
src/index.ts
Normal file
26
src/index.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
35
src/mailer.ts
Normal file
35
src/mailer.ts
Normal file
|
|
@ -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: `
|
||||
<h3>Welcome to ${MAILSHIP_NAME}!</h3>
|
||||
<p>Please confirm that you would like to receive ${sub.frequency} digests by clicking the link below:</p>
|
||||
<p><a href="${href}">Confirm Digest</a></p>
|
||||
`,
|
||||
TextBody: `Please confirm that you would like to receive ${sub.frequency} digests by visiting: ${href}`,
|
||||
})
|
||||
}
|
||||
|
||||
export const sendDigest = async (sub: Subscription, variables: Record<string, any>) => {
|
||||
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}`,
|
||||
}),
|
||||
})
|
||||
}
|
||||
34
src/pages/confirm-error.html
Normal file
34
src/pages/confirm-error.html
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Confirmation Failed</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.message {
|
||||
margin-top: 1rem;
|
||||
color: #e74c3c;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Confirmation Failed</h2>
|
||||
<div class="message">{{message}}</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
36
src/pages/confirm-success.html
Normal file
36
src/pages/confirm-success.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Confirmation Successful</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.message {
|
||||
margin-top: 1rem;
|
||||
color: #2ecc71;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Email Alert Confirmed</h2>
|
||||
<div class="message">
|
||||
Your alert has been successfully confirmed. You will now receive notifications.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
36
src/pages/unsubscribe.html
Normal file
36
src/pages/unsubscribe.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Unsubscribe Successful</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: system-ui, sans-serif;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
}
|
||||
.message {
|
||||
margin-top: 1rem;
|
||||
color: #2ecc71;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h2>Unsubscribe Successful</h2>
|
||||
<div class="message">
|
||||
You have been successfully unsubscribed from all notifications.
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
199
src/repository.ts
Normal file
199
src/repository.ts
Normal file
|
|
@ -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<PublishedList>(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<PublishedProfile>(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<PublishedList>(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<string>()
|
||||
|
||||
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<string>('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<any>[] = []
|
||||
|
||||
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<string, number>()
|
||||
|
||||
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
|
||||
}
|
||||
28
src/run.ts
Normal file
28
src/run.ts
Normal file
|
|
@ -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)
|
||||
})
|
||||
197
src/server.ts
Normal file
197
src/server.ts
Normal file
|
|
@ -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<any>
|
||||
|
||||
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()
|
||||
}
|
||||
})
|
||||
35
src/templates.ts
Normal file
35
src/templates.ts
Normal file
|
|
@ -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<string, string>()
|
||||
|
||||
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<string, any> = {}) => {
|
||||
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
|
||||
}
|
||||
4
src/types.d.ts
vendored
Normal file
4
src/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
declare module "succinct-async" {
|
||||
export function instrument<T extends (...args: any[]) => any>(n: string, f: T): T
|
||||
export const succinctAsyncConfig: any
|
||||
}
|
||||
138
src/util.ts
Normal file
138
src/util.ts
Normal file
|
|
@ -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}</${this.tagName}>`
|
||||
},
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
77
src/worker/email.ts
Normal file
77
src/worker/email.ts
Normal file
|
|
@ -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<string, CronJob>()
|
||||
|
||||
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',
|
||||
})
|
||||
14
src/worker/index.ts
Normal file
14
src/worker/index.ts
Normal file
|
|
@ -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)
|
||||
}
|
||||
12
tsconfig.json
Normal file
12
tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"include": ["src/**/*"],
|
||||
"compilerOptions": {
|
||||
"target": "es6",
|
||||
"module": "nodenext",
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true
|
||||
}
|
||||
}
|
||||
3
web/.env.template
Normal file
3
web/.env.template
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
VITE_NOTIFIER_PUBKEY=
|
||||
VITE_NOTIFIER_RELAY=
|
||||
VITE_INDEXER_RELAYS=purplepag.es,relay.damus.io,relay.nostr.band
|
||||
24
web/.gitignore
vendored
Normal file
24
web/.gitignore
vendored
Normal file
|
|
@ -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?
|
||||
21
web/eslint.config.js
Normal file
21
web/eslint.config.js
Normal file
|
|
@ -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"},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
15
web/index.html
Normal file
15
web/index.html
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Anchor Alerts</title>
|
||||
</head>
|
||||
<body class="bg-gray-50 min-h-screen">
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div id="app" class="max-w-2xl mx-auto"></div>
|
||||
</div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
web/package.json
Normal file
34
web/package.json
Normal file
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
BIN
web/pnpm-lock.yaml
Normal file
BIN
web/pnpm-lock.yaml
Normal file
Binary file not shown.
534
web/src/main.ts
Normal file
534
web/src/main.ts
Normal file
|
|
@ -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 = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 5V19M5 12H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
const TRASH_ICON = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M20.5 6H3.49991" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M18.8333 8.5L18.3734 15.3991C18.1964 18.054 18.1079 19.3815 17.2429 20.1907C16.3779 21 15.0475 21 12.3867 21H11.6133C8.95252 21 7.62212 21 6.75711 20.1907C5.8921 19.3815 5.80361 18.054 5.62661 15.3991L5.16667 8.5" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M6.5 6C6.55588 6 6.58382 6 6.60915 5.99936C7.43259 5.97849 8.15902 5.45491 8.43922 4.68032C8.44784 4.65649 8.45667 4.62999 8.47434 4.57697L8.57143 4.28571C8.65431 4.03708 8.69575 3.91276 8.75071 3.8072C8.97001 3.38607 9.37574 3.09364 9.84461 3.01877C9.96213 3 10.0932 3 10.3553 3H13.6447C13.9068 3 14.0379 3 14.1554 3.01877C14.6243 3.09364 15.03 3.38607 15.2493 3.8072C15.3043 3.91276 15.3457 4.03708 15.4286 4.28571L15.5257 4.57697C15.5433 4.62992 15.5522 4.65651 15.5608 4.68032C15.841 5.45491 16.5674 5.97849 17.3909 5.99936C17.4162 6 17.4441 6 17.5 6" stroke="#1C274C" stroke-width="1.5"/>
|
||||
</svg>`
|
||||
|
||||
const ARROW_LEFT_ICON = `
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M19 12H5M5 12L12 19M5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>`
|
||||
|
||||
// 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<AlertValues>) => {
|
||||
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})
|
||||
42
web/src/style.css
Normal file
42
web/src/style.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
1
web/src/vite-env.d.ts
vendored
Normal file
1
web/src/vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
22
web/tsconfig.json
Normal file
22
web/tsconfig.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
11
web/vite.config.js
Normal file
11
web/vite.config.js
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
export default defineConfig({
|
||||
server: {
|
||||
port: 2893,
|
||||
},
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
],
|
||||
})
|
||||
Loading…
Reference in a new issue