diff options
Diffstat (limited to 'vnext/server')
-rw-r--r-- | vnext/server/Dockerfile | 15 | ||||
-rw-r--r-- | vnext/server/app.js | 66 | ||||
-rw-r--r-- | vnext/server/common/MessageUtils.js | 5 | ||||
-rw-r--r-- | vnext/server/db/Users.js | 43 | ||||
-rw-r--r-- | vnext/server/db/__testdata__/testdata.sql | 1 | ||||
-rw-r--r-- | vnext/server/db/index.js | 10 | ||||
-rw-r--r-- | vnext/server/durov.js | 2 | ||||
-rw-r--r-- | vnext/server/index.js | 43 | ||||
-rw-r--r-- | vnext/server/middleware/android.spec.js | 16 | ||||
-rw-r--r-- | vnext/server/middleware/event.js | 4 | ||||
-rw-r--r-- | vnext/server/middleware/host-meta.js | 42 | ||||
-rw-r--r-- | vnext/server/middleware/mastodon.js | 23 | ||||
-rw-r--r-- | vnext/server/middleware/mastodon.spec.js | 17 | ||||
-rw-r--r-- | vnext/server/middleware/rememberme.js | 50 | ||||
-rw-r--r-- | vnext/server/middleware/urlexpand.js | 3 | ||||
-rw-r--r-- | vnext/server/middleware/webfinger.js | 59 | ||||
-rw-r--r-- | vnext/server/middleware/webfinger.spec.js | 32 | ||||
-rw-r--r-- | vnext/server/sender.js | 10 |
18 files changed, 373 insertions, 68 deletions
diff --git a/vnext/server/Dockerfile b/vnext/server/Dockerfile index 37f8c896..52537cd4 100644 --- a/vnext/server/Dockerfile +++ b/vnext/server/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20.17.0-bookworm-slim +FROM node:23.1.0-bookworm-slim # Install app dependencies COPY package.json . @@ -8,11 +8,12 @@ RUN npm install # Bundle APP files COPY public/server.js . -ENV NODE_ENV production -ENV NPM_CONFIG_LOGLEVEL warn -ENV DEBUG http +ENV NODE_ENV=production +ENV NPM_CONFIG_LOGLEVEL=warn +ENV DEBUG=http -# Expose the listening port of your app -EXPOSE 8081 +RUN useradd juick +USER juick -CMD [ "node", "server.js" ] +EXPOSE 8081 +CMD ["node", "server.js"] diff --git a/vnext/server/app.js b/vnext/server/app.js new file mode 100644 index 00000000..4249b922 --- /dev/null +++ b/vnext/server/app.js @@ -0,0 +1,66 @@ +import express from 'express' +import { raw } from 'body-parser' +import cookieParser from 'cookie-parser' +import cors from 'cors' +import config from 'config' + +//import serverRenderer from './middleware/renderer' +import event from './middleware/event' +import oembed from './middleware/oembed' +import urlExpand from './middleware/urlexpand' +import releases from './middleware/android' +import { instance } from './middleware/mastodon' + +import path from 'path' +import { webhook, webhookPath } from './durov' +import { webfinger } from './middleware/webfinger' +import { jsonMeta, nodeinfo, xmlMeta } from './middleware/host-meta' +import { rememberMeParser } from './middleware/rememberme' + +// initialize the application and create the routes +export const app = express() +app.use(raw()) +app.use(cors()) +app.use(cookieParser()) +app.use(rememberMeParser) +const router = express.Router() + +router.post('/api/v2/sender', event) +router.get('/api/v2/oembed', oembed) +router.get('/api/v2/urlexpand', urlExpand) +router.get('/api/apps/android/releases', releases) + +// Web Host Metadata + +router.get('/.well-known/host-meta', xmlMeta) +router.get('/.well-known/host-meta.json', jsonMeta) + +// WebFinger + +router.get('/.well-known/webfinger', webfinger) + +// Nodeinfo + +router.get('/.well-known/nodeinfo', nodeinfo) + +// Mastodon API + +router.get('/api/v2/instance', instance) + +const durov_webhook = webhookPath() +if (durov_webhook) { + router.post(`/api/v2/${durov_webhook}`, webhook) +} +//router.use('^/$', serverRenderer) + +const STATIC_ROOT = config.get('service.static_root') || path.resolve(__dirname, 'public') + +// other static resources should just be served as they are +router.use(express.static( + STATIC_ROOT, + { maxAge: '30d' }, +)) + +//router.use('*', serverRenderer) + +app.use(router) diff --git a/vnext/server/common/MessageUtils.js b/vnext/server/common/MessageUtils.js index bb3d791f..d3c6a0f5 100644 --- a/vnext/server/common/MessageUtils.js +++ b/vnext/server/common/MessageUtils.js @@ -39,11 +39,12 @@ export function formatTitle(msg) { /** * format notification quote * @param { import('../../src/api').Message } msg message + * @param { boolean } isDurov format to Telegram markup * @returns {string} formatted quote line */ -export function formatQuote(msg) { +export function formatQuote(msg, isDurov = false) { if (isReply(msg)) { - return msg.replyQuote || '' + return msg.replyQuote ? isDurov ? `<blockquote>${msg.replyQuote.substring(1)}</blockquote>` : msg.replyQuote : '' } else if (isPM(msg)) { return '' } diff --git a/vnext/server/db/Users.js b/vnext/server/db/Users.js new file mode 100644 index 00000000..445b0ff0 --- /dev/null +++ b/vnext/server/db/Users.js @@ -0,0 +1,43 @@ +import { DataTypes, Op } from 'sequelize' +import db from './index' + +const User = db.define('user', { + id: { + type: DataTypes.INTEGER, + primaryKey: true + }, + nick: DataTypes.STRING, + passw: DataTypes.STRING, + banned: DataTypes.INTEGER, + last_seen: DataTypes.DATE +}, { + timestamps: false +}) + +export const getMonthlyActiveUsers = async () => { + const seenDate = new Date() + seenDate.setMonth(seenDate.getMonth() - 1) + return await User.count({ + where: { + banned: { + [Op.eq]: 0 + }, + last_seen: { + [Op.gt]: seenDate + } + } + }) +} + +export const getUserByName = async (name = '') => { + return await User.findOne({ + where: { + nick: { + [Op.eq]: name + }, + banned: { + [Op.eq]: 0 + } + } + }) +} diff --git a/vnext/server/db/__testdata__/testdata.sql b/vnext/server/db/__testdata__/testdata.sql new file mode 100644 index 00000000..f2876732 --- /dev/null +++ b/vnext/server/db/__testdata__/testdata.sql @@ -0,0 +1 @@ +INSERT INTO users(nick,passw,last_seen) VALUES('ugnich', '12345', NULL), ('freefd', '12345', DATETIME('now')) diff --git a/vnext/server/db/index.js b/vnext/server/db/index.js new file mode 100644 index 00000000..9c9e564b --- /dev/null +++ b/vnext/server/db/index.js @@ -0,0 +1,10 @@ +import debug from 'debug' +var log = debug('db') + +import { Sequelize } from 'sequelize' + +const db = new Sequelize(process.env.DATABASE_URL, { + logging: (...msg) => log(msg) +}) + +export default db diff --git a/vnext/server/durov.js b/vnext/server/durov.js index 6ab1831f..51996b9e 100644 --- a/vnext/server/durov.js +++ b/vnext/server/durov.js @@ -41,7 +41,7 @@ export const sendTelegramNotification = (msg, subscribers) => { log(`Telegram event: ${JSON.stringify(msg)}, ${subscribers} ${subscribers.length}`) if (!msg.service) { if (subscribers && subscribers.includes(demouser)) { - const message = `${formatTitle(msg)}\n${formatQuote(msg)}\n${format(msg.body, msg.uid, false)}` + const message = `${formatTitle(msg)}\n${formatQuote(msg, true)}\n${format(msg.body, msg.uid, false, true)}` log(message) sender.sendMessage(demouser, message, { parse_mode: 'HTML', diff --git a/vnext/server/index.js b/vnext/server/index.js index 692fbd76..bbb5a578 100644 --- a/vnext/server/index.js +++ b/vnext/server/index.js @@ -1,55 +1,14 @@ -import express from 'express' -import { raw } from 'body-parser' -import cors from 'cors' -import config from 'config' import debug from 'debug' const log = debug('http') -import serverRenderer from './middleware/renderer' -import event from './middleware/event' -import oembed from './middleware/oembed' -import urlExpand from './middleware/urlexpand' -import releases from './middleware/android' +import { app } from './app' const PORT = process.env.LISTEN_PORT || 8081 -import path from 'path' -import { webhook, webhookPath } from './durov' - -// initialize the application and create the routes -const app = express() -app.use(raw()) -app.use(cors()) -const router = express.Router() - -router.post('/api/v2/sender', event) -router.get('/api/v2/oembed', oembed) -router.get('/api/v2/urlexpand', urlExpand) -router.get('/api/apps/android/releases', releases) - -const durov_webhook = webhookPath() -if (durov_webhook) { - router.post(`/api/v2/${durov_webhook}`, webhook) -} -router.use('^/$', serverRenderer) - -const STATIC_ROOT = config.get('service.static_root') || path.resolve(__dirname, 'public') - -// other static resources should just be served as they are -router.use(express.static( - STATIC_ROOT, - { maxAge: '30d' }, -)) - -router.use('*', serverRenderer) - -app.use(router) - // start the app app.listen(PORT, (error) => { if (error) { return console.log('something bad happened', error) } - log('listening on ' + PORT + '...') }) diff --git a/vnext/server/middleware/android.spec.js b/vnext/server/middleware/android.spec.js index 448714ac..f5b9792a 100644 --- a/vnext/server/middleware/android.spec.js +++ b/vnext/server/middleware/android.spec.js @@ -1,15 +1,15 @@ -import request from 'supertest' -import express from 'express' +/** + * @jest-environment node + */ -import releases from './android' +import request from 'supertest' -const app = express() -app.get('/releases', releases) +import { app } from '../app' describe('Releases helper', () => { it('Should respond with empty array to unknown user agents', async () => { return request(app) - .get('/releases') + .get('/api/apps/android/releases') .expect(200) .then(response => { expect(response.body).toStrictEqual([]) @@ -17,7 +17,7 @@ describe('Releases helper', () => { }) it('Should respond with a single legacy version data to old Android app', async () => { return request(app) - .get('/releases') + .get('/api/apps/android/releases') .set('User-Agent', 'Juick/100 okhttp/3.12 Android/19') .expect(200) .then(response => { @@ -27,7 +27,7 @@ describe('Releases helper', () => { }) it('Should redirect to Github when Android version is ok', async () => { return request(app) - .get('/releases') + .get('/api/apps/android/releases') .set('User-Agent', 'Juick/100 okhttp/3.12 Android/24') .expect(302) .then(response => { diff --git a/vnext/server/middleware/event.js b/vnext/server/middleware/event.js index c80f2249..623ee9a9 100644 --- a/vnext/server/middleware/event.js +++ b/vnext/server/middleware/event.js @@ -1,3 +1,5 @@ +import config from 'config' + import { simpleParser } from 'mailparser' import { isPM, isReply, isService } from '../common/MessageUtils' import { sendTelegramNotification } from '../durov' @@ -8,7 +10,7 @@ import { send } from '../hms' var log = debug('event') /** @type {number[]} */ -const allSandboxIds = [] +const allSandboxIds = config.get('service.sandboxIds') /** * handle message event diff --git a/vnext/server/middleware/host-meta.js b/vnext/server/middleware/host-meta.js new file mode 100644 index 00000000..0bca925a --- /dev/null +++ b/vnext/server/middleware/host-meta.js @@ -0,0 +1,42 @@ +import config from 'config' + +const baseURL = config.get('service.baseURL') + +/** + * host-meta endpoint + * @type {import('express').RequestParamHandler} + */ +export const xmlMeta = async (_req, res) => { + return res.set('Content-Type', 'text/xml') + .send(`<?xml version="1.0" encoding="UTF-8"?><XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0"><Link rel="lrdd" template="${baseURL}/.well-known/webfinger?resource={uri}"/></XRD>`) +} + +/** + * host-meta.json endpoint + * @type {import('express').RequestParamHandler} + */ +export const jsonMeta = async (_req, res) => { + return res.json({ + 'links': [ + { + 'rel': 'lrdd', + 'template': `${baseURL}/.well-known/webfinger?resource={uri}` + } + ] + }) +} + +/** + * nodeinfo endpoint + * @type {import('express').RequestParamHandler} + */ +export const nodeinfo = async (_req, res) => { + return res.json({ + 'links': [ + { + 'rel': 'https://nodeinfo.diaspora.software/ns/schema/2.0', + 'href': `${baseURL}/.well-known/nodeinfo/2.0` + } + ] + }) +} diff --git a/vnext/server/middleware/mastodon.js b/vnext/server/middleware/mastodon.js new file mode 100644 index 00000000..e29fd736 --- /dev/null +++ b/vnext/server/middleware/mastodon.js @@ -0,0 +1,23 @@ +import { getMonthlyActiveUsers } from '../db/Users' + +/** + * Return content for embedding + * @type {import('express').RequestParamHandler} + */ +export const instance = async (req, res) => { + const activeUsers = await getMonthlyActiveUsers() + res.json({ + 'domain': 'juick.com', + 'title': 'Microblogging service', + 'description': 'Juick', + 'version': '2.x', + 'contact': { + 'email': 'support@juick.com' + }, + 'usage': { + 'users': { + 'active_month': activeUsers + } + } + }) +} diff --git a/vnext/server/middleware/mastodon.spec.js b/vnext/server/middleware/mastodon.spec.js new file mode 100644 index 00000000..7f30d027 --- /dev/null +++ b/vnext/server/middleware/mastodon.spec.js @@ -0,0 +1,17 @@ +/** + * @jest-environment node + */ + +import request from 'supertest' +import { app } from '../app' + +describe('Mastodon API middleware', () => { + it('Inactive users should not be included in Instance response', async () => { + return request(app) + .get('/api/v2/instance') + .expect(200) + .then(response => { + expect(response.body.usage.users.active_month).toStrictEqual(1) + }) + }) +}) diff --git a/vnext/server/middleware/rememberme.js b/vnext/server/middleware/rememberme.js new file mode 100644 index 00000000..1c0a2ee4 --- /dev/null +++ b/vnext/server/middleware/rememberme.js @@ -0,0 +1,50 @@ +import config from 'config' +import { createHash } from 'node:crypto' +import debug from 'debug' +import { getUserByName } from '../db/Users' +const log = debug('auth') + +const auth_key = config.get('service.auth.key') +const JUICK_COOKIE_NAME = 'juick-remember-me' + +export const rememberMeParser = (req, _res, next) => { + if (req.cookies && !!req.cookies[JUICK_COOKIE_NAME]) { + validate_cookie(req.cookies[JUICK_COOKIE_NAME]).then(visitor => { + req.visitor = visitor + setImmediate(next) + }).catch(() => { + setImmediate(next) + }) + } else { + setImmediate(next) + } +} + +const validate_cookie = async (cookie) => { + const [ username, expiry_time, , signature ] = Buffer.from(cookie, 'base64').toString('ascii').split(':', 4) + if (is_token_expired(expiry_time)) { + log(`Token expired at: ${new Date(expiry_time)}`) + return '' + } + const user = await getUserByName(username) + if (!user) { + log(`User not found: ${username}`) + return '' + } + const expected_signature = make_token_signature(expiry_time, username, user['passw']) + if (expected_signature === signature) { + log(`Signature verified: ${username}`) + return username + } + log(`Invalid token signature: ${username}`) + return '' +} + +const make_token_signature = (expiry_time, username, password) => { + const data = `${username}:${expiry_time}:{noop}${password}:${auth_key}` + return createHash('sha256').update(data).digest('hex') +} + +const is_token_expired = (expiry_time) => { + return new Date(expiry_time) < new Date() +} diff --git a/vnext/server/middleware/urlexpand.js b/vnext/server/middleware/urlexpand.js index a99f80a7..fd7ab3bb 100644 --- a/vnext/server/middleware/urlexpand.js +++ b/vnext/server/middleware/urlexpand.js @@ -2,8 +2,7 @@ import { expandShortenedLink } from '../../src/api' /** * Expand URLs - * @param {import("next").NextApiRequest} req - * @param {import("next").NextApiResponse} res + * @type {import('express').RequestParamHandler} */ export default function urlExpand(req, res) { let url = (req.query.url || '').toString() diff --git a/vnext/server/middleware/webfinger.js b/vnext/server/middleware/webfinger.js new file mode 100644 index 00000000..873387b3 --- /dev/null +++ b/vnext/server/middleware/webfinger.js @@ -0,0 +1,59 @@ +import config from 'config' +import addrparser from 'address-rfc2822' +import debug from 'debug' +var log = debug('webfinger') + +import { getUserByName } from '../db/Users' + +const baseUrl = config.get('service.baseURL') + +/** + * WebFinger endpoint + * @type {import('express').RequestParamHandler} + */ +export const webfinger = async (req, res) => { + const resource = req.query.resource + if (resource) { + const acct = resource.substring(5) // drop "acct:" + const addresses = parseAddress(acct) + if (addresses && addresses.length == 1) { + const address = addresses[0] + const ourAddress = new URL(baseUrl) + if (address.host() === ourAddress.hostname) { + const name = addresses[0].user() + const user = await getUserByName(name) + if (user) { + return res.json({ + subject: resource, + links: [ + { + rel: 'self', + type: 'application/activity+json', + href: `${baseUrl}/u/${user.nick}` + } + ] + }) + } else { + log(`User not found: ${name}`) + return res.status(404).end() + } + } else { + log(`Address not found: ${address.host()}`) + return res.status(404).end() + } + } else { + log(`Invalid resource: ${resource}`) + return res.status(400).end() + } + } + log('Missing `resource` param') + res.status(400) +} + +const parseAddress = (address = '') => { + try { + return addrparser.parse(address, { startAt: 'address' }) + } catch { + return undefined + } +} diff --git a/vnext/server/middleware/webfinger.spec.js b/vnext/server/middleware/webfinger.spec.js new file mode 100644 index 00000000..c36c4571 --- /dev/null +++ b/vnext/server/middleware/webfinger.spec.js @@ -0,0 +1,32 @@ +/** + * @jest-environment node + */ + +import request from 'supertest' + +import { app } from '../app' + +describe('WebFinger middleware', () => { + it('Existing user response should have a subject and links', async () => { + const resource = 'acct:ugnich@example.lan' + const response = await request(app) + .get(`/.well-known/webfinger?resource=${resource}`) + expect(response.status).toStrictEqual(200) + expect(response.body.subject).toStrictEqual(resource) + expect(response.body.links.length).toStrictEqual(1) + expect(response.body.links[0].href).toStrictEqual('https://example.lan/u/ugnich') + }) + it('Unknown user should return 404', async () => { + const resource = 'acct:durov@example.lan' + const response = await request(app) + .get(`/.well-known/webfinger?resource=${resource}`) + expect(response.status).toStrictEqual(404) + }) + it('Invalid input should return 400', async () => { + const resource = ';DROP TABLE users' + const response = await request(app) + .get(`/.well-known/webfinger?resource=${resource}`) + expect(response.status).toStrictEqual(400) + }) +}) + diff --git a/vnext/server/sender.js b/vnext/server/sender.js index fa8c4853..d5272bfb 100644 --- a/vnext/server/sender.js +++ b/vnext/server/sender.js @@ -24,7 +24,7 @@ const fcmConfig = { serviceAccountKey: process.env.JUICK_FCM_SERVICE_ACCOUNT_FILE } -console.log(`fcm config: ${JSON.stringify(fcmConfig)}`) +log(`fcm config: ${JSON.stringify(fcmConfig)}`) const push = new PushNotifications({ ...config, @@ -143,15 +143,15 @@ export function buildNotification(user, msg) { template.body = body //template.badge = user.unreadCount || 0 template.mutableContent = 1 - template.color = '#3c77aa' - template.icon = 'ic_notification' - template.clickAction = 'com.juick.NEW_EVENT_ACTION' const tag = msg.mid == 0 ? msg.user.uname : msg.mid template.tag = `${tag}` template.fcm_notification = { title: title, body: body, - channel_id: 'default' + channel_id: 'default', + click_action: 'com.juick.NEW_EVENT_ACTION', + icon: 'ic_notification', + color: '#3c77aa' } } return template |