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:
DarkSky
2026-08-28 03:32:26 +08:00
committed by GitHub
parent 329839e467
commit b6de0ad51b
36 changed files with 3152 additions and 372 deletions
@@ -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 = {