mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-30 00:29:46 +08:00
feat: improve mobile native impl (#14481)
fix #13529 #### PR Dependency Tree * **PR #14481** 👈 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** * Mobile blob caching with file-backed storage for faster loads and reduced network usage * Blob decoding with lazy refresh on token-read failures for improved reliability * Full-text search/indexing exposed to mobile apps * Document sync APIs and peer clock management for robust cross-device sync * **Tests** * Added unit tests covering payload decoding, cache safety, and concurrency * **Dependencies** * Added an LRU cache dependency and a new mobile-shared package for shared mobile logic <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -0,0 +1 @@
|
||||
export * from './nbstore/payload';
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockBase64ToUint8Array, mockConvertFileSrc } = vi.hoisted(() => ({
|
||||
mockBase64ToUint8Array: vi.fn((data: string) =>
|
||||
Uint8Array.from(data.split('').map(char => char.charCodeAt(0)))
|
||||
),
|
||||
mockConvertFileSrc: vi.fn((path: string) => `capacitor://localhost${path}`),
|
||||
}));
|
||||
|
||||
vi.mock('@affine/core/modules/workspace-engine', () => ({
|
||||
base64ToUint8Array: mockBase64ToUint8Array,
|
||||
}));
|
||||
|
||||
vi.mock('@capacitor/core', () => ({
|
||||
Capacitor: {
|
||||
convertFileSrc: mockConvertFileSrc,
|
||||
},
|
||||
}));
|
||||
|
||||
import { decodePayload, MOBILE_BLOB_FILE_PREFIX } from './payload';
|
||||
|
||||
describe('decodePayload', () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
mockBase64ToUint8Array.mockClear();
|
||||
mockConvertFileSrc.mockClear();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('decodes inline base64 payloads without file IO', async () => {
|
||||
const decoded = await decodePayload('ZGF0YQ==', MOBILE_BLOB_FILE_PREFIX);
|
||||
expect(decoded).toEqual(Uint8Array.from([90, 71, 70, 48, 89, 81, 61, 61]));
|
||||
expect(mockBase64ToUint8Array).toHaveBeenCalledWith('ZGF0YQ==');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reads valid cache file tokens', async () => {
|
||||
const expected = Uint8Array.from([1, 2, 3, 4]);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => expected.buffer,
|
||||
} as Response);
|
||||
|
||||
const path =
|
||||
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
||||
const decoded = await decodePayload(
|
||||
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
||||
MOBILE_BLOB_FILE_PREFIX
|
||||
);
|
||||
|
||||
expect(decoded).toEqual(expected);
|
||||
expect(mockConvertFileSrc).toHaveBeenCalledWith(`file://${path}`);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('reads valid android cache file tokens', async () => {
|
||||
const expected = Uint8Array.from([4, 3, 2, 1]);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => expected.buffer,
|
||||
} as Response);
|
||||
|
||||
const path =
|
||||
'/data/user/0/com.affine.app/cache/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
||||
const decoded = await decodePayload(
|
||||
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
||||
MOBILE_BLOB_FILE_PREFIX
|
||||
);
|
||||
|
||||
expect(decoded).toEqual(expected);
|
||||
expect(mockConvertFileSrc).toHaveBeenCalledWith(`file://${path}`);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rejects suffix-only paths outside expected cache shape', async () => {
|
||||
const path =
|
||||
'/attacker/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
||||
await expect(
|
||||
decodePayload(
|
||||
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
||||
MOBILE_BLOB_FILE_PREFIX
|
||||
)
|
||||
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects random cache roots', async () => {
|
||||
const path =
|
||||
'/random/cache/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
||||
await expect(
|
||||
decodePayload(
|
||||
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
||||
MOBILE_BLOB_FILE_PREFIX
|
||||
)
|
||||
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects encoded traversal segments', async () => {
|
||||
const path =
|
||||
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/%2E%2E/fedcba9876543210.blob';
|
||||
await expect(
|
||||
decodePayload(
|
||||
`${MOBILE_BLOB_FILE_PREFIX}${path}`,
|
||||
MOBILE_BLOB_FILE_PREFIX
|
||||
)
|
||||
).rejects.toThrow('Refusing to read mobile payload outside cache dir');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retries once with refreshed payload when token read fails', async () => {
|
||||
const path =
|
||||
'/var/mobile/Containers/Data/Application/abc/Library/Caches/nbstore-blob-cache/0123456789abcdef/fedcba9876543210.blob';
|
||||
const payload = `${MOBILE_BLOB_FILE_PREFIX}${path}`;
|
||||
const expected = Uint8Array.from([9, 8, 7]);
|
||||
|
||||
fetchMock
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
arrayBuffer: async () => expected.buffer,
|
||||
} as Response);
|
||||
|
||||
const reloadedPayload = vi.fn(async () => payload);
|
||||
const decoded = await decodePayload(payload, MOBILE_BLOB_FILE_PREFIX, {
|
||||
onTokenReadFailure: reloadedPayload,
|
||||
});
|
||||
|
||||
expect(decoded).toEqual(expected);
|
||||
expect(reloadedPayload).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
import { base64ToUint8Array } from '@affine/core/modules/workspace-engine';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
|
||||
export const MOBILE_BLOB_FILE_PREFIX = '__AFFINE_BLOB_FILE__:';
|
||||
export const MOBILE_PAYLOAD_INLINE_THRESHOLD_BYTES = 1024 * 1024;
|
||||
const MOBILE_PAYLOAD_CACHE_DIR = 'nbstore-blob-cache';
|
||||
const MOBILE_PAYLOAD_BUCKET_PATTERN = /^[0-9a-f]{16}$/;
|
||||
const MOBILE_PAYLOAD_FILE_PATTERN = /^[0-9a-f]{16}\.blob$/;
|
||||
const MOBILE_PAYLOAD_PARENT_DIRS = new Set(['cache', 'Caches', 'T', 'tmp']);
|
||||
const MOBILE_ANDROID_PACKAGE_PATTERN =
|
||||
/^[A-Za-z][A-Za-z0-9_]*(\.[A-Za-z][A-Za-z0-9_]*)+$/;
|
||||
|
||||
function normalizeTokenFilePath(rawPath: string): string {
|
||||
const trimmedPath = rawPath.trim();
|
||||
if (!trimmedPath) {
|
||||
throw new Error('Invalid mobile payload token: empty file path');
|
||||
}
|
||||
|
||||
return trimmedPath.startsWith('file://')
|
||||
? trimmedPath
|
||||
: `file://${trimmedPath}`;
|
||||
}
|
||||
|
||||
function assertMobileCachePath(fileUrl: string): void {
|
||||
let pathname: string;
|
||||
try {
|
||||
const parsedUrl = new URL(fileUrl);
|
||||
if (parsedUrl.protocol !== 'file:') {
|
||||
throw new Error('unexpected protocol');
|
||||
}
|
||||
pathname = parsedUrl.pathname;
|
||||
} catch {
|
||||
throw new Error('Invalid mobile payload token: malformed file URL');
|
||||
}
|
||||
|
||||
let decodedSegments: string[];
|
||||
try {
|
||||
decodedSegments = pathname
|
||||
.split('/')
|
||||
.filter(Boolean)
|
||||
.map(segment => {
|
||||
const decoded = decodeURIComponent(segment);
|
||||
if (
|
||||
!decoded ||
|
||||
decoded === '.' ||
|
||||
decoded === '..' ||
|
||||
decoded.includes('/') ||
|
||||
decoded.includes('\\')
|
||||
) {
|
||||
throw new Error('path traversal');
|
||||
}
|
||||
return decoded;
|
||||
});
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Refusing to read mobile payload outside cache dir: ${fileUrl}`
|
||||
);
|
||||
}
|
||||
|
||||
const fileName = decodedSegments.at(-1);
|
||||
const bucket = decodedSegments.at(-2);
|
||||
const cacheDir = decodedSegments.at(-3);
|
||||
const parentDir = decodedSegments.at(-4);
|
||||
const cacheParent = decodedSegments.at(-5);
|
||||
|
||||
if (
|
||||
!fileName ||
|
||||
!bucket ||
|
||||
!cacheDir ||
|
||||
!parentDir ||
|
||||
cacheDir !== MOBILE_PAYLOAD_CACHE_DIR ||
|
||||
!MOBILE_PAYLOAD_BUCKET_PATTERN.test(bucket) ||
|
||||
!MOBILE_PAYLOAD_FILE_PATTERN.test(fileName) ||
|
||||
!MOBILE_PAYLOAD_PARENT_DIRS.has(parentDir) ||
|
||||
!isAllowedCacheParent(decodedSegments, parentDir, cacheParent)
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to read mobile payload outside cache dir: ${fileUrl}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isAllowedCacheParent(
|
||||
parts: string[],
|
||||
parentDir: string,
|
||||
cacheParent: string | undefined
|
||||
): boolean {
|
||||
if (parentDir === 'Caches') {
|
||||
return cacheParent === 'Library' && ['var', 'private'].includes(parts[0]);
|
||||
}
|
||||
|
||||
if (parentDir === 'cache') {
|
||||
if (parts[0] !== 'data' || !cacheParent) {
|
||||
return false;
|
||||
}
|
||||
if (!MOBILE_ANDROID_PACKAGE_PATTERN.test(cacheParent)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parts[1] === 'data') {
|
||||
return true;
|
||||
}
|
||||
if (parts[1] === 'user') {
|
||||
return !!parts[2] && /^[0-9]+$/.test(parts[2]);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (parentDir === 'tmp') {
|
||||
return (
|
||||
parts[0] === 'tmp' ||
|
||||
(parts[0] === 'private' && parts[1] === 'var' && parts[2] === 'tmp')
|
||||
);
|
||||
}
|
||||
|
||||
if (parentDir === 'T') {
|
||||
return parts[0] === 'var' && parts[1] === 'folders';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function readTokenPayload(filePath: string): Promise<Uint8Array> {
|
||||
const response = await fetch(Capacitor.convertFileSrc(filePath));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to read mobile payload file: ${filePath} (status ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export interface DecodePayloadOptions {
|
||||
onTokenReadFailure?: (error: Error) => Promise<string | null | undefined>;
|
||||
}
|
||||
|
||||
export async function decodePayload(
|
||||
data: string,
|
||||
prefix: string,
|
||||
options?: DecodePayloadOptions
|
||||
): Promise<Uint8Array> {
|
||||
if (!data.startsWith(prefix)) {
|
||||
return base64ToUint8Array(data);
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeTokenFilePath(data.slice(prefix.length));
|
||||
assertMobileCachePath(normalizedPath);
|
||||
|
||||
try {
|
||||
return await readTokenPayload(normalizedPath);
|
||||
} catch (error) {
|
||||
const reloadPayload = options?.onTokenReadFailure;
|
||||
if (!reloadPayload) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const refreshedPayload = await reloadPayload(
|
||||
error instanceof Error ? error : new Error(String(error))
|
||||
);
|
||||
if (!refreshedPayload) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return decodePayload(refreshedPayload, prefix);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user