aboutsummaryrefslogtreecommitdiff
path: root/vnext
diff options
context:
space:
mode:
Diffstat (limited to 'vnext')
-rw-r--r--vnext/server/Dockerfile19
-rw-r--r--vnext/server/app.js66
-rw-r--r--vnext/server/common/MessageUtils.js5
-rw-r--r--vnext/server/db/Users.js43
-rw-r--r--vnext/server/db/__testdata__/testdata.sql1
-rw-r--r--vnext/server/db/index.js10
-rw-r--r--vnext/server/durov.js2
-rw-r--r--vnext/server/index.js41
-rw-r--r--vnext/server/middleware/android.js43
-rw-r--r--vnext/server/middleware/android.spec.js33
-rw-r--r--vnext/server/middleware/event.js7
-rw-r--r--vnext/server/middleware/host-meta.js42
-rw-r--r--vnext/server/middleware/legacy.json142
-rw-r--r--vnext/server/middleware/mastodon.js23
-rw-r--r--vnext/server/middleware/mastodon.spec.js13
-rw-r--r--vnext/server/middleware/rememberme.js50
-rw-r--r--vnext/server/middleware/urlexpand.js3
-rw-r--r--vnext/server/middleware/webfinger.js59
-rw-r--r--vnext/server/middleware/webfinger.spec.js28
-rw-r--r--vnext/server/sape.js4
-rw-r--r--vnext/server/sender.js26
-rw-r--r--vnext/server/webpack.config.js59
-rw-r--r--vnext/src/ui/Icon.js4
-rw-r--r--vnext/src/utils/embed.js48
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(/^(?:>|&gt;)\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: /((?:^(?:>|&gt;)\s?[\s\S]+?$\n?)+)/gmi, brackets: true, with: ['<q>', '</q>', bqReplace] },
+ } else {
+ const rules = [
+ { pr: 0, re: /((?:^(?:>|&gt;)\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
+ }
+ },
]
}