From 0e4a111f81cceed275d9bec2695f6e401fb654d8 Mon Sep 17 00:00:00 2001 From: syuilo Date: Fri, 12 Nov 2021 02:02:25 +0900 Subject: refactoring Resolve #7779 --- packages/backend/src/server/web/bios.css | 40 ++ packages/backend/src/server/web/bios.js | 87 +++++ packages/backend/src/server/web/boot.js | 166 +++++++++ packages/backend/src/server/web/cli.css | 19 + packages/backend/src/server/web/cli.js | 55 +++ packages/backend/src/server/web/feed.ts | 58 +++ packages/backend/src/server/web/index.ts | 414 +++++++++++++++++++++ packages/backend/src/server/web/manifest.json | 28 ++ packages/backend/src/server/web/manifest.ts | 15 + packages/backend/src/server/web/style.css | 29 ++ packages/backend/src/server/web/url-preview.ts | 53 +++ packages/backend/src/server/web/views/base.pug | 60 +++ packages/backend/src/server/web/views/bios.pug | 20 + packages/backend/src/server/web/views/channel.pug | 21 ++ packages/backend/src/server/web/views/cli.pug | 21 ++ packages/backend/src/server/web/views/clip.pug | 33 ++ packages/backend/src/server/web/views/flush.pug | 47 +++ .../backend/src/server/web/views/gallery-post.pug | 35 ++ .../backend/src/server/web/views/info-card.pug | 50 +++ packages/backend/src/server/web/views/note.pug | 43 +++ packages/backend/src/server/web/views/page.pug | 33 ++ packages/backend/src/server/web/views/user.pug | 42 +++ 22 files changed, 1369 insertions(+) create mode 100644 packages/backend/src/server/web/bios.css create mode 100644 packages/backend/src/server/web/bios.js create mode 100644 packages/backend/src/server/web/boot.js create mode 100644 packages/backend/src/server/web/cli.css create mode 100644 packages/backend/src/server/web/cli.js create mode 100644 packages/backend/src/server/web/feed.ts create mode 100644 packages/backend/src/server/web/index.ts create mode 100644 packages/backend/src/server/web/manifest.json create mode 100644 packages/backend/src/server/web/manifest.ts create mode 100644 packages/backend/src/server/web/style.css create mode 100644 packages/backend/src/server/web/url-preview.ts create mode 100644 packages/backend/src/server/web/views/base.pug create mode 100644 packages/backend/src/server/web/views/bios.pug create mode 100644 packages/backend/src/server/web/views/channel.pug create mode 100644 packages/backend/src/server/web/views/cli.pug create mode 100644 packages/backend/src/server/web/views/clip.pug create mode 100644 packages/backend/src/server/web/views/flush.pug create mode 100644 packages/backend/src/server/web/views/gallery-post.pug create mode 100644 packages/backend/src/server/web/views/info-card.pug create mode 100644 packages/backend/src/server/web/views/note.pug create mode 100644 packages/backend/src/server/web/views/page.pug create mode 100644 packages/backend/src/server/web/views/user.pug (limited to 'packages/backend/src/server/web') diff --git a/packages/backend/src/server/web/bios.css b/packages/backend/src/server/web/bios.css new file mode 100644 index 0000000000..b0da3ee39b --- /dev/null +++ b/packages/backend/src/server/web/bios.css @@ -0,0 +1,40 @@ +* { + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; +} + +html { + background: #ffb4e1; +} + +main { + background: #dedede; +} +main > .tabs { + padding: 16px; + border-bottom: solid 4px #c3c3c3; +} + +#lsEditor > .adder { + margin: 16px; + padding: 16px; + border: solid 2px #c3c3c3; +} +#lsEditor > .adder > textarea { + display: block; + width: 100%; + min-height: 5em; + box-sizing: border-box; +} +#lsEditor > .record { + padding: 16px; + border-bottom: solid 1px #c3c3c3; +} +#lsEditor > .record > header { + font-weight: bold; +} +#lsEditor > .record > textarea { + display: block; + width: 100%; + min-height: 5em; + box-sizing: border-box; +} diff --git a/packages/backend/src/server/web/bios.js b/packages/backend/src/server/web/bios.js new file mode 100644 index 0000000000..d06dee801a --- /dev/null +++ b/packages/backend/src/server/web/bios.js @@ -0,0 +1,87 @@ +'use strict'; + +window.onload = async () => { + const account = JSON.parse(localStorage.getItem('account')); + const i = account.token; + + const api = (endpoint, data = {}) => { + const promise = new Promise((resolve, reject) => { + // Append a credential + if (i) data.i = i; + + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache' + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + return promise; + }; + + const content = document.getElementById('content'); + + document.getElementById('ls').addEventListener('click', () => { + content.innerHTML = ''; + + const lsEditor = document.createElement('div'); + lsEditor.id = 'lsEditor'; + + const adder = document.createElement('div'); + adder.classList.add('adder'); + const addKeyInput = document.createElement('input'); + const addValueTextarea = document.createElement('textarea'); + const addButton = document.createElement('button'); + addButton.textContent = 'add'; + addButton.addEventListener('click', () => { + localStorage.setItem(addKeyInput.value, addValueTextarea.value); + location.reload(); + }); + + adder.appendChild(addKeyInput); + adder.appendChild(addValueTextarea); + adder.appendChild(addButton); + lsEditor.appendChild(adder); + + for (let i = 0; i < localStorage.length; i++) { + const k = localStorage.key(i); + const record = document.createElement('div'); + record.classList.add('record'); + const header = document.createElement('header'); + header.textContent = k; + const textarea = document.createElement('textarea'); + textarea.textContent = localStorage.getItem(k); + const saveButton = document.createElement('button'); + saveButton.textContent = 'save'; + saveButton.addEventListener('click', () => { + localStorage.setItem(k, textarea.value); + location.reload(); + }); + const removeButton = document.createElement('button'); + removeButton.textContent = 'remove'; + removeButton.addEventListener('click', () => { + localStorage.removeItem(k); + location.reload(); + }); + record.appendChild(header); + record.appendChild(textarea); + record.appendChild(saveButton); + record.appendChild(removeButton); + lsEditor.appendChild(record); + } + + content.appendChild(lsEditor); + }); +}; diff --git a/packages/backend/src/server/web/boot.js b/packages/backend/src/server/web/boot.js new file mode 100644 index 0000000000..d4a2529e63 --- /dev/null +++ b/packages/backend/src/server/web/boot.js @@ -0,0 +1,166 @@ +/** + * BOOT LOADER + * サーバーからレスポンスされるHTMLに埋め込まれるスクリプトで、以下の役割を持ちます。 + * - 翻訳ファイルをフェッチする。 + * - バージョンに基づいて適切なメインスクリプトを読み込む。 + * - キャッシュされたコンパイル済みテーマを適用する。 + * - クライアントの設定値に基づいて対応するHTMLクラス等を設定する。 + * テーマをこの段階で設定するのは、メインスクリプトが読み込まれる間もテーマを適用したいためです。 + * 注: webpackは介さないため、このファイルではrequireやimportは使えません。 + */ + +'use strict'; + +// ブロックの中に入れないと、定義した変数がブラウザのグローバルスコープに登録されてしまい邪魔なので +(async () => { + window.onerror = (e) => { + renderError('SOMETHING_HAPPENED', e.toString()); + }; + window.onunhandledrejection = (e) => { + renderError('SOMETHING_HAPPENED_IN_PROMISE', e.toString()); + }; + + const v = localStorage.getItem('v') || VERSION; + + //#region Detect language & fetch translations + const localeVersion = localStorage.getItem('localeVersion'); + const localeOutdated = (localeVersion == null || localeVersion !== v); + + if (!localStorage.hasOwnProperty('locale') || localeOutdated) { + const supportedLangs = LANGS; + let lang = localStorage.getItem('lang'); + if (lang == null || !supportedLangs.includes(lang)) { + if (supportedLangs.includes(navigator.language)) { + lang = navigator.language; + } else { + lang = supportedLangs.find(x => x.split('-')[0] === navigator.language); + + // Fallback + if (lang == null) lang = 'en-US'; + } + } + + const res = await fetch(`/assets/locales/${lang}.${v}.json`); + if (res.status === 200) { + localStorage.setItem('lang', lang); + localStorage.setItem('locale', await res.text()); + localStorage.setItem('localeVersion', v); + } else if (localeOutdated) { + // nop + } else { + await checkUpdate(); + renderError('LOCALE_FETCH_FAILED'); + return; + } + } + //#endregion + + //#region Script + const salt = localStorage.getItem('salt') + ? `?salt=${localStorage.getItem('salt')}` + : ''; + + const script = document.createElement('script'); + script.setAttribute('src', `/assets/app.${v}.js${salt}`); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + script.addEventListener('error', async () => { + await checkUpdate(); + renderError('APP_FETCH_FAILED'); + }); + document.head.appendChild(script); + //#endregion + + //#region Theme + const theme = localStorage.getItem('theme'); + if (theme) { + for (const [k, v] of Object.entries(JSON.parse(theme))) { + document.documentElement.style.setProperty(`--${k}`, v.toString()); + + // HTMLの theme-color 適用 + if (k === 'htmlThemeColor') { + for (const tag of document.head.children) { + if (tag.tagName === 'META' && tag.getAttribute('name') === 'theme-color') { + tag.setAttribute('content', v); + break; + } + } + } + } + } + //#endregion + + const fontSize = localStorage.getItem('fontSize'); + if (fontSize) { + document.documentElement.classList.add('f-' + fontSize); + } + + const useSystemFont = localStorage.getItem('useSystemFont'); + if (useSystemFont) { + document.documentElement.classList.add('useSystemFont'); + } + + const wallpaper = localStorage.getItem('wallpaper'); + if (wallpaper) { + document.documentElement.style.backgroundImage = `url(${wallpaper})`; + } + + const customCss = localStorage.getItem('customCss'); + if (customCss && customCss.length > 0) { + const style = document.createElement('style'); + style.innerHTML = customCss; + document.head.appendChild(style); + } + + // eslint-disable-next-line no-inner-declarations + function renderError(code, details) { + document.documentElement.innerHTML = ` +

