mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-05 00:16:47 +08:00
feat(ios): improve share preview (#15538)
#### PR Dependency Tree * **PR #15538** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added rich link previews to mobile and iOS sharing, including images, metadata, transcripts, and selected text. * Share imports can now create structured content blocks, embeds, bookmarks, and transcript callouts. * Added workspace-aware preview handling for cloud, self-hosted, and signed-out modes. * **Accessibility** * Improved collapse/expand controls with semantic buttons and ARIA relationships. * **Bug Fixes** * Enhanced URL and error sanitization in server logs. * Improved link-preview CORS support, validation, and request handling. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import serverNativeModule from '@affine/server-native';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import type { ExecutionContext, TestFn } from 'ava';
|
||||
import ava from 'ava';
|
||||
import Sinon from 'sinon';
|
||||
@@ -91,6 +92,7 @@ const assertAndSnapshotRaw = async (
|
||||
referer?: string | null;
|
||||
method?: 'GET' | 'OPTIONS' | 'POST';
|
||||
body?: any;
|
||||
headers?: Record<string, string>;
|
||||
checker?: (res: Response) => any;
|
||||
}
|
||||
) => {
|
||||
@@ -109,6 +111,9 @@ const assertAndSnapshotRaw = async (
|
||||
if (referer) {
|
||||
req.set('Referer', referer);
|
||||
}
|
||||
if (options?.headers) {
|
||||
req.set(options.headers);
|
||||
}
|
||||
|
||||
const res = req.send(options?.body).expect(status).expect(checker);
|
||||
await t.notThrowsAsync(res, message);
|
||||
@@ -225,9 +230,19 @@ test('should preview link', async t => {
|
||||
{
|
||||
status: 204,
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
'Access-Control-Request-Headers': 'content-type, x-affine-version',
|
||||
},
|
||||
checker: (res: Response) => {
|
||||
if (!res.headers['access-control-allow-methods']) {
|
||||
throw new Error('Missing CORS headers');
|
||||
if (
|
||||
!res.headers['access-control-allow-methods'] ||
|
||||
!res.headers['access-control-allow-headers']
|
||||
?.toLowerCase()
|
||||
.includes('x-affine-version')
|
||||
) {
|
||||
throw new Error(
|
||||
`Missing CORS headers: ${JSON.stringify(res.headers)}`
|
||||
);
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -289,7 +304,7 @@ test('should preview link', async t => {
|
||||
{
|
||||
status: 200,
|
||||
method: 'POST',
|
||||
body: { url: pageUrl },
|
||||
body: { url: pageUrl, include: ['transcript'] },
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
@@ -297,6 +312,38 @@ test('should preview link', async t => {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const secret = `secret-${Date.now()}`;
|
||||
const pageUrl = `http://external.com/private/page?token=${secret}&user=name`;
|
||||
const logSpies = [
|
||||
Sinon.spy(Logger.prototype, 'debug'),
|
||||
Sinon.spy(Logger.prototype, 'warn'),
|
||||
Sinon.spy(Logger.prototype, 'error'),
|
||||
];
|
||||
const fetchSpy = stubSafeFetch(request => ({
|
||||
body: '<title>Safe log test</title>',
|
||||
finalUrl: request.url,
|
||||
headers: { 'content-type': 'text/html;charset=UTF-8' },
|
||||
}));
|
||||
try {
|
||||
await t.context.app
|
||||
.POST('/api/worker/link-preview')
|
||||
.set('Origin', 'http://localhost:3010')
|
||||
.send({ url: pageUrl })
|
||||
.expect(200);
|
||||
const logged = logSpies
|
||||
.flatMap(spy => spy.getCalls())
|
||||
.map(call => JSON.stringify(call.args))
|
||||
.join('\n');
|
||||
t.true(logged.includes('http://external.com/private/page'));
|
||||
t.false(logged.includes(secret));
|
||||
t.false(logged.includes('?token='));
|
||||
} finally {
|
||||
fetchSpy.restore();
|
||||
logSpies.forEach(spy => spy.restore());
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const encoded = [
|
||||
{
|
||||
|
||||
@@ -48,6 +48,20 @@ const FETCH_TIMEOUT_MS = 10_000;
|
||||
const IMAGE_PROXY_MAX_BYTES = 10 * 1024 * 1024;
|
||||
const LINK_PREVIEW_MAX_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
function safeLogUrl(value: string | URL | undefined) {
|
||||
if (!value) return undefined;
|
||||
try {
|
||||
const url = value instanceof URL ? value : new URL(value);
|
||||
return `${url.origin}${url.pathname}`;
|
||||
} catch {
|
||||
return 'invalid-url';
|
||||
}
|
||||
}
|
||||
|
||||
function safeLogError(error: unknown) {
|
||||
return error instanceof Error ? error.name : 'UnknownError';
|
||||
}
|
||||
|
||||
function toBadRequestReason(reason: SSRFBlockReason) {
|
||||
switch (reason) {
|
||||
case 'disallowed_protocol':
|
||||
@@ -109,7 +123,10 @@ export class WorkerController {
|
||||
? isRefererAllowed(referer, this.allowedOrigin)
|
||||
: false;
|
||||
if (!originAllowed && !refererAllowed) {
|
||||
this.logger.error('Invalid Origin', 'ERROR', { origin, referer });
|
||||
this.logger.error('Invalid Origin', {
|
||||
origin: safeLogUrl(origin),
|
||||
referer: safeLogUrl(referer),
|
||||
});
|
||||
throw new BadRequest('Invalid header');
|
||||
}
|
||||
const url = new URL(req.url, this.url.requestBaseUrl);
|
||||
@@ -120,10 +137,12 @@ export class WorkerController {
|
||||
|
||||
const targetURL = fixUrl(imageURL);
|
||||
if (!targetURL) {
|
||||
this.logger.error(`Invalid URL: ${url}`);
|
||||
this.logger.error('Invalid URL', { url: safeLogUrl(imageURL) });
|
||||
throw new BadRequest(`Invalid URL`);
|
||||
}
|
||||
|
||||
const logUrl = safeLogUrl(targetURL);
|
||||
|
||||
const cachedUrl = `image-proxy:${targetURL.toString()}`;
|
||||
const cachedResponse = await this.cache.get<string>(cachedUrl);
|
||||
if (cachedResponse) {
|
||||
@@ -162,23 +181,23 @@ export class WorkerController {
|
||||
if (error instanceof SsrfBlockedError) {
|
||||
const reason = error.data?.reason as SSRFBlockReason | undefined;
|
||||
this.logger.warn('Blocked image proxy target', {
|
||||
url: imageURL,
|
||||
url: logUrl,
|
||||
reason,
|
||||
});
|
||||
throw new BadRequest(toBadRequestReason(reason ?? 'invalid_url'));
|
||||
}
|
||||
if (error instanceof ResponseTooLargeError) {
|
||||
this.logger.warn('Image proxy response too large', {
|
||||
url: imageURL,
|
||||
url: logUrl,
|
||||
limitBytes: error.data?.limitBytes,
|
||||
receivedBytes: error.data?.receivedBytes,
|
||||
});
|
||||
throw new BadRequest('Response too large');
|
||||
}
|
||||
this.logger.error('Failed to fetch image', {
|
||||
origin,
|
||||
url: imageURL,
|
||||
error,
|
||||
origin: safeLogUrl(origin),
|
||||
url: logUrl,
|
||||
error: safeLogError(error),
|
||||
});
|
||||
throw new BadRequest('Failed to fetch image');
|
||||
}
|
||||
@@ -212,8 +231,8 @@ export class WorkerController {
|
||||
});
|
||||
}
|
||||
this.logger.error('Failed to fetch image', {
|
||||
origin,
|
||||
url: imageURL,
|
||||
origin: safeLogUrl(origin),
|
||||
url: logUrl,
|
||||
status: response.status,
|
||||
});
|
||||
throw new BadRequest('Failed to fetch image');
|
||||
@@ -225,8 +244,8 @@ export class WorkerController {
|
||||
return inspectImageForProxy(buffer);
|
||||
} catch (error) {
|
||||
this.logger.warn('Image proxy rejected invalid image', {
|
||||
url,
|
||||
error,
|
||||
url: safeLogUrl(url),
|
||||
error: safeLogError(error),
|
||||
});
|
||||
throw new BadRequest('Invalid image');
|
||||
}
|
||||
@@ -243,7 +262,7 @@ export class WorkerController {
|
||||
.header({
|
||||
...getCorsHeaders(origin),
|
||||
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, x-affine-version',
|
||||
})
|
||||
.send();
|
||||
}
|
||||
@@ -262,21 +281,32 @@ export class WorkerController {
|
||||
? isRefererAllowed(referer, this.allowedOrigin)
|
||||
: false;
|
||||
if (!originAllowed && !refererAllowed) {
|
||||
this.logger.error('Invalid Origin', { origin, referer });
|
||||
this.logger.error('Invalid Origin', {
|
||||
origin: safeLogUrl(origin),
|
||||
referer: safeLogUrl(referer),
|
||||
});
|
||||
throw new BadRequest('Invalid header');
|
||||
}
|
||||
|
||||
this.logger.debug('Received request', { origin, method: request.method });
|
||||
const logOrigin = safeLogUrl(origin);
|
||||
this.logger.debug('Received request', {
|
||||
origin: logOrigin,
|
||||
method: request.method,
|
||||
});
|
||||
|
||||
const requestBody = parseJson<LinkPreviewRequest>(request.body);
|
||||
const targetURL = fixUrl(requestBody?.url);
|
||||
// not allow same site preview
|
||||
if (!targetURL || isOriginAllowed(targetURL.origin, this.allowedOrigin)) {
|
||||
this.logger.error('Invalid URL', { origin, url: requestBody?.url });
|
||||
this.logger.error('Invalid URL', {
|
||||
origin: logOrigin,
|
||||
url: safeLogUrl(requestBody?.url),
|
||||
});
|
||||
throw new BadRequest('Invalid URL');
|
||||
}
|
||||
|
||||
this.logger.debug('Processing request', { origin, url: targetURL });
|
||||
const logUrl = safeLogUrl(targetURL);
|
||||
this.logger.debug('Processing request', { origin: logOrigin, url: logUrl });
|
||||
|
||||
try {
|
||||
const cachedUrl = `link-preview:${targetURL.toString()}`;
|
||||
@@ -303,8 +333,8 @@ export class WorkerController {
|
||||
}
|
||||
);
|
||||
this.logger.debug('Fetched URL', {
|
||||
origin,
|
||||
url: targetURL,
|
||||
origin: logOrigin,
|
||||
url: logUrl,
|
||||
status: response.status,
|
||||
});
|
||||
|
||||
@@ -396,8 +426,8 @@ export class WorkerController {
|
||||
res.images = await reduceUrls(res.images);
|
||||
|
||||
this.logger.debug('Processed response with HTMLRewriter', {
|
||||
origin,
|
||||
url: response.url,
|
||||
origin: logOrigin,
|
||||
url: safeLogUrl(response.url),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -423,8 +453,8 @@ export class WorkerController {
|
||||
|
||||
const json = JSON.stringify(res);
|
||||
this.logger.debug('Sending response', {
|
||||
origin,
|
||||
url: res.url,
|
||||
origin: logOrigin,
|
||||
url: safeLogUrl(res.url),
|
||||
responseSize: json.length,
|
||||
});
|
||||
|
||||
@@ -440,25 +470,25 @@ export class WorkerController {
|
||||
if (error instanceof SsrfBlockedError) {
|
||||
const reason = error.data?.reason as SSRFBlockReason | undefined;
|
||||
this.logger.warn('Blocked link preview target', {
|
||||
origin,
|
||||
url: requestBody?.url,
|
||||
origin: logOrigin,
|
||||
url: safeLogUrl(requestBody?.url),
|
||||
reason,
|
||||
});
|
||||
throw new BadRequest(toBadRequestReason(reason ?? 'invalid_url'));
|
||||
}
|
||||
if (error instanceof ResponseTooLargeError) {
|
||||
this.logger.warn('Link preview response too large', {
|
||||
origin,
|
||||
url: requestBody?.url,
|
||||
origin: logOrigin,
|
||||
url: safeLogUrl(requestBody?.url),
|
||||
limitBytes: error.data?.limitBytes,
|
||||
receivedBytes: error.data?.receivedBytes,
|
||||
});
|
||||
throw new BadRequest('Response too large');
|
||||
}
|
||||
this.logger.error('Error fetching URL', {
|
||||
origin,
|
||||
url: targetURL,
|
||||
error,
|
||||
origin: logOrigin,
|
||||
url: logUrl,
|
||||
error: safeLogError(error),
|
||||
});
|
||||
throw new BadRequest('Error fetching URL');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export type LinkPreviewRequest = {
|
||||
url: string;
|
||||
head?: boolean;
|
||||
include?: Array<'transcript'>;
|
||||
};
|
||||
|
||||
export type LinkPreviewResponse = {
|
||||
|
||||
Reference in New Issue
Block a user