Skip to content

Commit fe036e8

Browse files
IM.codesclaude
andcommitted
fix: iOS file download via one-time token (no native rebuild needed)
Server generates a single-use download token (POST .../download-token, 300s expiry). Native app opens the download URL with ?token=xxx in the system browser (SFSafariViewController), which handles save to Files, Photos, etc. natively. No Capacitor plugin changes — works with existing @capacitor/browser. Removes unused @capacitor/filesystem and @capacitor/share deps. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 0247792 commit fe036e8

4 files changed

Lines changed: 60 additions & 44 deletions

File tree

server/src/routes/file-transfer.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,29 @@ import * as path from 'node:path';
1313

1414
export const fileTransferRoutes = new Hono<{ Bindings: Env; Variables: { userId: string; role: string } }>();
1515

16-
fileTransferRoutes.use('/*', requireAuth());
16+
// ── One-time download tokens (in-memory, 60s expiry) ─────────────────────────
17+
// Allows native apps (iOS WKWebView) to open download URLs in the system browser
18+
// without needing auth cookies. Token is single-use and short-lived.
19+
const downloadTokens = new Map<string, { serverId: string; attachmentId: string; userId: string; expiresAt: number }>();
20+
21+
const authMiddleware = requireAuth();
22+
fileTransferRoutes.use('/*', async (c, next) => {
23+
// Allow token-based auth for download endpoint, otherwise require cookie/bearer auth
24+
const token = c.req.query('token');
25+
if (token && c.req.method === 'GET' && c.req.path.endsWith('/download')) {
26+
const entry = downloadTokens.get(token);
27+
if (!entry || Date.now() > entry.expiresAt) {
28+
downloadTokens.delete(token ?? '');
29+
return c.json({ error: 'invalid_or_expired_token' }, 401);
30+
}
31+
// Consume token (single-use)
32+
downloadTokens.delete(token);
33+
// Set userId so downstream handler can proceed
34+
c.set('userId' as never, entry.userId as never);
35+
return next();
36+
}
37+
return (authMiddleware as any)(c, next);
38+
});
1739

1840
// ── POST /api/server/:id/upload ─────────────────────────────────────────────
1941

@@ -95,6 +117,35 @@ fileTransferRoutes.post('/:id/upload', async (c) => {
95117
}
96118
});
97119

120+
// ── POST /api/server/:id/uploads/:attachmentId/download-token ────────────────
121+
// Generate a one-time token for downloading without cookies (iOS native app).
122+
123+
fileTransferRoutes.post('/:id/uploads/:attachmentId/download-token', async (c) => {
124+
const userId = c.get('userId' as never) as string;
125+
const serverId = c.req.param('id')!;
126+
const attachmentId = c.req.param('attachmentId')!;
127+
128+
const role = await resolveServerRole(c.env.DB, serverId, userId);
129+
if (role === 'none') return c.json({ error: 'forbidden' }, 403);
130+
131+
if (!/^[a-f0-9]+(\.[a-zA-Z0-9]+)?$/.test(attachmentId)) {
132+
return c.json({ error: 'invalid_attachment_id' }, 400);
133+
}
134+
135+
const token = randomHex(32);
136+
downloadTokens.set(token, { serverId, attachmentId, userId, expiresAt: Date.now() + 300_000 });
137+
138+
// Cleanup expired tokens periodically (max 1000 entries)
139+
if (downloadTokens.size > 1000) {
140+
const now = Date.now();
141+
for (const [k, v] of downloadTokens) {
142+
if (now > v.expiresAt) downloadTokens.delete(k);
143+
}
144+
}
145+
146+
return c.json({ token, expiresIn: 300 });
147+
});
148+
98149
// ── GET /api/server/:id/uploads/:attachmentId/download ──────────────────────
99150

100151
fileTransferRoutes.get('/:id/uploads/:attachmentId/download', async (c) => {

web/package-lock.json

Lines changed: 0 additions & 29 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

web/package.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,9 @@
1717
"@capacitor/app": "^8.0.1",
1818
"@capacitor/browser": "^8.0.2",
1919
"@capacitor/core": "^8.2.0",
20-
"@capacitor/filesystem": "^8.1.2",
2120
"@capacitor/ios": "^8.2.0",
2221
"@capacitor/preferences": "^8.0.1",
2322
"@capacitor/push-notifications": "^8.0.2",
24-
"@capacitor/share": "^8.0.1",
2523
"@capacitor/splash-screen": "^8.0.1",
2624
"@capacitor/status-bar": "^8.0.1",
2725
"@capgo/capacitor-speech-recognition": "^8.0.10",

web/src/api.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -928,20 +928,16 @@ export async function downloadAttachment(serverId: string, attachmentId: string)
928928
}
929929
}
930930
// Trigger download — WKWebView (iOS Capacitor) ignores <a download>,
931-
// so on native platforms write blob to cache via Filesystem then open
932-
// the iOS share sheet (Save to Files, Save Image, AirDrop, etc.).
931+
// so on native platforms get a one-time download token and open the URL
932+
// in the system browser. No extra native plugins needed.
933933
const isNative = !!(globalThis as Record<string, unknown>).Capacitor;
934934
if (isNative) {
935-
const { Filesystem, Directory } = await import('@capacitor/filesystem');
936-
const base64 = await new Promise<string>((resolve, reject) => {
937-
const reader = new FileReader();
938-
reader.onload = () => resolve((reader.result as string).split(',')[1] ?? '');
939-
reader.onerror = reject;
940-
reader.readAsDataURL(blob);
941-
});
942-
const saved = await Filesystem.writeFile({ path: filename, data: base64, directory: Directory.Cache });
943-
const { Share } = await import('@capacitor/share');
944-
await Share.share({ url: saved.uri, title: filename });
935+
const tokenRes = await apiFetch(`/api/server/${serverId}/uploads/${attachmentId}/download-token`, { method: 'POST' });
936+
const downloadToken = (tokenRes as { token: string }).token;
937+
const baseUrl = _baseUrl || window.location.origin;
938+
const downloadUrl = `${baseUrl}/api/server/${serverId}/uploads/${attachmentId}/download?token=${downloadToken}`;
939+
const { Browser } = await import('@capacitor/browser');
940+
await Browser.open({ url: downloadUrl });
945941
} else {
946942
const url = URL.createObjectURL(blob);
947943
const a = document.createElement('a');

0 commit comments

Comments
 (0)