mirror of
https://codeberg.org/yeentown/barkey.git
synced 2025-07-07 20:44:34 +00:00
merge: Fix "fetch linked note" button for AP previews (!1037)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1037 Approved-by: dakkar <dakkar@thenautilus.net> Approved-by: Marie <github@yuugi.dev>
This commit is contained in:
commit
6c77be64b6
3 changed files with 301 additions and 83 deletions
|
@ -675,9 +675,11 @@ export class FileServerService {
|
||||||
if (info.blocked) {
|
if (info.blocked) {
|
||||||
reply.code(429);
|
reply.code(429);
|
||||||
reply.send({
|
reply.send({
|
||||||
message: 'Rate limit exceeded. Please try again later.',
|
error: {
|
||||||
code: 'RATE_LIMIT_EXCEEDED',
|
message: 'Rate limit exceeded. Please try again later.',
|
||||||
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
code: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -15,15 +15,21 @@ import type Logger from '@/logger.js';
|
||||||
import { query } from '@/misc/prelude/url.js';
|
import { query } from '@/misc/prelude/url.js';
|
||||||
import { LoggerService } from '@/core/LoggerService.js';
|
import { LoggerService } from '@/core/LoggerService.js';
|
||||||
import { bindThis } from '@/decorators.js';
|
import { bindThis } from '@/decorators.js';
|
||||||
import { ApiError } from '@/server/api/error.js';
|
|
||||||
import { MiMeta } from '@/models/Meta.js';
|
import { MiMeta } from '@/models/Meta.js';
|
||||||
import { RedisKVCache } from '@/misc/cache.js';
|
import { RedisKVCache } from '@/misc/cache.js';
|
||||||
import { UtilityService } from '@/core/UtilityService.js';
|
import { UtilityService } from '@/core/UtilityService.js';
|
||||||
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
import { ApDbResolverService } from '@/core/activitypub/ApDbResolverService.js';
|
||||||
import type { NotesRepository } from '@/models/_.js';
|
import type { MiAccessToken, NotesRepository } from '@/models/_.js';
|
||||||
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
import { ApUtilityService } from '@/core/activitypub/ApUtilityService.js';
|
||||||
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
|
import { ApRequestService } from '@/core/activitypub/ApRequestService.js';
|
||||||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||||
|
import { ApNoteService } from '@/core/activitypub/models/ApNoteService.js';
|
||||||
|
import { AuthenticateService, AuthenticationError } from '@/server/api/AuthenticateService.js';
|
||||||
|
import { SkRateLimiterService } from '@/server/SkRateLimiterService.js';
|
||||||
|
import { BucketRateLimit, Keyed, sendRateLimitHeaders } from '@/misc/rate-limit-utils.js';
|
||||||
|
import type { MiLocalUser } from '@/models/User.js';
|
||||||
|
import { getIpHash } from '@/misc/get-ip-hash.js';
|
||||||
|
import { isRetryableError } from '@/misc/is-retryable-error.js';
|
||||||
import type { FastifyRequest, FastifyReply } from 'fastify';
|
import type { FastifyRequest, FastifyReply } from 'fastify';
|
||||||
|
|
||||||
export type LocalSummalyResult = SummalyResult & {
|
export type LocalSummalyResult = SummalyResult & {
|
||||||
|
@ -31,7 +37,27 @@ export type LocalSummalyResult = SummalyResult & {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Increment this to invalidate cached previews after a major change.
|
// Increment this to invalidate cached previews after a major change.
|
||||||
const cacheFormatVersion = 2;
|
const cacheFormatVersion = 3;
|
||||||
|
|
||||||
|
type PreviewRoute = {
|
||||||
|
Querystring: {
|
||||||
|
url?: string
|
||||||
|
lang?: string,
|
||||||
|
fetch?: string,
|
||||||
|
i?: string,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
type AuthArray = [user: MiLocalUser | null | undefined, app: MiAccessToken | null | undefined, actor: MiLocalUser | string];
|
||||||
|
|
||||||
|
// Up to 50 requests, then 10 / second (at 2 / 200ms rate)
|
||||||
|
const previewLimit: Keyed<BucketRateLimit> = {
|
||||||
|
key: '/url',
|
||||||
|
type: 'bucket',
|
||||||
|
size: 50,
|
||||||
|
dripSize: 2,
|
||||||
|
dripRate: 200,
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UrlPreviewService {
|
export class UrlPreviewService {
|
||||||
|
@ -58,6 +84,9 @@ export class UrlPreviewService {
|
||||||
private readonly apDbResolverService: ApDbResolverService,
|
private readonly apDbResolverService: ApDbResolverService,
|
||||||
private readonly apRequestService: ApRequestService,
|
private readonly apRequestService: ApRequestService,
|
||||||
private readonly systemAccountService: SystemAccountService,
|
private readonly systemAccountService: SystemAccountService,
|
||||||
|
private readonly apNoteService: ApNoteService,
|
||||||
|
private readonly authenticateService: AuthenticateService,
|
||||||
|
private readonly rateLimiterService: SkRateLimiterService,
|
||||||
) {
|
) {
|
||||||
this.logger = this.loggerService.getLogger('url-preview');
|
this.logger = this.loggerService.getLogger('url-preview');
|
||||||
this.previewCache = new RedisKVCache<LocalSummalyResult>(this.redisClient, 'summaly', {
|
this.previewCache = new RedisKVCache<LocalSummalyResult>(this.redisClient, 'summaly', {
|
||||||
|
@ -85,9 +114,9 @@ export class UrlPreviewService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public async handle(
|
public async handle(
|
||||||
request: FastifyRequest<{ Querystring: { url?: string; lang?: string; } }>,
|
request: FastifyRequest<PreviewRoute>,
|
||||||
reply: FastifyReply,
|
reply: FastifyReply,
|
||||||
): Promise<object | undefined> {
|
): Promise<void> {
|
||||||
const url = request.query.url;
|
const url = request.query.url;
|
||||||
if (typeof url !== 'string' || !URL.canParse(url)) {
|
if (typeof url !== 'string' || !URL.canParse(url)) {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
|
@ -101,38 +130,39 @@ export class UrlPreviewService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.meta.urlPreviewEnabled) {
|
if (!this.meta.urlPreviewEnabled) {
|
||||||
reply.code(403);
|
return reply.code(403).send({
|
||||||
return {
|
error: {
|
||||||
error: new ApiError({
|
|
||||||
message: 'URL preview is disabled',
|
message: 'URL preview is disabled',
|
||||||
code: 'URL_PREVIEW_DISABLED',
|
code: 'URL_PREVIEW_DISABLED',
|
||||||
id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
|
id: '58b36e13-d2f5-0323-b0c6-76aa9dabefb8',
|
||||||
}),
|
},
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check rate limit
|
||||||
|
const auth = await this.authenticate(request);
|
||||||
|
if (!await this.checkRateLimit(auth, reply)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) {
|
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(url).host)) {
|
||||||
reply.code(403);
|
return reply.code(403).send({
|
||||||
return {
|
error: {
|
||||||
error: new ApiError({
|
|
||||||
message: 'URL is blocked',
|
message: 'URL is blocked',
|
||||||
code: 'URL_PREVIEW_BLOCKED',
|
code: 'URL_PREVIEW_BLOCKED',
|
||||||
id: '50294652-857b-4b13-9700-8e5c7a8deae8',
|
id: '50294652-857b-4b13-9700-8e5c7a8deae8',
|
||||||
}),
|
},
|
||||||
};
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetch = !!request.query.fetch;
|
||||||
|
if (fetch && !await this.checkFetchPermissions(auth, reply)) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cacheKey = `${url}@${lang}@${cacheFormatVersion}`;
|
const cacheKey = `${url}@${lang}@${cacheFormatVersion}`;
|
||||||
const cached = await this.previewCache.get(cacheKey);
|
if (await this.sendCachedPreview(cacheKey, reply, fetch)) {
|
||||||
if (cached !== undefined) {
|
return;
|
||||||
// Cache 1 day (matching redis)
|
|
||||||
reply.header('Cache-Control', 'public, max-age=86400');
|
|
||||||
|
|
||||||
if (cached.activityPub) {
|
|
||||||
cached.haveNoteLocally = !! await this.apDbResolverService.getNoteFromApId(cached.activityPub);
|
|
||||||
}
|
|
||||||
|
|
||||||
return cached;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -144,14 +174,13 @@ export class UrlPreviewService {
|
||||||
|
|
||||||
// Repeat check, since redirects are allowed.
|
// Repeat check, since redirects are allowed.
|
||||||
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) {
|
if (this.utilityService.isBlockedHost(this.meta.blockedHosts, new URL(summary.url).host)) {
|
||||||
reply.code(403);
|
return reply.code(403).send({
|
||||||
return {
|
error: {
|
||||||
error: new ApiError({
|
|
||||||
message: 'URL is blocked',
|
message: 'URL is blocked',
|
||||||
code: 'URL_PREVIEW_BLOCKED',
|
code: 'URL_PREVIEW_BLOCKED',
|
||||||
id: '50294652-857b-4b13-9700-8e5c7a8deae8',
|
id: '50294652-857b-4b13-9700-8e5c7a8deae8',
|
||||||
}),
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`);
|
this.logger.info(`Got preview of ${url} in ${lang}: ${summary.title}`);
|
||||||
|
@ -164,33 +193,76 @@ export class UrlPreviewService {
|
||||||
await this.inferActivityPubLink(summary);
|
await this.inferActivityPubLink(summary);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (summary.activityPub) {
|
if (summary.activityPub && !summary.haveNoteLocally) {
|
||||||
// Avoid duplicate checks in case inferActivityPubLink already set this.
|
// Avoid duplicate checks in case inferActivityPubLink already set this.
|
||||||
summary.haveNoteLocally ||= !!await this.apDbResolverService.getNoteFromApId(summary.activityPub);
|
const exists = await this.noteExists(summary.activityPub, fetch);
|
||||||
|
|
||||||
|
// Remove the AP flag if we encounter a permanent error fetching the note.
|
||||||
|
if (exists === false) {
|
||||||
|
summary.activityPub = null;
|
||||||
|
summary.haveNoteLocally = undefined;
|
||||||
|
} else {
|
||||||
|
summary.haveNoteLocally = exists ?? false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Await this to avoid hammering redis when a bunch of URLs are fetched at once
|
// Await this to avoid hammering redis when a bunch of URLs are fetched at once
|
||||||
await this.previewCache.set(cacheKey, summary);
|
await this.previewCache.set(cacheKey, summary);
|
||||||
|
|
||||||
// Cache 1 day (matching redis)
|
// Cache 1 day (matching redis), but only once we finalize the result
|
||||||
reply.header('Cache-Control', 'public, max-age=86400');
|
if (!summary.activityPub || summary.haveNoteLocally) {
|
||||||
|
reply.header('Cache-Control', 'public, max-age=86400');
|
||||||
|
}
|
||||||
|
|
||||||
return summary;
|
return reply.code(200).send(summary);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`);
|
this.logger.warn(`Failed to get preview of ${url} for ${lang}: ${err}`);
|
||||||
|
|
||||||
reply.code(422);
|
|
||||||
reply.header('Cache-Control', 'max-age=3600');
|
reply.header('Cache-Control', 'max-age=3600');
|
||||||
return {
|
return reply.code(422).send({
|
||||||
error: new ApiError({
|
error: {
|
||||||
message: 'Failed to get preview',
|
message: 'Failed to get preview',
|
||||||
code: 'URL_PREVIEW_FAILED',
|
code: 'URL_PREVIEW_FAILED',
|
||||||
id: '09d01cb5-53b9-4856-82e5-38a50c290a3b',
|
id: '09d01cb5-53b9-4856-82e5-38a50c290a3b',
|
||||||
}),
|
},
|
||||||
};
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async sendCachedPreview(cacheKey: string, reply: FastifyReply, fetch: boolean): Promise<boolean> {
|
||||||
|
const summary = await this.previewCache.get(cacheKey);
|
||||||
|
if (summary === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if note has loaded since we last cached the preview
|
||||||
|
if (summary.activityPub && !summary.haveNoteLocally) {
|
||||||
|
// Avoid duplicate checks in case inferActivityPubLink already set this.
|
||||||
|
const exists = await this.noteExists(summary.activityPub, fetch);
|
||||||
|
|
||||||
|
// Remove the AP flag if we encounter a permanent error fetching the note.
|
||||||
|
if (exists === false) {
|
||||||
|
summary.activityPub = null;
|
||||||
|
summary.haveNoteLocally = undefined;
|
||||||
|
} else {
|
||||||
|
summary.haveNoteLocally = exists ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist the result once we finalize the result
|
||||||
|
if (!summary.activityPub || summary.haveNoteLocally) {
|
||||||
|
await this.previewCache.set(cacheKey, summary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache 1 day (matching redis), but only once we finalize the result
|
||||||
|
if (!summary.activityPub || summary.haveNoteLocally) {
|
||||||
|
reply.header('Cache-Control', 'public, max-age=86400');
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(200).send(summary);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
|
private fetchSummary(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
|
||||||
const agent = this.config.proxy
|
const agent = this.config.proxy
|
||||||
? {
|
? {
|
||||||
|
@ -211,6 +283,7 @@ export class UrlPreviewService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
|
private fetchSummaryFromProxy(url: string, meta: MiMeta, lang?: string): Promise<SummalyResult> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
const proxy = meta.urlPreviewSummaryProxyUrl!;
|
const proxy = meta.urlPreviewSummaryProxyUrl!;
|
||||||
const queryStr = query({
|
const queryStr = query({
|
||||||
followRedirects: true,
|
followRedirects: true,
|
||||||
|
@ -302,4 +375,129 @@ export class UrlPreviewService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// true = exists, false = does not exist (permanently), null = does not exist (temporarily)
|
||||||
|
private async noteExists(uri: string, fetch = false): Promise<boolean | null> {
|
||||||
|
try {
|
||||||
|
// Local note or cached remote note
|
||||||
|
if (await this.apDbResolverService.getNoteFromApId(uri)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Un-cached remote note
|
||||||
|
if (!fetch) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Newly cached remote note
|
||||||
|
if (await this.apNoteService.resolveNote(uri)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-existent or deleted note
|
||||||
|
return false;
|
||||||
|
} catch (err) {
|
||||||
|
// Errors, including invalid notes and network errors
|
||||||
|
return isRetryableError(err) ? null : false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapted from ApiCallService
|
||||||
|
private async authenticate(request: FastifyRequest<{ Querystring?: { i?: string | string[] }, Body?: { i?: string | string[] } }>): Promise<AuthArray> {
|
||||||
|
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') {
|
||||||
|
return [undefined, undefined, getIpHash(request.ip)];
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const auth = await this.authenticateService.authenticate(token);
|
||||||
|
return [auth[0], auth[1], auth[0] ?? getIpHash(request.ip)];
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof AuthenticationError) {
|
||||||
|
return [undefined, undefined, getIpHash(request.ip)];
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adapted from ApiCallService
|
||||||
|
private async checkFetchPermissions(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
|
||||||
|
const [user, app] = auth;
|
||||||
|
|
||||||
|
// Authentication
|
||||||
|
if (user === undefined) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async checkRateLimit(auth: AuthArray, reply: FastifyReply): Promise<boolean> {
|
||||||
|
const info = await this.rateLimiterService.limit(previewLimit, auth[2]);
|
||||||
|
|
||||||
|
// Always send headers, even if not blocked
|
||||||
|
sendRateLimitHeaders(reply, info);
|
||||||
|
|
||||||
|
if (info.blocked) {
|
||||||
|
reply.code(429).send({
|
||||||
|
error: {
|
||||||
|
message: 'Rate limit exceeded. Please try again later.',
|
||||||
|
code: 'RATE_LIMIT_EXCEEDED',
|
||||||
|
id: 'd5826d14-3982-4d2e-8011-b9e9f02499ef',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -71,8 +71,8 @@ SPDX-License-Identifier: AGPL-3.0-only
|
||||||
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
|
<i class="ti ti-brand-x"></i> {{ i18n.ts.expandTweet }}
|
||||||
</MkButton>
|
</MkButton>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="showAsQuote && activityPub && !theNote && !fetchingTheNote" :class="$style.action">
|
<div v-if="showAsQuote && activityPub && !theNote && $i" :class="$style.action">
|
||||||
<MkButton :small="true" inline @click="fetchNote()">
|
<MkButton :small="true" :disabled="!!fetching || fetchingTheNote" inline @click="() => refresh(true)">
|
||||||
<i class="ti ti-note"></i> {{ i18n.ts.fetchLinkedNote }}
|
<i class="ti ti-note"></i> {{ i18n.ts.fetchLinkedNote }}
|
||||||
</MkButton>
|
</MkButton>
|
||||||
</div>
|
</div>
|
||||||
|
@ -93,6 +93,7 @@ import { defineAsyncComponent, onDeactivated, onUnmounted, ref } from 'vue';
|
||||||
import { url as local } from '@@/js/config.js';
|
import { url as local } from '@@/js/config.js';
|
||||||
import { versatileLang } from '@@/js/intl-const.js';
|
import { versatileLang } from '@@/js/intl-const.js';
|
||||||
import * as Misskey from 'misskey-js';
|
import * as Misskey from 'misskey-js';
|
||||||
|
import { maybeMakeRelative } from '@@/js/url.js';
|
||||||
import type { summaly } from '@misskey-dev/summaly';
|
import type { summaly } from '@misskey-dev/summaly';
|
||||||
import { i18n } from '@/i18n.js';
|
import { i18n } from '@/i18n.js';
|
||||||
import * as os from '@/os.js';
|
import * as os from '@/os.js';
|
||||||
|
@ -104,7 +105,7 @@ import { prefer } from '@/preferences.js';
|
||||||
import { misskeyApi } from '@/utility/misskey-api.js';
|
import { misskeyApi } from '@/utility/misskey-api.js';
|
||||||
import { warningExternalWebsite } from '@/utility/warning-external-website.js';
|
import { warningExternalWebsite } from '@/utility/warning-external-website.js';
|
||||||
import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
|
import DynamicNoteSimple from '@/components/DynamicNoteSimple.vue';
|
||||||
import { maybeMakeRelative } from '@@/js/url.js';
|
import { $i } from '@/i';
|
||||||
|
|
||||||
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
type SummalyResult = Awaited<ReturnType<typeof summaly>>;
|
||||||
|
|
||||||
|
@ -131,7 +132,7 @@ const maybeRelativeUrl = maybeMakeRelative(props.url, local);
|
||||||
const self = maybeRelativeUrl !== props.url;
|
const self = maybeRelativeUrl !== props.url;
|
||||||
const attr = self ? 'to' : 'href';
|
const attr = self ? 'to' : 'href';
|
||||||
const target = self ? null : '_blank';
|
const target = self ? null : '_blank';
|
||||||
const fetching = ref(true);
|
const fetching = ref<Promise<void> | null>(null);
|
||||||
const title = ref<string | null>(null);
|
const title = ref<string | null>(null);
|
||||||
const description = ref<string | null>(null);
|
const description = ref<string | null>(null);
|
||||||
const thumbnail = ref<string | null>(null);
|
const thumbnail = ref<string | null>(null);
|
||||||
|
@ -139,11 +140,12 @@ const icon = ref<string | null>(null);
|
||||||
const sitename = ref<string | null>(null);
|
const sitename = ref<string | null>(null);
|
||||||
const sensitive = ref<boolean>(false);
|
const sensitive = ref<boolean>(false);
|
||||||
const activityPub = ref<string | null>(null);
|
const activityPub = ref<string | null>(null);
|
||||||
const player = ref({
|
const player = ref<SummalyResult['player']>({
|
||||||
url: null,
|
url: null,
|
||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
} as SummalyResult['player']);
|
allow: [],
|
||||||
|
});
|
||||||
const playerEnabled = ref(false);
|
const playerEnabled = ref(false);
|
||||||
const tweetId = ref<string | null>(null);
|
const tweetId = ref<string | null>(null);
|
||||||
const tweetExpanded = ref(props.detail);
|
const tweetExpanded = ref(props.detail);
|
||||||
|
@ -173,14 +175,14 @@ async function fetchNote() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
theNote.value = response['object'];
|
theNote.value = response['object'];
|
||||||
fetchingTheNote.value = false;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (_DEV_) {
|
if (_DEV_) {
|
||||||
console.error(`failed to extract note for preview of ${activityPub.value}`, err);
|
console.error(`failed to extract note for preview of ${activityPub.value}`, err);
|
||||||
}
|
}
|
||||||
activityPub.value = null;
|
activityPub.value = null;
|
||||||
fetchingTheNote.value = false;
|
|
||||||
theNote.value = null;
|
theNote.value = null;
|
||||||
|
} finally {
|
||||||
|
fetchingTheNote.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -198,39 +200,52 @@ if (requestUrl.hostname === 'music.youtube.com' && requestUrl.pathname.match('^/
|
||||||
|
|
||||||
requestUrl.hash = '';
|
requestUrl.hash = '';
|
||||||
|
|
||||||
window.fetch(`/url?url=${encodeURIComponent(requestUrl.href)}&lang=${versatileLang}`)
|
function refresh(withFetch = false) {
|
||||||
.then(res => {
|
const params = new URLSearchParams({
|
||||||
if (!res.ok) {
|
url: requestUrl.href,
|
||||||
if (_DEV_) {
|
lang: versatileLang,
|
||||||
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();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
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) {
|
function adjustTweetHeight(message: MessageEvent) {
|
||||||
if (message.origin !== 'https://platform.twitter.com') return;
|
if (message.origin !== 'https://platform.twitter.com') return;
|
||||||
|
@ -256,6 +271,9 @@ window.addEventListener('message', adjustTweetHeight);
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('message', adjustTweetHeight);
|
window.removeEventListener('message', adjustTweetHeight);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Load initial data
|
||||||
|
refresh();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" module>
|
<style lang="scss" module>
|
||||||
|
|
Loading…
Add table
Reference in a new issue