diff --git a/packages/backend/src/server/web/UrlPreviewService.ts b/packages/backend/src/server/web/UrlPreviewService.ts index 0cab657c23..737cf9e536 100644 --- a/packages/backend/src/server/web/UrlPreviewService.ts +++ b/packages/backend/src/server/web/UrlPreviewService.ts @@ -15,7 +15,6 @@ import type Logger from '@/logger.js'; import { query } from '@/misc/prelude/url.js'; import { LoggerService } from '@/core/LoggerService.js'; import { bindThis } from '@/decorators.js'; -import { ApiError } from '@/server/api/error.js'; import { MiMeta } from '@/models/Meta.js'; import { RedisKVCache } from '@/misc/cache.js'; import { UtilityService } from '@/core/UtilityService.js'; @@ -24,6 +23,8 @@ import type { NotesRepository } from '@/models/_.js'; import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js'; import { ApRequestService } from '@/core/activitypub/ApRequestService.js'; import { SystemAccountService } from '@/core/SystemAccountService.js'; +import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js'; +import { AuthenticateService, AuthenticationError } from '@/server/api/AuthenticateService.js'; import type { FastifyRequest, FastifyReply } from 'fastify'; export type LocalSummalyResult = SummalyResult & { @@ -33,6 +34,15 @@ export type LocalSummalyResult = SummalyResult & { // Increment this to invalidate cached previews after a major change. const cacheFormatVersion = 2; +type PreviewRoute = { + Querystring: { + url?: string + lang?: string, + fetch?: string, + i?: string, + }, +}; + @Injectable() export class UrlPreviewService { private logger: Logger; @@ -58,6 +68,8 @@ export class UrlPreviewService { private readonly apDbResolverService: ApDbResolverService, private readonly apRequestService: ApRequestService, private readonly systemAccountService: SystemAccountService, + private readonly apNoteService: ApNoteService, + private readonly authenticateService: AuthenticateService, ) { this.logger = this.loggerService.getLogger('url-preview'); this.previewCache = new RedisKVCache(this.redisClient, 'summaly', { @@ -85,9 +97,9 @@ export class UrlPreviewService { @bindThis public async handle( - request: FastifyRequest<{ Querystring: { url?: string; lang?: string; } }>, + request: FastifyRequest, reply: FastifyReply, - ): Promise { + ): Promise { const url = request.query.url; if (typeof url !== 'string' || !URL.canParse(url)) { reply.code(400); @@ -101,38 +113,48 @@ export class UrlPreviewService { } if (!this.meta.urlPreviewEnabled) { - reply.code(403); - return { - error: new ApiError({ + return reply.code(403).send({ + error: { message: 'URL preview is disabled', code: 'URL_PREVIEW_DISABLED', id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8', - }), - }; + }, + }); } if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) { - reply.code(403); - return { - error: new ApiError({ + return reply.code(403).send({ + error: { message: 'URL is blocked', code: 'URL_PREVIEW_BLOCKED', id: '50294652-857b-4b13-9700-8e5c7a8deae8', - }), - }; + }, + }); + } + + const fetch = !!request.query.fetch; + if (fetch && !await this.hasFetchPermissions(request, reply)) { + return; } const cacheKey = `${url}@${lang}@${cacheFormatVersion}`; const cached = await this.previewCache.get(cacheKey); if (cached !== undefined) { - // Cache 1 day (matching redis) - reply.header('Cache-Control', 'public, max-age=86400'); + if (cached.activityPub && !cached.haveNoteLocally) { + cached.haveNoteLocally = await this.hasNoteLocally(cached.activityPub, fetch); - if (cached.activityPub) { - cached.haveNoteLocally = !! await this.apDbResolverService.getNoteFromApId(cached.activityPub); + // Persist the result once we manage to fetch the note + if (cached.haveNoteLocally) { + await this.previewCache.set(cacheKey, cached); + } } - return cached; + // Cache 1 day (matching redis), but not if the note could be fetched later + if (!cached.activityPub || cached.haveNoteLocally) { + reply.header('Cache-Control', 'public, max-age=86400'); + } + + return reply.code(200).send(cached); } try { @@ -144,14 +166,13 @@ export class UrlPreviewService { // Repeat check, since redirects are allowed. if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) { - reply.code(403); - return { - error: new ApiError({ + return reply.code(403).send({ + error: { message: 'URL is blocked', code: 'URL_PREVIEW_BLOCKED', id: '50294652-857b-4b13-9700-8e5c7a8deae8', - }), - }; + }, + }); } this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`); @@ -166,28 +187,29 @@ export class UrlPreviewService { if (summary.activityPub) { // Avoid duplicate checks in case inferActivityPubLink already set this. - summary.haveNoteLocally ||= !!await this.apDbResolverService.getNoteFromApId(summary.activityPub); + summary.haveNoteLocally ||= await this.hasNoteLocally(summary.activityPub, fetch); } // Await this to avoid hammering redis when a bunch of URLs are fetched at once await this.previewCache.set(cacheKey, summary); - // Cache 1 day (matching redis) - reply.header('Cache-Control', 'public, max-age=86400'); + // Cache 1 day (matching redis), but not if the note could be fetched later + if (!summary.activityPub || summary.haveNoteLocally) { + reply.header('Cache-Control', 'public, max-age=86400'); + } - return summary; + return reply.code(200).send(summary); } catch (err) { this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`); - reply.code(422); reply.header('Cache-Control', 'max-age=3600'); - return { - error: new ApiError({ + return reply.code(422).send({ + error: { message: 'Failed to get preview', code: 'URL_PREVIEW_FAILED', id: '09d01cb5-53b9-4856-82e5-38a50c290a3b', - }), - }; + }, + }); } } @@ -211,6 +233,7 @@ export class UrlPreviewService { } private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const proxy = meta.urlPreviewSummaryProxyUrl!; const queryStr = query({ followRedirects: true, @@ -302,4 +325,95 @@ export class UrlPreviewService { return; } } + + private async hasNoteLocally(uri: string, fetch = false): Promise { + try { + // Local or cached remote notes + if (await this.apDbResolverService.getNoteFromApId(uri)) { + return true; + } + + // Un-cached remote notes + if (fetch && await this.apNoteService.resolveNote(uri)) { + return true; + } + + // Everything else + return false; + } catch { + // Errors, including invalid notes and network errors + return false; + } + } + + // Adapted from ApiCallService + private async hasFetchPermissions(request: FastifyRequest<{ Querystring?: { i?: string | string[] }, Body?: { i?: string | string[] } }>, reply: FastifyReply): Promise { + const body = request.method === 'GET' ? request.query : request.body; + + // https://datatracker.ietf.org/doc/html/rfc6750.html#section-2.1 (case sensitive) + const token = request.headers.authorization?.startsWith('Bearer ') + ? request.headers.authorization.slice(7) + : body?.['i']; + if (token != null && typeof token !== 'string') { + reply.code(400); + return false; + } + + const auth = await this.authenticateService.authenticate(token).catch(async (err) => { + if (err instanceof AuthenticationError) { + return null; + } else { + throw err; + } + }); + + // Authentication + if (!auth) { + reply.code(401).send({ + error: { + message: 'Authentication failed. Please ensure your token is correct.', + code: 'AUTHENTICATION_FAILED', + id: 'b0a7f5f8-dc2f-4171-b91f-de88ad238e14', + }, + }); + return false; + } + const [user, app] = auth; + if (user == null) { + reply.code(401).send({ + error: { + message: 'Credential required.', + code: 'CREDENTIAL_REQUIRED', + id: '1384574d-a912-4b81-8601-c7b1c4085df1', + }, + }); + return false; + } + + // Authorization + if (user.isSuspended || user.isDeleted) { + reply.code(403).send({ + error: { + message: 'Your account has been suspended.', + code: 'YOUR_ACCOUNT_SUSPENDED', + kind: 'permission', + id: 'a8c724b3-6e9c-4b46-b1a8-bc3ed6258370', + }, + }); + return false; + } + if (app && !app.permission.includes('read:account')) { + reply.code(403).send({ + error: { + message: 'Your app does not have the necessary permissions to use this endpoint.', + code: 'PERMISSION_DENIED', + kind: 'permission', + id: '1370e5b7-d4eb-4566-bb1d-7748ee6a1838', + }, + }); + return false; + } + + return true; + } } diff --git a/packages/frontend/src/components/MkUrlPreview.vue b/packages/frontend/src/components/MkUrlPreview.vue index 21de04b844..5a5099d8e0 100644 --- a/packages/frontend/src/components/MkUrlPreview.vue +++ b/packages/frontend/src/components/MkUrlPreview.vue @@ -71,8 +71,8 @@ SPDX-License-Identifier: AGPL-3.0-only {{ i18n.ts.expandTweet }} -
- +
+ {{ i18n.ts.fetchLinkedNote }}
@@ -93,6 +93,7 @@ import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue'; import { url as local } from '@@/js/config.js'; import { versatileLang } from '@@/js/intl-const.js'; import * as Misskey from 'misskey-js'; +import { maybeMakeRelative } from '@@/js/url.js'; import type { summaly } from '@misskey-dev/summaly'; import { i18n } from '@/i18n.js'; import * as os from '@/os.js'; @@ -104,7 +105,7 @@ import { prefer } from '@/preferences.js'; import { misskeyApi } from '@/utility/misskey-api.js'; import { warningExternalWebsite } from '@/utility/warning-external-website.js'; import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue'; -import { maybeMakeRelative } from '@@/js/url.js'; +import { $i } from '@/i'; type SummalyResult = Awaited>; @@ -131,7 +132,7 @@ const maybeRelativeUrl = maybeMakeRelative(props.url, local); const self = maybeRelativeUrl !== props.url; const attr = self ? 'to' : 'href'; const target = self ? null : '_blank'; -const fetching = ref(true); +const fetching = ref | null>(null); const title = ref(null); const description = ref(null); const thumbnail = ref(null); @@ -139,11 +140,12 @@ const icon = ref(null); const sitename = ref(null); const sensitive = ref(false); const activityPub = ref(null); -const player = ref({ +const player = ref({ url: null, width: null, height: null, -} as SummalyResult['player']); + allow: [], +}); const playerEnabled = ref(false); const tweetId = ref(null); const tweetExpanded = ref(props.detail); @@ -173,14 +175,14 @@ async function fetchNote() { return; } theNote.value = response['object']; - fetchingTheNote.value = false; } catch (err) { if (_DEV_) { console.error(`failed to extract note for preview of ${activityPub.value}`, err); } activityPub.value = null; - fetchingTheNote.value = false; theNote.value = null; + } finally { + fetchingTheNote.value = false; } } @@ -198,39 +200,52 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/ requestUrl.hash = ''; -window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`) - .then(res => { - if (!res.ok) { - if (_DEV_) { - console.warn(`[HTTP${res.status}] Failed to fetch url preview`); - } - return null; - } - - return res.json(); - }) - .then((info: SummalyResult & { haveNoteLocally?: boolean } | null) => { - if (!info || info.url == null) { - fetching.value = false; - unknownUrl.value = true; - return; - } - - fetching.value = false; - unknownUrl.value = false; - - title.value = info.title; - description.value = info.description; - thumbnail.value = info.thumbnail; - icon.value = info.icon; - sitename.value = info.sitename; - player.value = info.player; - sensitive.value = info.sensitive ?? false; - activityPub.value = info.activityPub; - if (info.haveNoteLocally) { - fetchNote(); - } +function refresh(withFetch = false) { + const params = new URLSearchParams({ + url: requestUrl.href, + lang: versatileLang, }); + if (withFetch) { + params.set('fetch', 'true'); + } + + const headers = $i ? { Authorization: `Bearer ${$i.token}` } : undefined; + return fetching.value ??= window.fetch(`/url?${params.toString()}`, { headers }) + .then(res => { + if (!res.ok) { + if (_DEV_) { + console.warn(`[HTTP${res.status}] Failed to fetch url preview`); + } + return null; + } + + return res.json(); + }) + .then(async (info: SummalyResult & { haveNoteLocally?: boolean } | null) => { + unknownUrl.value = info != null; + title.value = info?.title ?? null; + description.value = info?.description ?? null; + thumbnail.value = info?.thumbnail ?? null; + icon.value = info?.icon ?? null; + sitename.value = info?.sitename ?? null; + player.value = info?.player ?? { + url: null, + width: null, + height: null, + allow: [], + }; + sensitive.value = info?.sensitive ?? false; + activityPub.value = info?.activityPub ?? null; + + theNote.value = null; + if (info?.haveNoteLocally) { + await fetchNote(); + } + }) + .finally(() => { + fetching.value = null; + }); +} function adjustTweetHeight(message: MessageEvent) { if (message.origin !== 'https://platform.twitter.com') return; @@ -256,6 +271,9 @@ window.addEventListener('message', adjustTweetHeight); onUnmounted(() => { window.removeEventListener('message', adjustTweetHeight); }); + +// Load initial data +refresh();