diff options
Diffstat (limited to 'vnext')
24 files changed, 655 insertions, 116 deletions
diff --git a/vnext/server/Dockerfile b/vnext/server/Dockerfile new file mode 100644 index 00000000..bbbb37ad --- /dev/null +++ b/vnext/server/Dockerfile @@ -0,0 +1,19 @@ +FROM node:23.0.0-bookworm-slim + +# Install app dependencies +COPY package.json . +COPY package-lock.json . +RUN npm install + +# Bundle APP files +COPY public/server.js . + +ENV NODE_ENV=production +ENV NPM_CONFIG_LOGLEVEL=warn +ENV DEBUG=http + +RUN useradd juick +USER juick + +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 16abc8db..bbb5a578 100644 --- a/vnext/server/index.js +++ b/vnext/server/index.js @@ -1,53 +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 { 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) - -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.js b/vnext/server/middleware/android.js new file mode 100644 index 00000000..ae5f8fe8 --- /dev/null +++ b/vnext/server/middleware/android.js @@ -0,0 +1,43 @@ +import debug from 'debug' +var log = debug('android') + +import * as legacy_data from './legacy.json' + +const releases_url = 'https://api.github.com/repos/Juick/Juick-Android/releases' + +/** + * Return android releases + * @type {import('express').RequestParamHandler} + */ +const releases = async (req, res) => { + let agent = req.headers['user-agent'] || 'unknown' + let android_version = parse_agent_android_sdk_version(agent) + log(`releases request from ${android_version || agent}`) + if (android_version > 0) { + if (is_legacy_android(android_version)) { + log('responding with legacy stub') + return res.json([legacy_data]) + } else { + log('redirecting to Github') + return res.redirect(releases_url) + } + } + return res.json([]) +} + +const parse_agent_android_sdk_version = (agent = '') => { + let version = agent.split(' ', 3) + let is_android_app = version.length == 3 && version[2].startsWith('Android') + if (is_android_app) { + let android_version = version[2].split('/') + let is_valid_version = android_version.length == 2 + return is_valid_version ? +(android_version[1]) : NaN + } + return NaN +} + +const is_legacy_android = (version) => { + return version < 24 +} + +export default releases diff --git a/vnext/server/middleware/android.spec.js b/vnext/server/middleware/android.spec.js new file mode 100644 index 00000000..19d380d7 --- /dev/null +++ b/vnext/server/middleware/android.spec.js @@ -0,0 +1,33 @@ +import request from 'supertest' + +import { app } from '../app' + +describe('Releases helper', () => { + it('Should respond with empty array to unknown user agents', async () => { + return request(app) + .get('/api/apps/android/releases') + .expect(200) + .then(response => { + expect(response.body).toStrictEqual([]) + }) + }) + it('Should respond with a single legacy version data to old Android app', async () => { + return request(app) + .get('/api/apps/android/releases') + .set('User-Agent', 'Juick/100 okhttp/3.12 Android/19') + .expect(200) + .then(response => { + expect(response.body.length).toBe(1) + expect(response.body[0].name).toBe('3.1.216') + }) + }) + it('Should redirect to Github when Android version is ok', async () => { + return request(app) + .get('/api/apps/android/releases') + .set('User-Agent', 'Juick/100 okhttp/3.12 Android/24') + .expect(302) + .then(response => { + expect(response.redirect).toBe(true) + }) + }) +}) diff --git a/vnext/server/middleware/event.js b/vnext/server/middleware/event.js index 8280f32b..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 @@ -26,8 +28,9 @@ function processMessageEvent(msg) { subscribers(new URLSearchParams(JSON.parse(JSON.stringify(params)))).then(users => { return users.map(user => { log(`${user.uname}: ${user.unreadCount}`) + let tokenTypes = msg.service ? ['mpns', 'apns', 'fcm'] : ['mpns', 'apns', 'fcm', 'web'] let [sandboxTokens, productionTokens] = (user.tokens || []) - .filter(t => ['mpns', 'apns', 'fcm', 'web'].includes(t.type)) + .filter(t => tokenTypes.includes(t.type)) .map(t => t.type === 'web' ? JSON.parse(t.token) : t.token) .reduce((result, element) => { allSandboxIds.includes(user.uid) 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/legacy.json b/vnext/server/middleware/legacy.json new file mode 100644 index 00000000..840708e6 --- /dev/null +++ b/vnext/server/middleware/legacy.json @@ -0,0 +1,142 @@ +{ + "url": "https://api.github.com/repos/juick/Juick-Android/releases/164308344", + "assets_url": "https://api.github.com/repos/juick/Juick-Android/releases/164308344/assets", + "upload_url": "https://uploads.github.com/repos/juick/Juick-Android/releases/164308344/assets{?name,label}", + "html_url": "https://github.com/juick/Juick-Android/releases/tag/v3.1.216", + "id": 164308344, + "author": { + "login": "vitalyster", + "id": 1052407, + "node_id": "MDQ6VXNlcjEwNTI0MDc=", + "avatar_url": "https://avatars.githubusercontent.com/u/1052407?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/vitalyster", + "html_url": "https://github.com/vitalyster", + "followers_url": "https://api.github.com/users/vitalyster/followers", + "following_url": "https://api.github.com/users/vitalyster/following{/other_user}", + "gists_url": "https://api.github.com/users/vitalyster/gists{/gist_id}", + "starred_url": "https://api.github.com/users/vitalyster/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/vitalyster/subscriptions", + "organizations_url": "https://api.github.com/users/vitalyster/orgs", + "repos_url": "https://api.github.com/users/vitalyster/repos", + "events_url": "https://api.github.com/users/vitalyster/events{/privacy}", + "received_events_url": "https://api.github.com/users/vitalyster/received_events", + "type": "User", + "site_admin": false + }, + "node_id": "RE_kwDOADJkWc4JyyV4", + "tag_name": "v3.1.216", + "target_commitish": "master", + "name": "3.1.216", + "draft": false, + "prerelease": true, + "created_at": "2024-07-07T19:47:32Z", + "published_at": "2024-07-07T19:51:29Z", + "assets": [ + { + "url": "https://api.github.com/repos/juick/Juick-Android/releases/assets/178215973", + "id": 178215973, + "node_id": "RA_kwDOADJkWc4Kn1wl", + "name": "Juick-free-v3.1.216.apk", + "label": null, + "uploader": { + "login": "vitalyster", + "id": 1052407, + "node_id": "MDQ6VXNlcjEwNTI0MDc=", + "avatar_url": "https://avatars.githubusercontent.com/u/1052407?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/vitalyster", + "html_url": "https://github.com/vitalyster", + "followers_url": "https://api.github.com/users/vitalyster/followers", + "following_url": "https://api.github.com/users/vitalyster/following{/other_user}", + "gists_url": "https://api.github.com/users/vitalyster/gists{/gist_id}", + "starred_url": "https://api.github.com/users/vitalyster/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/vitalyster/subscriptions", + "organizations_url": "https://api.github.com/users/vitalyster/orgs", + "repos_url": "https://api.github.com/users/vitalyster/repos", + "events_url": "https://api.github.com/users/vitalyster/events{/privacy}", + "received_events_url": "https://api.github.com/users/vitalyster/received_events", + "type": "User", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 7807858, + "download_count": 2, + "created_at": "2024-07-07T19:51:12Z", + "updated_at": "2024-07-07T19:51:25Z", + "browser_download_url": "https://github.com/juick/Juick-Android/releases/download/v3.1.216/Juick-free-v3.1.216.apk" + }, + { + "url": "https://api.github.com/repos/juick/Juick-Android/releases/assets/178215894", + "id": 178215894, + "node_id": "RA_kwDOADJkWc4Kn1vW", + "name": "Juick-google-v3.1.216.apk", + "label": null, + "uploader": { + "login": "vitalyster", + "id": 1052407, + "node_id": "MDQ6VXNlcjEwNTI0MDc=", + "avatar_url": "https://avatars.githubusercontent.com/u/1052407?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/vitalyster", + "html_url": "https://github.com/vitalyster", + "followers_url": "https://api.github.com/users/vitalyster/followers", + "following_url": "https://api.github.com/users/vitalyster/following{/other_user}", + "gists_url": "https://api.github.com/users/vitalyster/gists{/gist_id}", + "starred_url": "https://api.github.com/users/vitalyster/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/vitalyster/subscriptions", + "organizations_url": "https://api.github.com/users/vitalyster/orgs", + "repos_url": "https://api.github.com/users/vitalyster/repos", + "events_url": "https://api.github.com/users/vitalyster/events{/privacy}", + "received_events_url": "https://api.github.com/users/vitalyster/received_events", + "type": "User", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 8421195, + "download_count": 7, + "created_at": "2024-07-07T19:50:49Z", + "updated_at": "2024-07-07T19:51:04Z", + "browser_download_url": "https://github.com/juick/Juick-Android/releases/download/v3.1.216/Juick-google-v3.1.216.apk" + }, + { + "url": "https://api.github.com/repos/juick/Juick-Android/releases/assets/178215877", + "id": 178215877, + "node_id": "RA_kwDOADJkWc4Kn1vF", + "name": "Juick-huawei-v3.1.216.apk", + "label": null, + "uploader": { + "login": "vitalyster", + "id": 1052407, + "node_id": "MDQ6VXNlcjEwNTI0MDc=", + "avatar_url": "https://avatars.githubusercontent.com/u/1052407?v=4", + "gravatar_id": "", + "url": "https://api.github.com/users/vitalyster", + "html_url": "https://github.com/vitalyster", + "followers_url": "https://api.github.com/users/vitalyster/followers", + "following_url": "https://api.github.com/users/vitalyster/following{/other_user}", + "gists_url": "https://api.github.com/users/vitalyster/gists{/gist_id}", + "starred_url": "https://api.github.com/users/vitalyster/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/vitalyster/subscriptions", + "organizations_url": "https://api.github.com/users/vitalyster/orgs", + "repos_url": "https://api.github.com/users/vitalyster/repos", + "events_url": "https://api.github.com/users/vitalyster/events{/privacy}", + "received_events_url": "https://api.github.com/users/vitalyster/received_events", + "type": "User", + "site_admin": false + }, + "content_type": "application/vnd.android.package-archive", + "state": "uploaded", + "size": 8275073, + "download_count": 3, + "created_at": "2024-07-07T19:50:27Z", + "updated_at": "2024-07-07T19:50:41Z", + "browser_download_url": "https://github.com/juick/Juick-Android/releases/download/v3.1.216/Juick-huawei-v3.1.216.apk" + } + ], + "tarball_url": "https://api.github.com/repos/juick/Juick-Android/tarball/v3.1.216", + "zipball_url": "https://api.github.com/repos/juick/Juick-Android/zipball/v3.1.216", + "body": "* Bugfixes and performance improvements" +} 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..561303bc --- /dev/null +++ b/vnext/server/middleware/mastodon.spec.js @@ -0,0 +1,13 @@ +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..d1b198e6 --- /dev/null +++ b/vnext/server/middleware/webfinger.spec.js @@ -0,0 +1,28 @@ +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/sape.js b/vnext/server/sape.js index cff9b48a..d88ee75a 100644 --- a/vnext/server/sape.js +++ b/vnext/server/sape.js @@ -2,6 +2,8 @@ import { parseStringPromise } from 'xml2js' import axios from 'axios' import { setupCache } from 'axios-cache-interceptor' import config from 'config' +import debug from 'debug' +const log = debug('sape') const token = config.get('service.sape.token') || process.env.SAPE_TOKEN @@ -14,7 +16,7 @@ const token = config.get('service.sape.token') || process.env.SAPE_TOKEN */ export const getLinks = async (uri, sapeCookie) => { if (!token) { - console.warn('Sape is not configured') + log('Sape is not configured') return [] } const response = await sape.get(`http://dispencer-01.sape.ru/code.php?user=${token}&host=juick.com&charset=UTF-8&as_xml=true`) diff --git a/vnext/server/sender.js b/vnext/server/sender.js index be907059..d5272bfb 100644 --- a/vnext/server/sender.js +++ b/vnext/server/sender.js @@ -19,20 +19,22 @@ const apnConfig = (production = true) => { } return apn } -const gcmConfig = { - ...cfg.gcm, - id: cfg.gcm?.id || process.env.JUICK_GCM_ID +const fcmConfig = { + ...cfg.fcm, + serviceAccountKey: process.env.JUICK_FCM_SERVICE_ACCOUNT_FILE } +log(`fcm config: ${JSON.stringify(fcmConfig)}`) + const push = new PushNotifications({ ...config, apn: apnConfig(true), - gcm: gcmConfig, + fcm: fcmConfig, }) const sandbox = new PushNotifications({ ...config, apn: apnConfig(false), - gcm: gcmConfig + fcm: fcmConfig }) /** @type {string} */ @@ -139,14 +141,18 @@ export function buildNotification(user, msg) { template.text2 = body template.title = title template.body = body - template.badge = user.unreadCount || 0 + //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.android_channel_id = 'default' + template.fcm_notification = { + title: title, + body: body, + channel_id: 'default', + click_action: 'com.juick.NEW_EVENT_ACTION', + icon: 'ic_notification', + color: '#3c77aa' + } } return template } diff --git a/vnext/server/webpack.config.js b/vnext/server/webpack.config.js index 9ffb3360..78c90050 100644 --- a/vnext/server/webpack.config.js +++ b/vnext/server/webpack.config.js @@ -1,21 +1,17 @@ -const ESLintPlugin = require('eslint-webpack-plugin') -const TerserPlugin = require('terser-webpack-plugin') +const nodeExternals = require('webpack-node-externals') +const path = require('path') -module.exports = () => { - const node_env = process.env.NODE_ENV ? process.env.NODE_ENV : 'development' - const dev = node_env !== 'production' - const config = { - mode: node_env, - devtool: dev ? 'cheap-module-source-map' : false, +module.exports = { + devtool: false, entry: { 'server': [ - __dirname + '/index.js' + path.resolve(__dirname, 'index.js') ] }, target: 'node', output: { - path: __dirname + '/../../public', - filename: '[name].js' + path: path.resolve(__dirname, '../../public'), + filename: '[name].js', }, module: { rules: [{ @@ -26,7 +22,8 @@ module.exports = () => { loader: 'babel-loader' }, { test: /\.(png|jpe?g|gif|svg)$/i, - type: 'asset/resource' + type: 'asset/resource', + dependency: { not: ['url'] }, }] }, plugins: [ @@ -34,37 +31,9 @@ module.exports = () => { resolve: { symlinks: false, extensions: ['.js'] - } - } - if (dev) { - config.plugins.push( - new ESLintPlugin({ - files: __dirname + '/src', - lintDirtyModulesOnly: true, - failOnWarning: false, - failOnError: true, - fix: false - })) - config.devServer = { - hot: true, - historyApiFallback: true, - client: { - overlay: { - runtimeErrors: true - } - } - } - } - config.optimization = { - minimize: !dev, - minimizer: [ - new TerserPlugin({ - minify: TerserPlugin.swcMinify, - // `terserOptions` options will be passed to `swc` (`@swc/core`) - // Link to options - https://swc.rs/docs/config-js-minify - terserOptions: {}, - }), - ] + }, + externalsPresets: { node: true }, // in order to ignore built-in modules like path, fs, etc. + externals: [nodeExternals({ + allowlist: [/\.(?!(?:jsx?|json)$).{1,5}$/i] + })], } - return config -} diff --git a/vnext/src/ui/Icon.js b/vnext/src/ui/Icon.js index bc5ce08a..19f1387c 100644 --- a/vnext/src/ui/Icon.js +++ b/vnext/src/ui/Icon.js @@ -1,7 +1,7 @@ import { createElement, memo } from 'react' import PropTypes from 'prop-types' -import evilIcons from 'evil-icons/assets/sprite.svg' +const spritesUrl = new URL('evil-icons/assets/sprite.svg', import.meta.url) /** * @typedef {object} IconProps @@ -21,7 +21,7 @@ function IconElement(props) { var klass = 'icon' + (!props.noFill ? ' icon--' + props.name : '') + size + className var name = '#' + props.name + '-icon' - var useTag = `<use xlink:href='${evilIcons}${name}' />` + var useTag = `<use xlink:href='${spritesUrl}${name}' />` var Icon = createElement('svg', { className: 'icon__cnt', dangerouslySetInnerHTML: { __html: useTag } }) return createElement( 'div', diff --git a/vnext/src/utils/embed.js b/vnext/src/utils/embed.js index a538a1a8..b6bbde14 100644 --- a/vnext/src/utils/embed.js +++ b/vnext/src/utils/embed.js @@ -122,27 +122,35 @@ function messageReplyReplace(messageId) { * @param {string} txt text message * @param {string} messageId current message id * @param {boolean} isCode set when message contains *code tag + * @param {boolean} isDurov skip rules non-compatible with Telegram * @returns {string} formatted message */ -function juickFormat(txt, messageId, isCode) { +function juickFormat(txt, messageId, isCode, isDurov) { const urlRe = /(?:\[([^\][]+)\](?:\[([^\]]+)\]|\(((?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[-\S+*&@#/%=~|$?!:;,.])*(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[\S+*&@#/%=~|$]))\))|\b(?:[a-z]+:\/\/|www\.|ftp\.)(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[-\S+*&@#/%=~|$?!:;,.])*(?:\([-\S+*&@#/%=~|$?!:;,.]*\)|[\S+*&@#/%=~|$]))/gi const bqReplace = m => m.replace(/^(?:>|>)\s?/gmi, '') - return (isCode) - ? formatText(txt, [ + if (isCode) { + return formatText(txt, [ { pr: 1, re: urlRe, with: urlReplaceInCode }, { pr: 1, re: /\B(?:#(\d+))?(?:\/(\d+))?\b/g, with: messageReplyReplace(messageId) }, { pr: 1, re: /\B@([\w-]+)\b/gi, with: '<a href="/$1">@$1</a>' }, ]) - : formatText(txt, [ - { pr: 0, re: /((?:^(?:>|>)\s?[\s\S]+?$\n?)+)/gmi, brackets: true, with: ['<q>', '</q>', bqReplace] }, + } else { + const rules = [ + { pr: 0, re: /((?:^(?:>|>)\s?[\s\S]+?$\n?)+)/gmi, brackets: true, with: ['<blockquote>', '</blockquote>', bqReplace] }, { pr: 1, re: urlRe, with: urlReplace }, { pr: 1, re: /\B(?:#(\d+))?(?:\/(\d+))?\b/g, with: messageReplyReplace(messageId) }, { pr: 1, re: /\B@([\w-]+)\b/gi, with: '<a href="/$1">@$1</a>' }, { pr: 2, re: /\B\*([^\n]+?)\*((?=\s)|(?=$)|(?=[!"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<b>', '</b>'] }, { pr: 2, re: /\B\/([^\n]+?)\/((?=\s)|(?=$)|(?=[!"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<i>', '</i>'] }, { pr: 2, re: /\b_([^\n]+?)_((?=\s)|(?=$)|(?=[!"#$%&'*+,\-./:;<=>?@[\]^_`{|}~()]+))/g, brackets: true, with: ['<u>', '</u>'] }, - { pr: 3, re: /\n/g, with: '<br/>' }, - ]) + ] + if (!isDurov) { + rules.push( + { pr: 3, re: /\n/g, with: '<br/>' } + ) + } + return formatText(txt, rules) + } } /** * @external RegExpExecArray @@ -180,7 +188,7 @@ function getEmbeddableLinkTypes() { makeNode: function(aNode, reResult, div) { // dirty fix for dropbox urls let url = aNode.href.endsWith('dl=0') ? aNode.href.replace('dl=0', 'raw=1') : aNode.href - div.innerHTML = `<a href="${url}"><img src="${url}"></a>` + div.innerHTML = `<a href="${url}"><img loading="lazy" src="${url}"></a>` return div } }, @@ -190,7 +198,7 @@ function getEmbeddableLinkTypes() { className: 'picture compact', re: /\.gif(:[a-zA-Z]+)?(?:\?[\w&;?=]*)?$/i, makeNode: function(aNode, reResult, div) { - div.innerHTML = `<a href="${aNode.href}"><img src="${aNode.href}"></a>` + div.innerHTML = `<a href="${aNode.href}"><img loading="lazy" src="${aNode.href}"></a>` return div } }, @@ -200,7 +208,7 @@ function getEmbeddableLinkTypes() { className: 'video compact', re: /\.(webm|mp4|m4v|ogv)(?:\?[\w&;?=]*)?$/i, makeNode: function(aNode, reResult, div) { - div.innerHTML = `<video src="${aNode.href}" title="${aNode.href}" controls></video>` + div.innerHTML = `<video src="${aNode.href}#t=0.001" title="${aNode.href}" controls></video>` return div } }, @@ -275,7 +283,7 @@ function getEmbeddableLinkTypes() { const twitter_url = reResult[0].startsWith(wrong_prefix) ? reResult[0].replace(wrong_prefix, correct_prefix) : reResult[0] - fetch('https://x.juick.com/oembed?url=' + twitter_url) + fetch('/api/v2/oembed?url=' + twitter_url) .then(response => response.json()) .then(json => { div.innerHTML = json.html @@ -314,6 +322,24 @@ function getEmbeddableLinkTypes() { return div } }, + { + name: 'Tiktok', + id: 'embed_tiktok', + className: 'tiktok compact', + re: /https?:\/\/www\.?tiktok\.com\/(\S+)/i, + makeNode: function(aNode, reResult, div) { + const tiktok_url = reResult[0] + fetch('https://www.tiktok.com/oembed?url=' + tiktok_url) + .then(response => response.json()) + .then(json => { + div.innerHTML = json.html + let script = document.createElement('script') + script.src = 'https://www.tiktok.com/embed.js' + div.appendChild(script) + }).catch(console.log) + return div + } + }, ] } |