⚠エラーが発生しました

+

問題が解決しない場合は管理者までお問い合わせください。以下のオプションを試すこともできます:

+ +
+ ERROR CODE: ${code} +
+ ${details} +
+ `; + } + + // eslint-disable-next-line no-inner-declarations + async function checkUpdate() { + // TODO: サーバーが落ちている場合などのエラーハンドリング + const res = await fetch('/api/meta', { + method: 'POST', + cache: 'no-cache' + }); + + const meta = await res.json(); + + if (meta.version != v) { + localStorage.setItem('v', meta.version); + refresh(); + } + } + + // eslint-disable-next-line no-inner-declarations + function refresh() { + // Random + localStorage.setItem('salt', Math.random().toString().substr(2, 8)); + + // Clear cache (service worker) + try { + navigator.serviceWorker.controller.postMessage('clear'); + navigator.serviceWorker.getRegistrations().then(registrations => { + registrations.forEach(registration => registration.unregister()); + }); + } catch (e) { + console.error(e); + } + + location.reload(); + } +})(); diff --git a/packages/backend/src/server/web/cli.css b/packages/backend/src/server/web/cli.css new file mode 100644 index 0000000000..07cd27830b --- /dev/null +++ b/packages/backend/src/server/web/cli.css @@ -0,0 +1,19 @@ +* { + font-family: Fira code, Fira Mono, Consolas, Menlo, Courier, monospace; +} + +html { + background: #ffb4e1; +} + +main { + background: #dedede; +} + +#tl > div { + padding: 16px; + border-bottom: solid 1px #c3c3c3; +} +#tl > div > header { + font-weight: bold; +} diff --git a/packages/backend/src/server/web/cli.js b/packages/backend/src/server/web/cli.js new file mode 100644 index 0000000000..3dff1d4860 --- /dev/null +++ b/packages/backend/src/server/web/cli.js @@ -0,0 +1,55 @@ +'use strict'; + +window.onload = async () => { + const account = JSON.parse(localStorage.getItem('account')); + const i = account.token; + + const api = (endpoint, data = {}) => { + const promise = new Promise((resolve, reject) => { + // Append a credential + if (i) data.i = i; + + // Send request + fetch(endpoint.indexOf('://') > -1 ? endpoint : `/api/${endpoint}`, { + method: 'POST', + body: JSON.stringify(data), + credentials: 'omit', + cache: 'no-cache' + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + return promise; + }; + + document.getElementById('submit').addEventListener('click', () => { + api('notes/create', { + text: document.getElementById('text').value + }).then(() => { + location.reload(); + }); + }); + + api('notes/timeline').then(notes => { + const tl = document.getElementById('tl'); + for (const note of notes) { + const el = document.createElement('div'); + const name = document.createElement('header'); + name.textContent = `${note.user.name} @${note.user.username}`; + const text = document.createElement('div'); + text.textContent = `${note.text}`; + el.appendChild(name); + el.appendChild(text); + tl.appendChild(el); + } + }); +}; diff --git a/packages/backend/src/server/web/feed.ts b/packages/backend/src/server/web/feed.ts new file mode 100644 index 0000000000..1d4c47dafb --- /dev/null +++ b/packages/backend/src/server/web/feed.ts @@ -0,0 +1,58 @@ +import { Feed } from 'feed'; +import config from '@/config/index'; +import { User } from '@/models/entities/user'; +import { Notes, DriveFiles, UserProfiles } from '@/models/index'; +import { In } from 'typeorm'; + +export default async function(user: User) { + const author = { + link: `${config.url}/@${user.username}`, + name: user.name || user.username + }; + + const profile = await UserProfiles.findOneOrFail(user.id); + + const notes = await Notes.find({ + where: { + userId: user.id, + renoteId: null, + visibility: In(['public', 'home']) + }, + order: { createdAt: -1 }, + take: 20 + }); + + const feed = new Feed({ + id: author.link, + title: `${author.name} (@${user.username}@${config.host})`, + updated: notes[0].createdAt, + generator: 'Misskey', + description: `${user.notesCount} Notes, ${profile.ffVisibility === 'public' ? user.followingCount : '?'} Following, ${profile.ffVisibility === 'public' ? user.followersCount : '?'} Followers${profile.description ? ` · ${profile.description}` : ''}`, + link: author.link, + image: user.avatarUrl ? user.avatarUrl : undefined, + feedLinks: { + json: `${author.link}.json`, + atom: `${author.link}.atom`, + }, + author, + copyright: user.name || user.username + }); + + for (const note of notes) { + const files = note.fileIds.length > 0 ? await DriveFiles.find({ + id: In(note.fileIds) + }) : []; + const file = files.find(file => file.type.startsWith('image/')); + + feed.addItem({ + title: `New note by ${author.name}`, + link: `${config.url}/notes/${note.id}`, + date: note.createdAt, + description: note.cw || undefined, + content: note.text || undefined, + image: file ? DriveFiles.getPublicUrl(file) || undefined : undefined + }); + } + + return feed; +} diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts new file mode 100644 index 0000000000..969b155d4d --- /dev/null +++ b/packages/backend/src/server/web/index.ts @@ -0,0 +1,414 @@ +/** + * Web Client Server + */ + +import { dirname } from 'path'; +import * as ms from 'ms'; +import * as Koa from 'koa'; +import * as Router from '@koa/router'; +import * as send from 'koa-send'; +import * as favicon from 'koa-favicon'; +import * as views from 'koa-views'; + +import packFeed from './feed'; +import { fetchMeta } from '@/misc/fetch-meta'; +import { genOpenapiSpec } from '../api/openapi/gen-spec'; +import config from '@/config/index'; +import { Users, Notes, UserProfiles, Pages, Channels, Clips, GalleryPosts } from '@/models/index'; +import * as Acct from 'misskey-js/built/acct'; +import { getNoteSummary } from '@/misc/get-note-summary'; + +//const _filename = fileURLToPath(import.meta.url); +const _filename = __filename; +const _dirname = dirname(_filename); + +const staticAssets = `${_dirname}/../../../assets/`; +const assets = `${_dirname}/../../../../../built/_client_dist_/`; + +// Init app +const app = new Koa(); + +// Init renderer +app.use(views(_dirname + '/views', { + extension: 'pug', + options: { + version: config.version, + config + } +})); + +// Serve favicon +app.use(favicon(`${_dirname}/../../../assets/favicon.ico`)); + +// Common request handler +app.use(async (ctx, next) => { + // IFrameの中に入れられないようにする + ctx.set('X-Frame-Options', 'DENY'); + await next(); +}); + +// Init router +const router = new Router(); + +//#region static assets + +router.get('/static-assets/(.*)', async ctx => { + await send(ctx as any, ctx.path.replace('/static-assets/', ''), { + root: staticAssets, + maxage: ms('7 days'), + }); +}); + +router.get('/assets/(.*)', async ctx => { + await send(ctx as any, ctx.path.replace('/assets/', ''), { + root: assets, + maxage: ms('7 days'), + }); +}); + +// Apple touch icon +router.get('/apple-touch-icon.png', async ctx => { + await send(ctx as any, '/apple-touch-icon.png', { + root: staticAssets + }); +}); + +router.get('/twemoji/(.*)', async ctx => { + const path = ctx.path.replace('/twemoji/', ''); + + if (!path.match(/^[0-9a-f-]+\.svg$/)) { + ctx.status = 404; + return; + } + + ctx.set('Content-Security-Policy', `default-src 'none'; style-src 'unsafe-inline'`); + + await send(ctx as any, path, { + root: `${_dirname}/../../../node_modules/@discordapp/twemoji/dist/svg/`, + maxage: ms('30 days'), + }); +}); + +// ServiceWorker +router.get('/sw.js', async ctx => { + await send(ctx as any, `/sw.${config.version}.js`, { + root: assets + }); +}); + +// Manifest +router.get('/manifest.json', require('./manifest')); + +router.get('/robots.txt', async ctx => { + await send(ctx as any, '/robots.txt', { + root: staticAssets + }); +}); + +//#endregion + +// Docs +router.get('/api-doc', async ctx => { + await send(ctx as any, '/redoc.html', { + root: staticAssets + }); +}); + +// URL preview endpoint +router.get('/url', require('./url-preview')); + +router.get('/api.json', async ctx => { + ctx.body = genOpenapiSpec(); +}); + +const getFeed = async (acct: string) => { + const { username, host } = Acct.parse(acct); + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host, + isSuspended: false + }); + + return user && await packFeed(user); +}; + +// Atom +router.get('/@:user.atom', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/atom+xml; charset=utf-8'); + ctx.body = feed.atom1(); + } else { + ctx.status = 404; + } +}); + +// RSS +router.get('/@:user.rss', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/rss+xml; charset=utf-8'); + ctx.body = feed.rss2(); + } else { + ctx.status = 404; + } +}); + +// JSON +router.get('/@:user.json', async ctx => { + const feed = await getFeed(ctx.params.user); + + if (feed) { + ctx.set('Content-Type', 'application/json; charset=utf-8'); + ctx.body = feed.json1(); + } else { + ctx.status = 404; + } +}); + +//#region SSR (for crawlers) +// User +router.get(['/@:user', '/@:user/:sub'], async (ctx, next) => { + const { username, host } = Acct.parse(ctx.params.user); + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host, + isSuspended: false + }); + + if (user != null) { + const profile = await UserProfiles.findOneOrFail(user.id); + const meta = await fetchMeta(); + const me = profile.fields + ? profile.fields + .filter(filed => filed.value != null && filed.value.match(/^https?:/)) + .map(field => field.value) + : []; + + await ctx.render('user', { + user, profile, me, + sub: ctx.params.sub, + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl + }); + ctx.set('Cache-Control', 'public, max-age=30'); + } else { + // リモートユーザーなので + // モデレータがAPI経由で参照可能にするために404にはしない + await next(); + } +}); + +router.get('/users/:user', async ctx => { + const user = await Users.findOne({ + id: ctx.params.user, + host: null, + isSuspended: false + }); + + if (user == null) { + ctx.status = 404; + return; + } + + ctx.redirect(`/@${user.username}${ user.host == null ? '' : '@' + user.host}`); +}); + +// Note +router.get('/notes/:note', async (ctx, next) => { + const note = await Notes.findOne(ctx.params.note); + + if (note) { + const _note = await Notes.pack(note); + const profile = await UserProfiles.findOneOrFail(note.userId); + const meta = await fetchMeta(); + await ctx.render('note', { + note: _note, + profile, + // TODO: Let locale changeable by instance setting + summary: getNoteSummary(_note), + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl + }); + + if (['public', 'home'].includes(note.visibility)) { + ctx.set('Cache-Control', 'public, max-age=180'); + } else { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } + + return; + } + + await next(); +}); + +// Page +router.get('/@:user/pages/:page', async (ctx, next) => { + const { username, host } = Acct.parse(ctx.params.user); + const user = await Users.findOne({ + usernameLower: username.toLowerCase(), + host + }); + + if (user == null) return; + + const page = await Pages.findOne({ + name: ctx.params.page, + userId: user.id + }); + + if (page) { + const _page = await Pages.pack(page); + const profile = await UserProfiles.findOneOrFail(page.userId); + const meta = await fetchMeta(); + await ctx.render('page', { + page: _page, + profile, + instanceName: meta.name || 'Misskey' + }); + + if (['public'].includes(page.visibility)) { + ctx.set('Cache-Control', 'public, max-age=180'); + } else { + ctx.set('Cache-Control', 'private, max-age=0, must-revalidate'); + } + + return; + } + + await next(); +}); + +// Clip +// TODO: 非publicなclipのハンドリング +router.get('/clips/:clip', async (ctx, next) => { + const clip = await Clips.findOne({ + id: ctx.params.clip, + }); + + if (clip) { + const _clip = await Clips.pack(clip); + const profile = await UserProfiles.findOneOrFail(clip.userId); + const meta = await fetchMeta(); + await ctx.render('clip', { + clip: _clip, + profile, + instanceName: meta.name || 'Misskey' + }); + + ctx.set('Cache-Control', 'public, max-age=180'); + + return; + } + + await next(); +}); + +// Gallery post +router.get('/gallery/:post', async (ctx, next) => { + const post = await GalleryPosts.findOne(ctx.params.post); + + if (post) { + const _post = await GalleryPosts.pack(post); + const profile = await UserProfiles.findOneOrFail(post.userId); + const meta = await fetchMeta(); + await ctx.render('gallery-post', { + post: _post, + profile, + instanceName: meta.name || 'Misskey', + icon: meta.iconUrl + }); + + ctx.set('Cache-Control', 'public, max-age=180'); + + return; + } + + await next(); +}); + +// Channel +router.get('/channels/:channel', async (ctx, next) => { + const channel = await Channels.findOne({ + id: ctx.params.channel, + }); + + if (channel) { + const _channel = await Channels.pack(channel); + const meta = await fetchMeta(); + await ctx.render('channel', { + channel: _channel, + instanceName: meta.name || 'Misskey' + }); + + ctx.set('Cache-Control', 'public, max-age=180'); + + return; + } + + await next(); +}); +//#endregion + +router.get('/_info_card_', async ctx => { + const meta = await fetchMeta(true); + + ctx.remove('X-Frame-Options'); + + await ctx.render('info-card', { + version: config.version, + host: config.host, + meta: meta, + originalUsersCount: await Users.count({ host: null }), + originalNotesCount: await Notes.count({ userHost: null }) + }); +}); + +router.get('/bios', async ctx => { + await ctx.render('bios', { + version: config.version, + }); +}); + +router.get('/cli', async ctx => { + await ctx.render('cli', { + version: config.version, + }); +}); + +const override = (source: string, target: string, depth: number = 0) => + [, ...target.split('/').filter(x => x), ...source.split('/').filter(x => x).splice(depth)].join('/'); + +router.get('/othello', async ctx => ctx.redirect(override(ctx.URL.pathname, 'games/reversi', 1))); +router.get('/reversi', async ctx => ctx.redirect(override(ctx.URL.pathname, 'games'))); + +router.get('/flush', async ctx => { + await ctx.render('flush'); +}); + +// streamingに非WebSocketリクエストが来た場合にbase htmlをキャシュ付きで返すと、Proxy等でそのパスがキャッシュされておかしくなる +router.get('/streaming', async ctx => { + ctx.status = 503; + ctx.set('Cache-Control', 'private, max-age=0'); +}); + +// Render base html for all requests +router.get('(.*)', async ctx => { + const meta = await fetchMeta(); + await ctx.render('base', { + img: meta.bannerUrl, + title: meta.name || 'Misskey', + instanceName: meta.name || 'Misskey', + desc: meta.description, + icon: meta.iconUrl + }); + ctx.set('Cache-Control', 'public, max-age=300'); +}); + +// Register router +app.use(router.routes()); + +module.exports = app; diff --git a/packages/backend/src/server/web/manifest.json b/packages/backend/src/server/web/manifest.json new file mode 100644 index 0000000000..48030a2980 --- /dev/null +++ b/packages/backend/src/server/web/manifest.json @@ -0,0 +1,28 @@ +{ + "short_name": "Misskey", + "name": "Misskey", + "start_url": "/", + "display": "standalone", + "background_color": "#313a42", + "theme_color": "#86b300", + "icons": [ + { + "src": "/static-assets/icons/192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/static-assets/icons/512.png", + "sizes": "512x512", + "type": "image/png" + } + ], + "share_target": { + "action": "/share/", + "params": { + "title": "title", + "text": "text", + "url": "url" + } + } +} diff --git a/packages/backend/src/server/web/manifest.ts b/packages/backend/src/server/web/manifest.ts new file mode 100644 index 0000000000..918fe27c03 --- /dev/null +++ b/packages/backend/src/server/web/manifest.ts @@ -0,0 +1,15 @@ +import * as Koa from 'koa'; +import * as manifest from './manifest.json'; +import { fetchMeta } from '@/misc/fetch-meta'; + +module.exports = async (ctx: Koa.Context) => { + const json = JSON.parse(JSON.stringify(manifest)); + + const instance = await fetchMeta(true); + + json.short_name = instance.name || 'Misskey'; + json.name = instance.name || 'Misskey'; + + ctx.set('Cache-Control', 'max-age=300'); + ctx.body = json; +}; diff --git a/packages/backend/src/server/web/style.css b/packages/backend/src/server/web/style.css new file mode 100644 index 0000000000..43fbe1ab06 --- /dev/null +++ b/packages/backend/src/server/web/style.css @@ -0,0 +1,29 @@ +html { + background-color: var(--bg); + color: var(--fg); +} + +#splash { + position: fixed; + z-index: 10000; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + cursor: wait; + background-color: var(--bg); + opacity: 1; + transition: opacity 0.5s ease; +} + +#splash > img { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + margin: auto; + width: 64px; + height: 64px; + pointer-events: none; +} diff --git a/packages/backend/src/server/web/url-preview.ts b/packages/backend/src/server/web/url-preview.ts new file mode 100644 index 0000000000..1375420c0a --- /dev/null +++ b/packages/backend/src/server/web/url-preview.ts @@ -0,0 +1,53 @@ +import * as Koa from 'koa'; +import summaly from 'summaly'; +import { fetchMeta } from '@/misc/fetch-meta'; +import Logger from '@/services/logger'; +import config from '@/config/index'; +import { query } from '@/prelude/url'; +import { getJson } from '@/misc/fetch'; + +const logger = new Logger('url-preview'); + +module.exports = async (ctx: Koa.Context) => { + const meta = await fetchMeta(); + + logger.info(meta.summalyProxy + ? `(Proxy) Getting preview of ${ctx.query.url}@${ctx.query.lang} ...` + : `Getting preview of ${ctx.query.url}@${ctx.query.lang} ...`); + + try { + const summary = meta.summalyProxy ? await getJson(`${meta.summalyProxy}?${query({ + url: ctx.query.url, + lang: ctx.query.lang || 'ja-JP' + })}`) : await summaly(ctx.query.url, { + followRedirects: false, + lang: ctx.query.lang || 'ja-JP' + }); + + logger.succ(`Got preview of ${ctx.query.url}: ${summary.title}`); + + summary.icon = wrap(summary.icon); + summary.thumbnail = wrap(summary.thumbnail); + + // Cache 7days + ctx.set('Cache-Control', 'max-age=604800, immutable'); + + ctx.body = summary; + } catch (e) { + logger.warn(`Failed to get preview of ${ctx.query.url}: ${e}`); + ctx.status = 200; + ctx.set('Cache-Control', 'max-age=86400, immutable'); + ctx.body = '{}'; + } +}; + +function wrap(url?: string): string | null { + return url != null + ? url.match(/^https?:\/\//) + ? `${config.url}/proxy/preview.jpg?${query({ + url, + preview: '1' + })}` + : url + : null; +} diff --git a/packages/backend/src/server/web/views/base.pug b/packages/backend/src/server/web/views/base.pug new file mode 100644 index 0000000000..42c068c403 --- /dev/null +++ b/packages/backend/src/server/web/views/base.pug @@ -0,0 +1,60 @@ +block vars + +doctype html + +!= '\n' + +html + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + meta(name='referrer' content='origin') + meta(name='theme-color' content='#86b300') + meta(name='theme-color-orig' content='#86b300') + meta(property='og:site_name' content= instanceName || 'Misskey') + meta(name='viewport' content='width=device-width, initial-scale=1') + link(rel='icon' href= icon || '/favicon.ico') + link(rel='apple-touch-icon' href= icon || '/apple-touch-icon.png') + link(rel='manifest' href='/manifest.json') + link(rel='prefetch' href='https://xn--931a.moe/assets/info.jpg') + link(rel='prefetch' href='https://xn--931a.moe/assets/not-found.jpg') + link(rel='prefetch' href='https://xn--931a.moe/assets/error.jpg') + link(rel='preload' href='https://use.fontawesome.com/releases/v5.15.3/css/all.css' as='style') + link(rel='stylesheet' href='https://use.fontawesome.com/releases/v5.15.3/css/all.css') + + title + block title + = title || 'Misskey' + + block desc + meta(name='description' content= desc || '✨🌎✨ A interplanetary communication platform ✨🚀✨') + + block meta + + block og + meta(property='og:image' content=img) + + style + include ../style.css + + script + include ../boot.js + + body + noscript: p + | JavaScriptを有効にしてください + br + | Please turn on your JavaScript + div#splash + img(src='/favicon.ico') + block content diff --git a/packages/backend/src/server/web/views/bios.pug b/packages/backend/src/server/web/views/bios.pug new file mode 100644 index 0000000000..d81a3ee67f --- /dev/null +++ b/packages/backend/src/server/web/views/bios.pug @@ -0,0 +1,20 @@ +doctype html + +html + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + title Misskey BIOS + style + include ../bios.css + script + include ../bios.js + + body + header + h1 Misskey BIOS #{version} + main + div.tabs + button#ls edit local storage + div#content diff --git a/packages/backend/src/server/web/views/channel.pug b/packages/backend/src/server/web/views/channel.pug new file mode 100644 index 0000000000..273632f0e0 --- /dev/null +++ b/packages/backend/src/server/web/views/channel.pug @@ -0,0 +1,21 @@ +extends ./base + +block vars + - const title = channel.name; + - const url = `${config.url}/channels/${channel.id}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= channel.description) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= channel.description) + meta(property='og:url' content= url) + meta(property='og:image' content= channel.bannerUrl) + +block meta + meta(name='twitter:card' content='summary') diff --git a/packages/backend/src/server/web/views/cli.pug b/packages/backend/src/server/web/views/cli.pug new file mode 100644 index 0000000000..d2cf7c4335 --- /dev/null +++ b/packages/backend/src/server/web/views/cli.pug @@ -0,0 +1,21 @@ +doctype html + +html + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + title Misskey Cli + style + include ../cli.css + script + include ../cli.js + + body + header + h1 Misskey Cli #{version} + main + div#form + textarea#text + button#submit submit + div#tl diff --git a/packages/backend/src/server/web/views/clip.pug b/packages/backend/src/server/web/views/clip.pug new file mode 100644 index 0000000000..8de53f19d6 --- /dev/null +++ b/packages/backend/src/server/web/views/clip.pug @@ -0,0 +1,33 @@ +extends ./base + +block vars + - const user = clip.user; + - const title = clip.name; + - const url = `${config.url}/clips/${clip.id}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= clip.description) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= clip.description) + meta(property='og:url' content= url) + meta(property='og:image' content= user.avatarUrl) + +block meta + if profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + meta(name='misskey:clip-id' content=clip.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/packages/backend/src/server/web/views/flush.pug b/packages/backend/src/server/web/views/flush.pug new file mode 100644 index 0000000000..ec585a34db --- /dev/null +++ b/packages/backend/src/server/web/views/flush.pug @@ -0,0 +1,47 @@ +doctype html + +html + #msg + script. + const msg = document.getElementById('msg'); + const successText = `\nSuccess Flush! Back to Misskey\n成功しました。Misskeyを開き直してください。`; + + message('Start flushing.'); + + (async function() { + try { + localStorage.clear(); + message('localStorage cleared.'); + + const idbPromises = ['MisskeyClient', 'keyval-store'].map((name, i, arr) => new Promise((res, rej) => { + const delidb = indexedDB.deleteDatabase(name); + delidb.onsuccess = () => res(message(`indexedDB "${name}" cleared. (${i + 1}/${arr.length})`)); + delidb.onerror = e => rej(e) + })); + + await Promise.all(idbPromises); + + if (navigator.serviceWorker.controller) { + navigator.serviceWorker.controller.postMessage('clear'); + await navigator.serviceWorker.getRegistrations() + .then(registrations => { + return Promise.all(registrations.map(registration => registration.unregister())); + }) + .catch(e => { throw Error(e) }); + } + + message(successText); + } catch (e) { + message(`\n${e}\n\nFlush Failed. Please retry.\n失敗しました。もう一度試してみてください。`); + message(`\nIf you retry more than 3 times, clear the browser cache or contact to instance admin.\n3回以上試しても失敗する場合、ブラウザのキャッシュを消去し、それでもだめならインスタンス管理者に連絡してみてください。\n`) + + console.error(e); + setTimeout(() => { + location = '/'; + }, 10000) + } + })(); + + function message(text) { + msg.insertAdjacentHTML('beforeend', `

