diff --git a/.eslintrc.json b/.eslintrc.json index f23b233..40eaa8f 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -60,7 +60,6 @@ "no-lone-blocks": "error", "no-loop-func": "error", "no-multi-spaces": "error", - "no-new": "error", "no-new-wrappers": "error", "no-octal": "error", "no-octal-escape": "error", diff --git a/packages/website/astro.config.ts b/packages/website/astro.config.ts index 922c61f..a478a90 100644 --- a/packages/website/astro.config.ts +++ b/packages/website/astro.config.ts @@ -26,11 +26,16 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ +// Astro only uses process.env for variables visible during build time +// As I do not want anything from .env to be inlined in server bundle, I load everything with dotenv +import 'dotenv/config' + import { defineConfig } from 'astro/config' import tailwind from '@astrojs/tailwind' import node from '@astrojs/node' export default defineConfig({ + site: 'https://pronoundb.org/', output: 'server', integrations: [ tailwind() ], adapter: node({ mode: 'standalone' }), diff --git a/packages/website/package.json b/packages/website/package.json index 4f75e77..27d354e 100644 --- a/packages/website/package.json +++ b/packages/website/package.json @@ -25,6 +25,7 @@ "@typescript-eslint/eslint-plugin": "^5.49.0", "@typescript-eslint/parser": "^5.49.0", "astro": "^2.0.2", + "dotenv": "^16.0.3", "eslint": "^8.32.0", "feather-icons": "^4.29.0", "simple-icons": "^8.3.0", diff --git a/packages/website/src/layouts/Layout.astro b/packages/website/src/layouts/Layout.astro index 049ba7e..bd79269 100644 --- a/packages/website/src/layouts/Layout.astro +++ b/packages/website/src/layouts/Layout.astro @@ -41,7 +41,11 @@ export interface Props { const DEFAULT_DESCRIPTION = 'PronounDB is a browser extension that helps people know each other\'s pronouns easily and instantly.' -const { title, description, flash } = Astro.props +const { title: t, description: d, flash } = Astro.props +const description = d || DEFAULT_DESCRIPTION +const title = t ? `${t} • PronounDB` : 'PronounDB' +const canonicalUrl = new URL(Astro.url.pathname, Astro.site) + const [ flashIcon, flashColor ] = flash?.startsWith('S_') ? [ checkSvg, 'text-green-700 border-green-700 dark:text-green-400 dark:border-green-400' ] : [ alertSvg, 'text-red-600 border-red-600 dark:text-red-orange dark:border-red-orange' ] @@ -50,14 +54,23 @@ const [ flashIcon, flashColor ] = flash?.startsWith('S_') - + + + {title} + + + + + + + + - - - {title ? `${title} • PronounDB` : 'PronounDB'} - - + + + +
diff --git a/packages/website/src/pages/api/v1/lookup-bulk.ts b/packages/website/src/pages/api/v1/lookup-bulk.ts index 3f6f5f5..3125c05 100644 --- a/packages/website/src/pages/api/v1/lookup-bulk.ts +++ b/packages/website/src/pages/api/v1/lookup-bulk.ts @@ -62,8 +62,8 @@ export async function get (ctx: APIContext) { const res = Object.create(null) let user: PronounsOfUser | null while ((user = await cursor.next())) { - res[user.id] = user.pronouns - ids.delete(user.id) + res[user.account.id] = user.pronouns + ids.delete(user.account.id) } await cursor.close() diff --git a/packages/website/src/pages/me/delete.ts b/packages/website/src/pages/me/delete.ts index 70aee1a..a5a2d79 100644 --- a/packages/website/src/pages/me/delete.ts +++ b/packages/website/src/pages/me/delete.ts @@ -27,9 +27,10 @@ */ import type { APIContext } from 'astro' +import { DeletedAccountCount } from '@server/metrics.js' import { authenticate, validateCsrf } from '@server/auth.js' -import { setFlash } from '@server/flash.js' import { deleteAccount } from '@server/database/account.js' +import { setFlash } from '@server/flash.js' export async function post (ctx: APIContext) { const user = await authenticate(ctx) @@ -43,6 +44,7 @@ export async function post (ctx: APIContext) { return ctx.redirect('/me') } + DeletedAccountCount.inc() deleteAccount(user._id) ctx.cookies.delete('token') setFlash(ctx, 'S_ACC_DELETED') diff --git a/packages/website/src/pages/me/unlink-account.ts b/packages/website/src/pages/me/unlink-account.ts index f7998bb..98bef32 100644 --- a/packages/website/src/pages/me/unlink-account.ts +++ b/packages/website/src/pages/me/unlink-account.ts @@ -27,9 +27,10 @@ */ import type { APIContext } from 'astro' +import { LinkedAccountsRemovalCount } from '@server/metrics.js' import { authenticate, validateCsrf } from '@server/auth.js' -import { setFlash } from '@server/flash.js' import { removeLinkedAccount } from '@server/database/account.js' +import { setFlash } from '@server/flash.js' export async function post (ctx: APIContext) { const user = await authenticate(ctx) @@ -50,6 +51,7 @@ export async function post (ctx: APIContext) { return new Response('400: Bad request', { status: 400 }) } + LinkedAccountsRemovalCount.inc({ platform: platform }) removeLinkedAccount(user._id, platform, id) return ctx.redirect('/me') } diff --git a/packages/website/src/pages/oauth/[platform]/callback.ts b/packages/website/src/pages/oauth/[platform]/callback.ts index 5da2c14..07e68e5 100644 --- a/packages/website/src/pages/oauth/[platform]/callback.ts +++ b/packages/website/src/pages/oauth/[platform]/callback.ts @@ -28,6 +28,7 @@ import type { APIContext } from 'astro' +import { CreatedAccountCount, LinkedAccountsAddCount } from '@server/metrics.js' import { generateToken, authenticate } from '@server/auth.js' import { type FlashMessage, setFlash } from '@server/flash.js' import { type ExternalAccount, createAccount, findByExternalAccount, addLinkedAccount } from '@server/database/account.js' @@ -81,6 +82,7 @@ export async function get (ctx: APIContext) { } if (!existingAccount) { + LinkedAccountsAddCount.inc({ platform: external.platform }) await addLinkedAccount(user!._id, external) } @@ -96,6 +98,11 @@ export async function get (ctx: APIContext) { return ctx.redirect('/') } + if (intent === 'register') { + CreatedAccountCount.inc({ platform: external.platform }) + LinkedAccountsAddCount.inc({ platform: external.platform }) + } + const authToken = generateToken({ id: account.toString() }) ctx.cookies.set('token', authToken, { path: '/', maxAge: 365 * 24 * 3600, httpOnly: true, secure: import.meta.env.PROD }) if (intent === 'register') setFlash(ctx, 'S_REGISTERED') diff --git a/packages/website/src/pages/shields/[id].json.ts b/packages/website/src/pages/shields/[id].json.ts index 6bb78c5..08eb0a5 100644 --- a/packages/website/src/pages/shields/[id].json.ts +++ b/packages/website/src/pages/shields/[id].json.ts @@ -55,7 +55,6 @@ export const LegacyPronouns: Record = { avoid: 'Avoid pronouns, use my name', } - function formatPronouns (pronounsId: string, capitalize: boolean) { const pronouns = LegacyPronouns[pronounsId] return Array.isArray(pronouns) ? pronouns[capitalize ? 1 : 0] : pronouns diff --git a/packages/website/src/server/auth.ts b/packages/website/src/server/auth.ts index 95cf16f..3df5dba 100644 --- a/packages/website/src/server/auth.ts +++ b/packages/website/src/server/auth.ts @@ -30,6 +30,7 @@ import type { APIContext } from 'astro' import { ObjectId } from 'mongodb' import { createHash, createHmac, randomBytes, timingSafeEqual } from 'crypto' import { createSigner, createVerifier } from 'fast-jwt' +import { LegacyTokenizeMigrationCounter } from './metrics.js' import { findById } from './database/account.js' export type JwtPayload = { id: string } @@ -81,10 +82,9 @@ export async function authenticate ({ cookies }: APIContext, lax?: boolean) { try { const { id } = verifier(token) - const user = await findById(new ObjectId(id)) - if (!user) cookies.delete('token') - return user + return findById(new ObjectId(id)) } catch { + // Not deleting the token if invalid is intentional. This allows the extension to still show up pronouns. return null } } @@ -117,11 +117,11 @@ export function validateCsrf ({ cookies }: APIContext, csrf: string) { // Legacy tokenize migration export function migrateAuth ({ cookies }: APIContext) { + if (!import.meta.env.LEGACY_SECRET_KEY) return + let token = cookies.get('token').value if (!token || token.startsWith('ey')) return - if (!import.meta.env.LEGACY_SECRET_KEY) return - const [ id, gen, sig ] = token.split('.') if (!id || !gen || !sig) return @@ -132,6 +132,7 @@ export function migrateAuth ({ cookies }: APIContext) { if (!safeEqual(sig, expectedSig)) return + LegacyTokenizeMigrationCounter.inc() token = generateToken({ id: Buffer.from(id, 'base64').toString() }) cookies.set('token', token, { path: '/', maxAge: 365 * 24 * 3600, httpOnly: true, secure: import.meta.env.PROD }) } diff --git a/packages/website/src/server/database/account.ts b/packages/website/src/server/database/account.ts index a3639eb..ee8c100 100644 --- a/packages/website/src/server/database/account.ts +++ b/packages/website/src/server/database/account.ts @@ -29,7 +29,9 @@ import type { ObjectId } from 'mongodb' import database from './database.js' -const collection = database.collection('accounts') +export const collection = database.collection('accounts') +await collection.createIndex({ 'accounts.id': 1, 'accounts.platform': 1 }) +await collection.createIndex({ 'accounts.platform': 1 }) export type Account = { pronouns: string @@ -44,8 +46,7 @@ export type ExternalAccount = { export type PronounsOfUser = { pronouns: string - platform: string - id: string + account: ExternalAccount } export async function createAccount (from: ExternalAccount) { @@ -75,20 +76,34 @@ export async function findByExternalAccount (external: ExternalAccount) { } export function findPronounsOf (platform: string, externalIds: string[]) { + // perf: first filtering ($match) needs to be done before doing anything to ensure we hit the index. + // once initial filtering is done, we can do whatever as the dataset is small enough. + // it was behaving with 10k+ docs, it should be ridiculously fast for <=50 docs... return collection.aggregate([ - { $unwind: '$accounts' }, { - '$match': { + $match: { 'accounts.platform': platform, - 'accounts.id': { '$in': externalIds }, + 'accounts.id': { $in: externalIds }, }, }, { $project: { _id: 0, pronouns: 1, - platform: '$accounts.platform', - id: '$accounts.id', + account: { + $first: { + $filter: { + input: '$accounts', + as: 'account', + cond: { + $and: [ + { $eq: [ '$$account.platform', platform ] }, + { $in: [ '$$account.id', externalIds ] }, + ], + }, + }, + }, + }, }, }, ]) diff --git a/packages/website/src/server/flash.ts b/packages/website/src/server/flash.ts index af6e0b7..6d1f0bc 100644 --- a/packages/website/src/server/flash.ts +++ b/packages/website/src/server/flash.ts @@ -36,7 +36,7 @@ export const FlashMessages = { S_ACC_DELETED: 'Your account has been successfully deleted. Sorry to see you go!', // Error - E_CSRF: 'Verification of the authenticity of the submission failed. Please try again.', + E_CSRF: 'Verification of the authenticity of the submission failed (CSRF check). Please try again.', E_OAUTH_GENERIC: 'An unknown error occurred while authenticating with the third party service.', E_OAUTH_FETCH: 'Could not fetch information about your external account.', E_OAUTH_10A_EXCHANGE: 'Could not initialize the authentication request with the third party.', diff --git a/packages/website/src/server/metrics.ts b/packages/website/src/server/metrics.ts index be749e5..1f785fb 100644 --- a/packages/website/src/server/metrics.ts +++ b/packages/website/src/server/metrics.ts @@ -26,14 +26,68 @@ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ -import { register, collectDefaultMetrics, Counter, Histogram } from 'prom-client' +import { register, collectDefaultMetrics, Counter, Histogram, Gauge } from 'prom-client' +import { collection } from './database/account.js' + +const providersGlob = import.meta.glob('../server/oauth/platforms/*.ts', { eager: true }) +const providers = Object.keys(providersGlob).map((k) => k.slice(26, -3)) collectDefaultMetrics({ prefix: 'pronoundb_', }) /// ACCOUNT METRICS -// todo +new Gauge({ + name: 'pronoundb_accounts_total', + help: 'accounts count', + labelNames: [], + collect: async function () { + const count = await collection.countDocuments() + this.set({}, count) + }, +}) + +new Gauge({ + name: 'pronoundb_linked_accounts_total', + help: 'accounts linked per platform count', + labelNames: [ 'platform' ], + collect: async function () { + // - But Cynthia!!!! You're doing this every 30 seconds!!!! + // - lol yea whatever hehe + // - ................ + // - *noms cookie* + await Promise.all( + providers.map( + (p) => collection.countDocuments({ 'accounts.platform': p }) + .then((count) => this.set({ platform: p }, count)) + ) + ) + }, +}) + +export const CreatedAccountCount = new Counter({ + name: 'pronoundb_account_create_count', + help: 'amount of accounts created', + labelNames: [ 'platform' ], +}) + +export const DeletedAccountCount = new Counter({ + name: 'pronoundb_account_deletion_count', + help: 'amount of accounts deleted', + labelNames: [], +}) + +export const LinkedAccountsAddCount = new Counter({ + name: 'pronoundb_linked_accounts_add_count', + help: 'amount of accounts linked per platform', + labelNames: [ 'platform' ], +}) + +export const LinkedAccountsRemovalCount = new Counter({ + name: 'pronoundb_linked_accounts_remove_count', + help: 'amount of accounts unlinked per platform', + labelNames: [ 'platform' ], +}) /// LOOKUP METRICS export const LookupRequestsCounter = new Counter({ @@ -61,7 +115,13 @@ export const LookupHitCounter = new Counter({ }) /// INTERNAL HEALTH METRICS -// todo +// some more metrics might be welcome +// for now I just log this one so I can know roughly when I can ditch Tokenize migration code +export const LegacyTokenizeMigrationCounter = new Counter({ + name: 'pronoundb_tokenize_migration_total', + help: 'tokens migrated from legacy tokenize to jwt', + labelNames: [], +}) /// HELPERS export function metrics () { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a31b17..b93b24c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,7 @@ importers: '@typescript-eslint/eslint-plugin': ^5.49.0 '@typescript-eslint/parser': ^5.49.0 astro: ^2.0.2 + dotenv: ^16.0.3 eslint: ^8.32.0 fast-jwt: ^2.1.0 feather-icons: ^4.29.0 @@ -75,6 +76,7 @@ importers: '@typescript-eslint/eslint-plugin': 5.49.0_iu322prlnwsygkcra5kbpy22si '@typescript-eslint/parser': 5.49.0_7uibuqfxkfaozanbtbziikiqje astro: 2.0.2_@types+node@18.11.18 + dotenv: 16.0.3 eslint: 8.32.0 feather-icons: 4.29.0 simple-icons: 8.3.0 @@ -2713,6 +2715,11 @@ packages: domhandler: 4.3.1 dev: true + /dotenv/16.0.3: + resolution: {integrity: sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ==} + engines: {node: '>=12'} + dev: true + /dset/3.1.2: resolution: {integrity: sha512-g/M9sqy3oHe477Ar4voQxWtaPIFw1jTdKZuomOjhCcBx9nHUNn0pu6NopuFFrTh/TRZIKEj+76vLWFu9BNKk+Q==} engines: {node: '>=4'} @@ -4416,17 +4423,6 @@ packages: hasBin: true dev: true - /postcss-import/14.1.0: - resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==} - engines: {node: '>=10.0.0'} - peerDependencies: - postcss: ^8.0.0 - dependencies: - postcss-value-parser: 4.2.0 - read-cache: 1.0.0 - resolve: 1.22.1 - dev: true - /postcss-import/14.1.0_postcss@8.4.21: resolution: {integrity: sha512-flwI+Vgm4SElObFVPpTIT7SU7R3qk2L7PyduMcokiaVKuWv9d/U+Gm/QAd8NDLuykTWTkcrjOeD2Pp1rMeBTGw==} engines: {node: '>=10.0.0'} @@ -4439,15 +4435,6 @@ packages: resolve: 1.22.1 dev: true - /postcss-js/4.0.0: - resolution: {integrity: sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==} - engines: {node: ^12 || ^14 || >= 16} - peerDependencies: - postcss: ^8.3.3 - dependencies: - camelcase-css: 2.0.1 - dev: true - /postcss-js/4.0.0_postcss@8.4.21: resolution: {integrity: sha512-77QESFBwgX4irogGVPgQ5s07vLvFqWr228qZY+w6lW599cRlK/HmnlivnnVUxkjHnCu4J16PDMHcH+e+2HbvTQ==} engines: {node: ^12 || ^14 || >= 16} @@ -4458,22 +4445,6 @@ packages: postcss: 8.4.21 dev: true - /postcss-load-config/3.1.4: - resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} - engines: {node: '>= 10'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - dependencies: - lilconfig: 2.0.6 - yaml: 1.10.2 - dev: true - /postcss-load-config/3.1.4_postcss@8.4.21: resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} engines: {node: '>= 10'} @@ -4508,15 +4479,6 @@ packages: yaml: 2.2.1 dev: true - /postcss-nested/6.0.0: - resolution: {integrity: sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.2.14 - dependencies: - postcss-selector-parser: 6.0.11 - dev: true - /postcss-nested/6.0.0_postcss@8.4.21: resolution: {integrity: sha512-0DkamqrPcmkBDsLn+vQDIrtkSbNkv5AD/M322ySo9kqFkCIYklym2xEmWkwo+Y3/qZo34tzEPNUw4y7yMCdv5w==} engines: {node: '>=12.0'} @@ -5223,8 +5185,6 @@ packages: resolution: {integrity: sha512-AhwtHCKMtR71JgeYDaswmZXhPcW9iuI9Sp2LvZPo9upDZ7231ZJ7eA9RaURbhpXGVlrjX4cFNlB4ieTetEb7hQ==} engines: {node: '>=12.13.0'} hasBin: true - peerDependencies: - postcss: ^8.0.9 dependencies: arg: 5.0.2 chokidar: 3.5.3 @@ -5241,10 +5201,10 @@ packages: object-hash: 3.0.0 picocolors: 1.0.0 postcss: 8.4.21 - postcss-import: 14.1.0 - postcss-js: 4.0.0 - postcss-load-config: 3.1.4 - postcss-nested: 6.0.0 + postcss-import: 14.1.0_postcss@8.4.21 + postcss-js: 4.0.0_postcss@8.4.21 + postcss-load-config: 3.1.4_postcss@8.4.21 + postcss-nested: 6.0.0_postcss@8.4.21 postcss-selector-parser: 6.0.11 postcss-value-parser: 4.2.0 quick-lru: 5.1.1