mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-24 01:42:55 +08:00
fix: cache path check
This commit is contained in:
@@ -15,6 +15,7 @@
|
||||
"@affine/core": "workspace:*",
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/mobile-shared": "workspace:*",
|
||||
"@affine/nbstore": "workspace:*",
|
||||
"@affine/track": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { uint8ArrayToBase64 } from '@affine/core/modules/workspace-engine';
|
||||
import {
|
||||
base64ToUint8Array,
|
||||
uint8ArrayToBase64,
|
||||
} from '@affine/core/modules/workspace-engine';
|
||||
decodePayload,
|
||||
MOBILE_BLOB_FILE_PREFIX,
|
||||
MOBILE_DOC_FILE_PREFIX,
|
||||
} from '@affine/mobile-shared/nbstore/payload';
|
||||
import {
|
||||
type BlobRecord,
|
||||
type CrawlResult,
|
||||
@@ -12,36 +14,13 @@ import {
|
||||
parseUniversalId,
|
||||
} from '@affine/nbstore';
|
||||
import { type NativeDBApis } from '@affine/nbstore/sqlite';
|
||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { NbStorePlugin } from './definitions';
|
||||
|
||||
export * from './definitions';
|
||||
|
||||
export const NbStore = registerPlugin<NbStorePlugin>('NbStoreDocStorage');
|
||||
const MOBILE_BLOB_FILE_PREFIX = '__AFFINE_BLOB_FILE__:';
|
||||
const MOBILE_DOC_FILE_PREFIX = '__AFFINE_DOC_FILE__:';
|
||||
|
||||
async function decodePayload(
|
||||
data: string,
|
||||
prefix: string
|
||||
): Promise<Uint8Array> {
|
||||
if (!data.startsWith(prefix)) {
|
||||
return base64ToUint8Array(data);
|
||||
}
|
||||
|
||||
const filePath = data.slice(prefix.length);
|
||||
const normalizedPath = filePath.startsWith('file://')
|
||||
? filePath
|
||||
: `file://${filePath}`;
|
||||
const response = await fetch(Capacitor.convertFileSrc(normalizedPath));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to read mobile payload file: ${filePath} (status ${response.status})`
|
||||
);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export const NbStoreNativeDBApis: NativeDBApis = {
|
||||
connect: async function (id: string): Promise<void> {
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "path": "../../core" },
|
||||
{ "path": "../../../common/env" },
|
||||
{ "path": "../../i18n" },
|
||||
{ "path": "../mobile-shared" },
|
||||
{ "path": "../../../common/nbstore" },
|
||||
{ "path": "../../track" },
|
||||
{ "path": "../../../../blocksuite/affine/all" },
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"@affine/env": "workspace:*",
|
||||
"@affine/graphql": "workspace:*",
|
||||
"@affine/i18n": "workspace:*",
|
||||
"@affine/mobile-shared": "workspace:*",
|
||||
"@affine/nbstore": "workspace:*",
|
||||
"@affine/track": "workspace:*",
|
||||
"@blocksuite/affine": "workspace:*",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { uint8ArrayToBase64 } from '@affine/core/modules/workspace-engine';
|
||||
import {
|
||||
base64ToUint8Array,
|
||||
uint8ArrayToBase64,
|
||||
} from '@affine/core/modules/workspace-engine';
|
||||
decodePayload,
|
||||
MOBILE_BLOB_FILE_PREFIX,
|
||||
MOBILE_DOC_FILE_PREFIX,
|
||||
} from '@affine/mobile-shared/nbstore/payload';
|
||||
import {
|
||||
type BlobRecord,
|
||||
type CrawlResult,
|
||||
@@ -12,36 +14,13 @@ import {
|
||||
parseUniversalId,
|
||||
} from '@affine/nbstore';
|
||||
import { type NativeDBApis } from '@affine/nbstore/sqlite';
|
||||
import { Capacitor, registerPlugin } from '@capacitor/core';
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
import type { NbStorePlugin } from './definitions';
|
||||
|
||||
export * from './definitions';
|
||||
|
||||
export const NbStore = registerPlugin<NbStorePlugin>('NbStoreDocStorage');
|
||||
const MOBILE_BLOB_FILE_PREFIX = '__AFFINE_BLOB_FILE__:';
|
||||
const MOBILE_DOC_FILE_PREFIX = '__AFFINE_DOC_FILE__:';
|
||||
|
||||
async function decodePayload(
|
||||
data: string,
|
||||
prefix: string
|
||||
): Promise<Uint8Array> {
|
||||
if (!data.startsWith(prefix)) {
|
||||
return base64ToUint8Array(data);
|
||||
}
|
||||
|
||||
const filePath = data.slice(prefix.length);
|
||||
const normalizedPath = filePath.startsWith('file://')
|
||||
? filePath
|
||||
: `file://${filePath}`;
|
||||
const response = await fetch(Capacitor.convertFileSrc(normalizedPath));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to read mobile payload file: ${filePath} (status ${response.status})`
|
||||
);
|
||||
}
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
export const NbStoreNativeDBApis: NativeDBApis = {
|
||||
connect: async function (id: string): Promise<void> {
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
{ "path": "../../../common/env" },
|
||||
{ "path": "../../../common/graphql" },
|
||||
{ "path": "../../i18n" },
|
||||
{ "path": "../mobile-shared" },
|
||||
{ "path": "../../../common/nbstore" },
|
||||
{ "path": "../../track" },
|
||||
{ "path": "../../../../blocksuite/affine/all" },
|
||||
|
||||
18
packages/frontend/apps/mobile-shared/package.json
Normal file
18
packages/frontend/apps/mobile-shared/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@affine/mobile-shared",
|
||||
"version": "0.26.1",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./nbstore/payload": "./src/nbstore/payload.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@affine/core": "workspace:*",
|
||||
"@capacitor/core": "^7.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
1
packages/frontend/apps/mobile-shared/src/index.ts
Normal file
1
packages/frontend/apps/mobile-shared/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './nbstore/payload';
|
||||
58
packages/frontend/apps/mobile-shared/src/nbstore/payload.ts
Normal file
58
packages/frontend/apps/mobile-shared/src/nbstore/payload.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
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_DOC_FILE_PREFIX = '__AFFINE_DOC_FILE__:';
|
||||
const MOBILE_PAYLOAD_CACHE_PATH_PATTERN =
|
||||
/\/nbstore-blob-cache\/[0-9a-f]{16}\/[0-9a-f]{16}\.(blob|docbin)$/;
|
||||
|
||||
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 {
|
||||
pathname = decodeURIComponent(new URL(fileUrl).pathname);
|
||||
} catch {
|
||||
throw new Error('Invalid mobile payload token: malformed file URL');
|
||||
}
|
||||
|
||||
if (
|
||||
pathname.includes('/../') ||
|
||||
pathname.includes('/./') ||
|
||||
!MOBILE_PAYLOAD_CACHE_PATH_PATTERN.test(pathname)
|
||||
) {
|
||||
throw new Error(
|
||||
`Refusing to read mobile payload outside cache dir: ${fileUrl}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function decodePayload(
|
||||
data: string,
|
||||
prefix: string
|
||||
): Promise<Uint8Array> {
|
||||
if (!data.startsWith(prefix)) {
|
||||
return base64ToUint8Array(data);
|
||||
}
|
||||
|
||||
const normalizedPath = normalizeTokenFilePath(data.slice(prefix.length));
|
||||
assertMobileCachePath(normalizedPath);
|
||||
|
||||
const response = await fetch(Capacitor.convertFileSrc(normalizedPath));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to read mobile payload file: ${normalizedPath} (status ${response.status})`
|
||||
);
|
||||
}
|
||||
|
||||
return new Uint8Array(await response.arrayBuffer());
|
||||
}
|
||||
10
packages/frontend/apps/mobile-shared/tsconfig.json
Normal file
10
packages/frontend/apps/mobile-shared/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.web.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"tsBuildInfoFile": "./dist/tsconfig.tsbuildinfo"
|
||||
},
|
||||
"include": ["./src"],
|
||||
"references": [{ "path": "../../core" }]
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use std::{
|
||||
use lru::LruCache;
|
||||
|
||||
pub(crate) const MOBILE_BLOB_INLINE_THRESHOLD_BYTES: usize = 1024 * 1024;
|
||||
const MOBILE_BLOB_MAX_READ_BYTES: u64 = 64 * 1024 * 1024;
|
||||
const MOBILE_BLOB_CACHE_CAPACITY: usize = 32;
|
||||
const MOBILE_BLOB_CACHE_DIR: &str = "nbstore-blob-cache";
|
||||
pub(crate) const MOBILE_BLOB_FILE_PREFIX: &str = "__AFFINE_BLOB_FILE__:";
|
||||
@@ -86,8 +87,7 @@ impl MobileBlobCache {
|
||||
|
||||
pub(crate) fn cache_blob(&self, universal_id: &str, blob: &affine_nbstore::Blob) -> std::io::Result<crate::Blob> {
|
||||
let cache_key = Self::cache_key(universal_id, &blob.key);
|
||||
let cache_dir = self.resolve_cache_dir(universal_id);
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
let cache_dir = self.ensure_cache_dir(universal_id)?;
|
||||
|
||||
let file_path = Self::blob_file_path(&cache_dir, &cache_key);
|
||||
std::fs::write(&file_path, &blob.data)?;
|
||||
@@ -118,8 +118,7 @@ impl MobileBlobCache {
|
||||
data: &[u8],
|
||||
) -> std::io::Result<String> {
|
||||
let cache_key = Self::cache_key(universal_id, &format!("doc\u{1f}{doc_id}\u{1f}{timestamp}"));
|
||||
let cache_dir = self.resolve_cache_dir(universal_id);
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
let cache_dir = self.ensure_cache_dir(universal_id)?;
|
||||
|
||||
let file_path = Self::doc_file_path(&cache_dir, &cache_key);
|
||||
std::fs::write(&file_path, data)?;
|
||||
@@ -248,6 +247,12 @@ impl MobileBlobCache {
|
||||
.clone()
|
||||
}
|
||||
|
||||
fn ensure_cache_dir(&self, universal_id: &str) -> std::io::Result<PathBuf> {
|
||||
let cache_dir = self.resolve_cache_dir(universal_id);
|
||||
std::fs::create_dir_all(&cache_dir)?;
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
fn blob_file_path(cache_dir: &Path, cache_key: &str) -> PathBuf {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
cache_key.hash(&mut hasher);
|
||||
@@ -284,5 +289,62 @@ pub(crate) fn read_mobile_binary_file(value: &str) -> std::io::Result<Vec<u8>> {
|
||||
.strip_prefix(MOBILE_BLOB_FILE_PREFIX)
|
||||
.or_else(|| value.strip_prefix(MOBILE_DOC_FILE_PREFIX))
|
||||
.ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidInput, "invalid mobile file token"))?;
|
||||
std::fs::read(path)
|
||||
|
||||
let path = path.strip_prefix("file://").unwrap_or(path);
|
||||
let canonical = std::fs::canonicalize(path)?;
|
||||
if !is_valid_mobile_cache_path(&canonical) {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::PermissionDenied,
|
||||
"mobile file token points outside the nbstore cache directory",
|
||||
));
|
||||
}
|
||||
|
||||
let metadata = std::fs::metadata(&canonical)?;
|
||||
if !metadata.is_file() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"mobile file token does not resolve to a file",
|
||||
));
|
||||
}
|
||||
if metadata.len() > MOBILE_BLOB_MAX_READ_BYTES {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
format!(
|
||||
"mobile file token exceeds max size: {} > {}",
|
||||
metadata.len(),
|
||||
MOBILE_BLOB_MAX_READ_BYTES
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
std::fs::read(canonical)
|
||||
}
|
||||
|
||||
fn is_valid_mobile_cache_path(path: &Path) -> bool {
|
||||
let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
let Some((stem, extension)) = file_name.rsplit_once('.') else {
|
||||
return false;
|
||||
};
|
||||
if extension != "blob" && extension != "docbin" {
|
||||
return false;
|
||||
}
|
||||
if stem.len() != 16 || !stem.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(workspace_bucket) = path.parent().and_then(Path::file_name).and_then(|name| name.to_str()) else {
|
||||
return false;
|
||||
};
|
||||
if workspace_bucket.len() != 16 || !workspace_bucket.chars().all(|c| c.is_ascii_hexdigit()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
path
|
||||
.parent()
|
||||
.and_then(Path::parent)
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
== Some(MOBILE_BLOB_CACHE_DIR)
|
||||
}
|
||||
|
||||
@@ -55,16 +55,20 @@ impl SqliteDocStoragePool {
|
||||
pub async fn disconnect(&self, universal_id: String) -> Result<()> {
|
||||
let storage = {
|
||||
let mut lock = self.inner.write().await;
|
||||
if let Entry::Occupied(entry) = lock.entry(universal_id) {
|
||||
Some(entry.remove())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
lock.remove(&universal_id)
|
||||
};
|
||||
if let Some(storage) = storage {
|
||||
storage.close().await;
|
||||
let Some(storage) = storage else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
// Prevent shutting down the shared storage while requests still hold refs.
|
||||
if Arc::strong_count(&storage) > 1 {
|
||||
let mut lock = self.inner.write().await;
|
||||
lock.insert(universal_id, storage);
|
||||
return Err(Error::InvalidOperation);
|
||||
}
|
||||
|
||||
storage.close().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"build": "napi build -p affine_native --platform --release",
|
||||
"build:debug": "napi build -p affine_native --platform",
|
||||
"universal": "napi universal",
|
||||
"test": "ava",
|
||||
"test": "ava --no-worker-threads --concurrency=2",
|
||||
"version": "napi version"
|
||||
},
|
||||
"version": "0.26.1"
|
||||
|
||||
@@ -1248,6 +1248,7 @@ export const PackageList = [
|
||||
'packages/frontend/core',
|
||||
'packages/common/env',
|
||||
'packages/frontend/i18n',
|
||||
'packages/frontend/apps/mobile-shared',
|
||||
'packages/common/nbstore',
|
||||
'packages/frontend/track',
|
||||
'blocksuite/affine/all',
|
||||
@@ -1290,6 +1291,7 @@ export const PackageList = [
|
||||
'packages/common/env',
|
||||
'packages/common/graphql',
|
||||
'packages/frontend/i18n',
|
||||
'packages/frontend/apps/mobile-shared',
|
||||
'packages/common/nbstore',
|
||||
'packages/frontend/track',
|
||||
'blocksuite/affine/all',
|
||||
@@ -1313,6 +1315,11 @@ export const PackageList = [
|
||||
'packages/common/infra',
|
||||
],
|
||||
},
|
||||
{
|
||||
location: 'packages/frontend/apps/mobile-shared',
|
||||
name: '@affine/mobile-shared',
|
||||
workspaceDependencies: ['packages/frontend/core'],
|
||||
},
|
||||
{
|
||||
location: 'packages/frontend/apps/web',
|
||||
name: '@affine/web',
|
||||
@@ -1593,6 +1600,7 @@ export type PackageName =
|
||||
| '@affine/electron-renderer'
|
||||
| '@affine/ios'
|
||||
| '@affine/mobile'
|
||||
| '@affine/mobile-shared'
|
||||
| '@affine/web'
|
||||
| '@affine/component'
|
||||
| '@affine/core'
|
||||
|
||||
@@ -139,6 +139,7 @@
|
||||
{ "path": "./packages/frontend/apps/electron-renderer" },
|
||||
{ "path": "./packages/frontend/apps/ios" },
|
||||
{ "path": "./packages/frontend/apps/mobile" },
|
||||
{ "path": "./packages/frontend/apps/mobile-shared" },
|
||||
{ "path": "./packages/frontend/apps/web" },
|
||||
{ "path": "./packages/frontend/component" },
|
||||
{ "path": "./packages/frontend/core" },
|
||||
|
||||
12
yarn.lock
12
yarn.lock
@@ -255,6 +255,7 @@ __metadata:
|
||||
"@affine/core": "workspace:*"
|
||||
"@affine/env": "workspace:*"
|
||||
"@affine/i18n": "workspace:*"
|
||||
"@affine/mobile-shared": "workspace:*"
|
||||
"@affine/nbstore": "workspace:*"
|
||||
"@affine/track": "workspace:*"
|
||||
"@blocksuite/affine": "workspace:*"
|
||||
@@ -703,6 +704,7 @@ __metadata:
|
||||
"@affine/env": "workspace:*"
|
||||
"@affine/graphql": "workspace:*"
|
||||
"@affine/i18n": "workspace:*"
|
||||
"@affine/mobile-shared": "workspace:*"
|
||||
"@affine/native": "workspace:*"
|
||||
"@affine/nbstore": "workspace:*"
|
||||
"@affine/track": "workspace:*"
|
||||
@@ -764,6 +766,16 @@ __metadata:
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@affine/mobile-shared@workspace:*, @affine/mobile-shared@workspace:packages/frontend/apps/mobile-shared":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@affine/mobile-shared@workspace:packages/frontend/apps/mobile-shared"
|
||||
dependencies:
|
||||
"@affine/core": "workspace:*"
|
||||
"@capacitor/core": "npm:^7.0.0"
|
||||
typescript: "npm:^5.7.2"
|
||||
languageName: unknown
|
||||
linkType: soft
|
||||
|
||||
"@affine/mobile@workspace:packages/frontend/apps/mobile":
|
||||
version: 0.0.0-use.local
|
||||
resolution: "@affine/mobile@workspace:packages/frontend/apps/mobile"
|
||||
|
||||
Reference in New Issue
Block a user