[${(new Date()).toString()}] ${text.replace(/\n/g,'
')}

`) + } diff --git a/packages/backend/src/server/web/views/gallery-post.pug b/packages/backend/src/server/web/views/gallery-post.pug new file mode 100644 index 0000000000..95bbb2437c --- /dev/null +++ b/packages/backend/src/server/web/views/gallery-post.pug @@ -0,0 +1,35 @@ +extends ./base + +block vars + - const user = post.user; + - const title = post.title; + - const url = `${config.url}/gallery/${post.id}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= post.description) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= post.description) + meta(property='og:url' content= url) + meta(property='og:image' content= post.files[0].thumbnailUrl) + +block meta + if user.host || profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) + + if !user.host + link(rel='alternate' href=url type='application/activity+json') diff --git a/packages/backend/src/server/web/views/info-card.pug b/packages/backend/src/server/web/views/info-card.pug new file mode 100644 index 0000000000..1d62778ce1 --- /dev/null +++ b/packages/backend/src/server/web/views/info-card.pug @@ -0,0 +1,50 @@ +doctype html + +html + + head + meta(charset='utf-8') + meta(name='application-name' content='Misskey') + title= meta.name || host + style. + html, body { + margin: 0; + padding: 0; + min-height: 100vh; + background: #fff; + } + + #a { + display: block; + } + + #banner { + background-size: cover; + background-position: center center; + } + + #title { + display: inline-block; + margin: 24px; + padding: 0.5em 0.8em; + color: #fff; + background: rgba(0, 0, 0, 0.5); + font-weight: bold; + font-size: 1.3em; + } + + #content { + overflow: auto; + color: #353c3e; + } + + #description { + margin: 24px; + } + + body + a#a(href=`https://${host}` target="_blank") + header#banner(style=`background-image: url(${meta.bannerUrl})`) + div#title= meta.name || host + div#content + div#description= meta.description diff --git a/packages/backend/src/server/web/views/note.pug b/packages/backend/src/server/web/views/note.pug new file mode 100644 index 0000000000..7030936975 --- /dev/null +++ b/packages/backend/src/server/web/views/note.pug @@ -0,0 +1,43 @@ +extends ./base + +block vars + - const user = note.user; + - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; + - const url = `${config.url}/notes/${note.id}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= summary) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= summary) + meta(property='og:url' content= url) + meta(property='og:image' content= user.avatarUrl) + +block meta + if user.host || profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + meta(name='misskey:note-id' content=note.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) + + if note.prev + link(rel='prev' href=`${config.url}/notes/${note.prev}`) + if note.next + link(rel='next' href=`${config.url}/notes/${note.next}`) + + if !user.host + link(rel='alternate' href=url type='application/activity+json') + if note.uri + link(rel='alternate' href=note.uri type='application/activity+json') diff --git a/packages/backend/src/server/web/views/page.pug b/packages/backend/src/server/web/views/page.pug new file mode 100644 index 0000000000..cb9e1039e1 --- /dev/null +++ b/packages/backend/src/server/web/views/page.pug @@ -0,0 +1,33 @@ +extends ./base + +block vars + - const user = page.user; + - const title = page.title; + - const url = `${config.url}/@${user.username}/${page.name}`; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= page.summary) + +block og + meta(property='og:type' content='article') + meta(property='og:title' content= title) + meta(property='og:description' content= page.summary) + meta(property='og:url' content= url) + meta(property='og:image' content= page.eyeCatchingImage ? page.eyeCatchingImage.thumbnailUrl : user.avatarUrl) + +block meta + if profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + meta(name='misskey:page-id' content=page.id) + + meta(name='twitter:card' content='summary') + + // todo + if user.twitter + meta(name='twitter:creator' content=`@${user.twitter.screenName}`) diff --git a/packages/backend/src/server/web/views/user.pug b/packages/backend/src/server/web/views/user.pug new file mode 100644 index 0000000000..1a8a6b4413 --- /dev/null +++ b/packages/backend/src/server/web/views/user.pug @@ -0,0 +1,42 @@ +extends ./base + +block vars + - const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`; + - const url = `${config.url}/@${(user.host ? `${user.username}@${user.host}` : user.username)}`; + - const img = user.avatarUrl || null; + +block title + = `${title} | ${instanceName}` + +block desc + meta(name='description' content= profile.description) + +block og + meta(property='og:type' content='blog') + meta(property='og:title' content= title) + meta(property='og:description' content= profile.description) + meta(property='og:url' content= url) + meta(property='og:image' content= img) + +block meta + if user.host || profile.noCrawle + meta(name='robots' content='noindex') + + meta(name='misskey:user-username' content=user.username) + meta(name='misskey:user-id' content=user.id) + + meta(name='twitter:card' content='summary') + + if profile.twitter + meta(name='twitter:creator' content=`@${profile.twitter.screenName}`) + + if !sub + if !user.host + link(rel='alternate' href=`${config.url}/users/${user.id}` type='application/activity+json') + if user.uri + link(rel='alternate' href=user.uri type='application/activity+json') + if profile.url + link(rel='alternate' href=profile.url type='text/html') + + each m in me + link(rel='me' href=`${m}`) -- cgit v1.2.3-freya