mirror of
https://github.com/saltbo/zpan.git
synced 2026-08-28 15:51:29 +08:00
fix: audit must-fixes + product gaps (SSRF, move-quota, multipart, password-reset, trash retention, notifications) (#428)
* fix(downloads): block SSRF targets in remote-download source URL The remote-download source URI was only length-validated, so an authenticated editor could point a task at the cloud metadata endpoint, loopback, or RFC 1918 hosts and have the response exfiltrated to their own drive. Add a shared isSafeHttpUrl/isBlockedUrlHost guard (scheme allowlist + private/loopback/link-local/metadata/IPv6 blocking) and cross-check source type vs uri in createDownloadTaskSchema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(api): cover 9 untested src/lib/api.ts wrappers Adds api.test.ts coverage (RPC path, method, payload, success + ApiError paths) for listObjectsByPath, isNameConflictError, listAdminAuthProviders, upsertAuthProvider, deleteAuthProvider, listInviteCodes, generateInviteCodes, deleteInviteCode, and listTeamActivities — satisfying the CLAUDE.md coverage gate that otherwise blocks PRs touching api.ts. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(spaces): release source quota on cross-space move A cross-space "move" copied bytes into the target (reserving quota there) but only trashed the source. Trashed files still count toward usage, so the moved bytes were billed in both spaces and the source never freed — contrary to the design doc ("copy + delete source, quota effectively transfers"). Purge the source subtree (independent S3 copy already exists in the target) instead of trashing it, which deletes the objects, cascades share cleanup, and reconciles usage. Rename the response field sourceTrashed -> sourceDeleted and update the move hint copy accordingly. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(upload): wire S3 multipart for large files The upload UI only ever did a single presigned PUT, which caps at S3's 5 GiB limit and fails the whole transfer on any network blip — despite a complete multipart backend (object-upload-sessions) sitting unused. Add uploadPartToS3 (PUTs a part, returns its ETag) and a multipart-upload orchestrator: open session -> presign parts in batches of 100 -> PUT parts with bounded concurrency and per-part retry -> complete. Files over 100 MiB take this path; smaller files keep the single-PUT flow. Cancellation aborts the multipart and the draft. Also fixes the presignObjectUploadParts wrapper type to match the server's actual `url` field. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(auth): add password-reset flow There was no self-service password recovery — a forgotten password needed admin intervention. SMTP/email sending was already built; this wires the last mile: better-auth sendResetPassword (reset email), a "Forgot password?" link on sign-in, and /forgot-password + /reset-password pages. The forgot-password page never reveals whether an account exists. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(trash): auto-purge trashed items past a retention window Trashed files counted toward quota forever — trash never auto-emptied, so storage was never reclaimed without a manual "empty trash". Add a daily cron (CF Workers 0 4 * * * + Node setInterval) that purges trashed items older than ZPAN_TRASH_RETENTION_DAYS (default 30, 0 disables) across all orgs, reusing the existing purge path so S3 objects, share references, and quota are all cleaned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(notifications): typed NotificationType, i18n rendering, team-join Notifications were a bare-string type with only 3 producers, and server copy was stored as hardcoded English (zh users saw English). - Add a NotificationType union in shared/ and type the notification service. - Render notification title/body client-side from type + metadata via i18n, falling back to stored strings for older rows (fixes the hardcoded-English gap). - Notify users when they join a team (team_join). (Login auditing was intentionally dropped: reusing the activity-events feed for sign_in events would spam every user's per-org activity timeline. Proper auth auditing belongs in a dedicated log and can be added separately.) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test: cover SSRF guard and multipart upload branches Raise patch coverage on the new code: uploadPartToS3 pre-aborted-signal and network-error paths, the url-safety octet-overflow and public-IPv6 branches, and the invalid-magnet rejection in the download-task schema. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -32,6 +32,7 @@ import { executeWriteTransaction } from './services/db-transaction'
|
||||
import { currentTrafficPeriod } from './services/effective-quota'
|
||||
import { isEmailConfigured, sendEmail } from './services/email'
|
||||
import { redeemInviteCode, validateInviteCode } from './services/invite'
|
||||
import { createNotification } from './services/notification'
|
||||
import { findPersonalOrg } from './services/org'
|
||||
import { getEffectiveSignupMode } from './services/signup-mode-guard'
|
||||
import { acceptSiteInvitation, validateSiteInvitation } from './services/site-invitations'
|
||||
@@ -134,6 +135,18 @@ function buildVerificationEmailHtml(url: string): string {
|
||||
</div>`
|
||||
}
|
||||
|
||||
function buildResetPasswordEmailHtml(url: string): string {
|
||||
if (!url.startsWith('https://') && !url.startsWith('http://')) {
|
||||
throw new Error(`Reset password URL has unsafe protocol: ${url}`)
|
||||
}
|
||||
return `<div style="font-family:sans-serif;max-width:480px;margin:0 auto;padding:24px">
|
||||
<h2 style="margin:0 0 16px">Reset your password</h2>
|
||||
<p style="color:#555;line-height:1.5">Click the button below to choose a new password. This link expires in 1 hour.</p>
|
||||
<a href="${url}" style="display:inline-block;margin:24px 0;padding:12px 24px;background:#2563eb;color:#fff;text-decoration:none;border-radius:6px;font-weight:600">Reset Password</a>
|
||||
<p style="color:#999;font-size:13px">If you didn't request a password reset, you can safely ignore this email.</p>
|
||||
</div>`
|
||||
}
|
||||
|
||||
export async function createAuth(
|
||||
initialSource: Database | Platform,
|
||||
secret: string,
|
||||
@@ -175,6 +188,14 @@ export async function createAuth(
|
||||
hash: authHashPassword,
|
||||
verify: authVerifyPassword,
|
||||
},
|
||||
sendResetPassword: async ({ user, url }) => {
|
||||
if (!(await isEmailConfigured(source))) return
|
||||
await sendEmail(source, {
|
||||
to: user.email,
|
||||
subject: 'Reset your password - ZPan',
|
||||
html: buildResetPasswordEmailHtml(url),
|
||||
})
|
||||
},
|
||||
},
|
||||
emailVerification: {
|
||||
sendVerificationEmail: async ({ user, url }) => {
|
||||
@@ -242,6 +263,15 @@ export async function createAuth(
|
||||
targetName: organization.name,
|
||||
metadata: { role: member.role },
|
||||
})
|
||||
await createNotification(db, {
|
||||
userId: user.id,
|
||||
type: 'team_join',
|
||||
title: `You joined ${organization.name}`,
|
||||
body: "You now have access to this team's space.",
|
||||
refType: 'team',
|
||||
refId: organization.id,
|
||||
metadata: JSON.stringify({ teamName: organization.name }),
|
||||
})
|
||||
},
|
||||
afterRemoveMember: async ({ member, organization }) => {
|
||||
// Better Auth does not expose the actor (initiator) in this hook;
|
||||
|
||||
@@ -16,11 +16,13 @@ import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from './services/ins
|
||||
import { runLicensingRefresh } from './services/licensing-refresh-runner'
|
||||
import { syncPendingRemoteDownloadUsageReports } from './services/remote-download-usage'
|
||||
import { getSitePublicOrigin } from './services/site-public-origin'
|
||||
import { purgeExpiredTrash, resolveTrashRetentionDays } from './services/trash-retention'
|
||||
|
||||
const REFRESH_INTERVAL_MS = 6 * 60 * 60 * 1000 // 6 hours
|
||||
const TRAFFIC_SYNC_INTERVAL_MS = 10 * 60 * 1000 // 10 minutes
|
||||
const INSTANCE_TELEMETRY_INTERVAL_MS = 12 * 60 * 60 * 1000 // 12 hours
|
||||
const QUOTA_RESET_INTERVAL_MS = 24 * 60 * 60 * 1000 // daily; idempotent, resets only stale periods
|
||||
const TRASH_PURGE_INTERVAL_MS = 24 * 60 * 60 * 1000 // daily; purges trash past the retention window
|
||||
const appVersionGlobalKey = '__ZPAN_APP_VERSION__'
|
||||
const appCommitGlobalKey = '__ZPAN_APP_COMMIT__'
|
||||
|
||||
@@ -134,3 +136,20 @@ void resetExpiredTrafficQuotas(platform.db)
|
||||
setInterval(() => {
|
||||
void resetExpiredTrafficQuotas(platform.db)
|
||||
}, QUOTA_RESET_INTERVAL_MS)
|
||||
|
||||
console.log('trash.purge.scheduler.started interval=24h')
|
||||
function purgeExpiredTrashJob(): void {
|
||||
void (async () => {
|
||||
try {
|
||||
const purged = await purgeExpiredTrash(
|
||||
platform.db,
|
||||
resolveTrashRetentionDays(process.env.ZPAN_TRASH_RETENTION_DAYS),
|
||||
)
|
||||
if (purged > 0) console.log(`trash.purge.done count=${purged}`)
|
||||
} catch (err) {
|
||||
console.error(`trash.purge.error code=${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
})()
|
||||
}
|
||||
purgeExpiredTrashJob()
|
||||
setInterval(purgeExpiredTrashJob, TRASH_PURGE_INTERVAL_MS)
|
||||
|
||||
@@ -60,6 +60,23 @@ describe('Auth API', () => {
|
||||
expect(res.headers.get('set-cookie')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('POST /api/auth/request-password-reset is accepted (password reset is wired)', async () => {
|
||||
const { app } = await createTestApp()
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: 'Reset', email: 'reset@example.com', password: 'password123456' }),
|
||||
})
|
||||
const res = await app.request('/api/auth/request-password-reset', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: 'reset@example.com', redirectTo: '/reset-password' }),
|
||||
})
|
||||
// Endpoint exists and the sendResetPassword hook runs without error (email
|
||||
// is unconfigured in tests, so it no-ops). Not a 404 = the flow is mounted.
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
it('POST /api/auth/sign-in/email rejects missing captcha token when captcha is enabled', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
await app.request('/api/auth/sign-up/email', {
|
||||
|
||||
@@ -148,6 +148,37 @@ describe('Download tasks API integration', () => {
|
||||
expect(created.token).toBeTruthy()
|
||||
})
|
||||
|
||||
it('rejects download tasks whose source URL targets an internal host', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
const user = await authedHeaders(app, 'ssrf-user@example.com')
|
||||
for (const uri of [
|
||||
'http://169.254.169.254/latest/meta-data/',
|
||||
'http://localhost:8080/admin',
|
||||
'http://10.0.0.5/secret',
|
||||
'file:///etc/passwd',
|
||||
]) {
|
||||
const res = await app.request('/api/download-tasks', {
|
||||
method: 'POST',
|
||||
headers: { ...user, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: { type: 'http', uri }, targetFolder: '' }),
|
||||
})
|
||||
expect(res.status, `expected ${uri} to be rejected`).toBe(400)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects a magnet task whose URI is not a magnet link', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
const user = await authedHeaders(app, 'magnet-user@example.com')
|
||||
const res = await app.request('/api/download-tasks', {
|
||||
method: 'POST',
|
||||
headers: { ...user, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ source: { type: 'magnet', uri: 'https://example.com/not-a-magnet' }, targetFolder: '' }),
|
||||
})
|
||||
expect(res.status).toBe(400)
|
||||
})
|
||||
|
||||
it('deletes a downloader and returns unfinished tasks to the queue', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('GET /api/notifications', () => {
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createNotification(db, { userId, type: 'test', title: `Notification ${i}` })
|
||||
await createNotification(db, { userId, type: 'share_received', title: `Notification ${i}` })
|
||||
}
|
||||
|
||||
const res = await app.request('/api/notifications?page=1&pageSize=3', { headers })
|
||||
@@ -77,8 +77,8 @@ describe('GET /api/notifications', () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
const n1 = await createNotification(db, { userId, type: 'test', title: 'Read' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'Unread' })
|
||||
const n1 = await createNotification(db, { userId, type: 'share_received', title: 'Read' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'Unread' })
|
||||
|
||||
await app.request(`/api/notifications/${n1.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -97,7 +97,7 @@ describe('GET /api/notifications', () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
const otherId = await insertUser(db)
|
||||
await createNotification(db, { userId: otherId, type: 'test', title: 'Other' })
|
||||
await createNotification(db, { userId: otherId, type: 'share_received', title: 'Other' })
|
||||
|
||||
const res = await app.request('/api/notifications', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
@@ -113,8 +113,8 @@ describe('GET /api/notifications/stats', () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'B' })
|
||||
|
||||
const res = await app.request('/api/notifications/stats', { headers })
|
||||
expect(res.status).toBe(200)
|
||||
@@ -129,7 +129,7 @@ describe('PATCH /api/notifications/:id', () => {
|
||||
it('marks notification as read and returns 204', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
const n = await createNotification(db, { userId, type: 'test', title: 'Test' })
|
||||
const n = await createNotification(db, { userId, type: 'share_received', title: 'Test' })
|
||||
|
||||
const res = await app.request(`/api/notifications/${n.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -146,7 +146,7 @@ describe('PATCH /api/notifications/:id', () => {
|
||||
it('is idempotent', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
const n = await createNotification(db, { userId, type: 'test', title: 'Test' })
|
||||
const n = await createNotification(db, { userId, type: 'share_received', title: 'Test' })
|
||||
|
||||
await app.request(`/api/notifications/${n.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -165,7 +165,7 @@ describe('PATCH /api/notifications/:id', () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
const otherId = await insertUser(db)
|
||||
const n = await createNotification(db, { userId: otherId, type: 'test', title: 'Other' })
|
||||
const n = await createNotification(db, { userId: otherId, type: 'share_received', title: 'Other' })
|
||||
|
||||
const res = await app.request(`/api/notifications/${n.id}`, {
|
||||
method: 'PATCH',
|
||||
@@ -195,8 +195,8 @@ describe('PATCH /api/notifications', () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers, userId } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
|
||||
await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'B' })
|
||||
|
||||
const res = await app.request('/api/notifications', {
|
||||
method: 'PATCH',
|
||||
@@ -216,7 +216,7 @@ describe('PATCH /api/notifications', () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const { headers } = await signUpAndGetUser(app, `${nanoid()}@example.com`)
|
||||
const otherId = await insertUser(db)
|
||||
await createNotification(db, { userId: otherId, type: 'test', title: 'Other' })
|
||||
await createNotification(db, { userId: otherId, type: 'share_received', title: 'Other' })
|
||||
|
||||
const res = await app.request('/api/notifications', {
|
||||
method: 'PATCH',
|
||||
|
||||
@@ -70,16 +70,22 @@ async function insertFolder(
|
||||
async function insertFile(
|
||||
db: Awaited<ReturnType<typeof createTestApp>>['db'],
|
||||
orgId: string,
|
||||
opts: { id: string; name: string; parent?: string; status?: string },
|
||||
opts: { id: string; name: string; parent?: string; status?: string; size?: number },
|
||||
) {
|
||||
const now = Date.now()
|
||||
const status = opts.status ?? 'active'
|
||||
const size = opts.size ?? 100
|
||||
await db.run(sql`
|
||||
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, created_at, updated_at)
|
||||
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', 100, 0, ${opts.parent ?? ''}, 'some/key.txt', ${validStorage.id}, ${status}, ${now}, ${now})
|
||||
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${opts.name}, 'text/plain', ${size}, 0, ${opts.parent ?? ''}, 'some/key.txt', ${validStorage.id}, ${status}, ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
async function getOrgQuota(db: Awaited<ReturnType<typeof createTestApp>>['db'], orgId: string) {
|
||||
const rows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId} LIMIT 1`)
|
||||
return rows[0] ?? null
|
||||
}
|
||||
|
||||
async function getOrgId(db: Awaited<ReturnType<typeof createTestApp>>['db']): Promise<string> {
|
||||
const rows = await db.all<{ id: string }>(sql`
|
||||
SELECT id FROM organization WHERE metadata LIKE '%"type":"personal"%' LIMIT 1
|
||||
@@ -1256,16 +1262,16 @@ describe('POST /api/objects/:id/transfers', () => {
|
||||
const res = await transferRequest(app, headers, 'src-copy', { targetOrgId: 'team-a', mode: 'copy' })
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as { saved: Array<{ orgId: string; name: string }>; sourceTrashed: boolean }
|
||||
const body = (await res.json()) as { saved: Array<{ orgId: string; name: string }>; sourceDeleted: boolean }
|
||||
expect(body.saved).toHaveLength(1)
|
||||
expect(body.saved[0].orgId).toBe('team-a')
|
||||
expect(body.sourceTrashed).toBe(false)
|
||||
expect(body.sourceDeleted).toBe(false)
|
||||
expect(S3Service.prototype.copyObject).toHaveBeenCalled()
|
||||
const source = await getMatter(db, 'src-copy', orgId)
|
||||
expect(source?.status).toBe('active')
|
||||
})
|
||||
|
||||
it('moves a file into a team space and trashes the source', async () => {
|
||||
it('moves a file into a team space, deleting the source and releasing its quota', async () => {
|
||||
const { app, db } = await createTestApp()
|
||||
const headers = await authedHeaders(app)
|
||||
await insertStorage(db)
|
||||
@@ -1273,15 +1279,21 @@ describe('POST /api/objects/:id/transfers', () => {
|
||||
const userId = await getUserIdByEmail(db, 'test@example.com')
|
||||
await insertTeamOrg(db, 'team-b')
|
||||
await insertMember(db, 'team-b', userId, 'owner')
|
||||
await insertFile(db, orgId, { id: 'src-move', name: 'photo.jpg' })
|
||||
await insertFile(db, orgId, { id: 'src-move', name: 'photo.jpg', size: 1024 })
|
||||
await db.run(sql`
|
||||
INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period)
|
||||
VALUES (${`q-${orgId}`}, ${orgId}, ${1024 * 1024}, 1024, 0, 0, '1970-01')
|
||||
`)
|
||||
|
||||
const res = await transferRequest(app, headers, 'src-move', { targetOrgId: 'team-b', mode: 'move' })
|
||||
|
||||
expect(res.status).toBe(201)
|
||||
const body = (await res.json()) as { saved: Array<{ orgId: string }>; sourceTrashed: boolean }
|
||||
expect(body.sourceTrashed).toBe(true)
|
||||
const body = (await res.json()) as { saved: Array<{ orgId: string }>; sourceDeleted: boolean }
|
||||
expect(body.sourceDeleted).toBe(true)
|
||||
// Source is purged, not trashed — its quota must be released, not double-counted.
|
||||
const source = await getMatter(db, 'src-move', orgId)
|
||||
expect(source?.status).toBe('trashed')
|
||||
expect(source).toBeNull()
|
||||
expect((await getOrgQuota(db, orgId))?.used ?? 0).toBe(0)
|
||||
const targetList = await listMatters(db, 'team-b', { parent: '', status: 'active', page: 1, pageSize: 10 })
|
||||
expect(targetList.items.map((m) => m.name)).toContain('photo.jpg')
|
||||
})
|
||||
|
||||
@@ -486,12 +486,16 @@ const app = new Hono<Env>()
|
||||
},
|
||||
})
|
||||
|
||||
// Move = copy + trash source. Only trash when every file copied — a
|
||||
// partial copy must never destroy the originals.
|
||||
let sourceTrashed = false
|
||||
// Move = copy + delete source. Only delete when every file copied — a
|
||||
// partial copy must never destroy the originals. The source is purged (not
|
||||
// trashed) so its quota is actually released: trashed files still count
|
||||
// toward usage, which would otherwise double-charge the moved bytes in both
|
||||
// spaces. The independent copy already lives in the target space.
|
||||
let sourceDeleted = false
|
||||
if (mode === 'move' && result.skipped.length === 0) {
|
||||
await trashMatter(db, orgId, source.id, userId)
|
||||
sourceTrashed = true
|
||||
const subtree = await collectForPurge(db, orgId, source)
|
||||
await purgeRecursively(db, orgId, subtree)
|
||||
sourceDeleted = true
|
||||
await recordActivity(db, {
|
||||
orgId,
|
||||
userId,
|
||||
@@ -503,7 +507,7 @@ const app = new Hono<Env>()
|
||||
})
|
||||
}
|
||||
|
||||
return c.json({ ...result, sourceTrashed }, 201)
|
||||
return c.json({ ...result, sourceDeleted }, 201)
|
||||
})
|
||||
|
||||
export default app
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { SQL } from 'drizzle-orm'
|
||||
import { and, asc, count, desc, eq, inArray, like, or, sql } from 'drizzle-orm'
|
||||
import { and, asc, count, desc, eq, inArray, isNotNull, like, lt, or, sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { DirType } from '../../shared/constants'
|
||||
import { matters } from '../db/schema'
|
||||
@@ -573,6 +573,15 @@ export async function purgeMatters(db: Database, orgId: string, ids: string[]):
|
||||
}
|
||||
}
|
||||
|
||||
/** Distinct orgIds that hold at least one trashed matter older than the cutoff (epoch ms). */
|
||||
export async function listOrgIdsWithExpiredTrash(db: Database, cutoff: number): Promise<string[]> {
|
||||
const rows = await db
|
||||
.selectDistinct({ orgId: matters.orgId })
|
||||
.from(matters)
|
||||
.where(and(eq(matters.status, 'trashed'), isNotNull(matters.trashedAt), lt(matters.trashedAt, cutoff)))
|
||||
return rows.map((r) => r.orgId)
|
||||
}
|
||||
|
||||
export async function listTrashedRoots(db: Database, orgId: string): Promise<Matter[]> {
|
||||
const all = await db
|
||||
.select()
|
||||
|
||||
@@ -79,7 +79,7 @@ describe('listNotifications', () => {
|
||||
const userId = await insertUser(db)
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await createNotification(db, { userId, type: 'test', title: `Notification ${i}` })
|
||||
await createNotification(db, { userId, type: 'share_received', title: `Notification ${i}` })
|
||||
}
|
||||
|
||||
const page1 = await listNotifications(db, userId, { page: 1, pageSize: 3 })
|
||||
@@ -94,8 +94,8 @@ describe('listNotifications', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const n1 = await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
const n1 = await createNotification(db, { userId, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'B' })
|
||||
await markAsRead(db, userId, n1.id)
|
||||
|
||||
const result = await listNotifications(db, userId, { page: 1, pageSize: 20 })
|
||||
@@ -107,8 +107,8 @@ describe('listNotifications', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
const n1 = await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
const n1 = await createNotification(db, { userId, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'B' })
|
||||
await markAsRead(db, userId, n1.id)
|
||||
|
||||
const result = await listNotifications(db, userId, { page: 1, pageSize: 20, unreadOnly: true })
|
||||
@@ -121,7 +121,7 @@ describe('listNotifications', () => {
|
||||
const user1 = await insertUser(db)
|
||||
const user2 = await insertUser(db)
|
||||
|
||||
await createNotification(db, { userId: user1, type: 'test', title: 'For user1' })
|
||||
await createNotification(db, { userId: user1, type: 'share_received', title: 'For user1' })
|
||||
|
||||
const result = await listNotifications(db, user2, { page: 1, pageSize: 20 })
|
||||
expect(result.items).toHaveLength(0)
|
||||
@@ -132,7 +132,7 @@ describe('markAsRead', () => {
|
||||
it('marks a notification as read (idempotent)', async () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
const n = await createNotification(db, { userId, type: 'test', title: 'Test' })
|
||||
const n = await createNotification(db, { userId, type: 'share_received', title: 'Test' })
|
||||
|
||||
const first = await markAsRead(db, userId, n.id)
|
||||
expect(first).toBe(true)
|
||||
@@ -148,7 +148,7 @@ describe('markAsRead', () => {
|
||||
const { db } = await createTestApp()
|
||||
const owner = await insertUser(db)
|
||||
const other = await insertUser(db)
|
||||
const n = await createNotification(db, { userId: owner, type: 'test', title: 'Test' })
|
||||
const n = await createNotification(db, { userId: owner, type: 'share_received', title: 'Test' })
|
||||
|
||||
const result = await markAsRead(db, other, n.id)
|
||||
expect(result).toBe(false)
|
||||
@@ -163,8 +163,8 @@ describe('markAllAsRead', () => {
|
||||
const { db } = await createTestApp()
|
||||
const userId = await insertUser(db)
|
||||
|
||||
await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'B' })
|
||||
|
||||
const result = await markAllAsRead(db, userId)
|
||||
expect(result.count).toBe(2)
|
||||
@@ -178,8 +178,8 @@ describe('markAllAsRead', () => {
|
||||
const user1 = await insertUser(db)
|
||||
const user2 = await insertUser(db)
|
||||
|
||||
await createNotification(db, { userId: user1, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId: user2, type: 'test', title: 'B' })
|
||||
await createNotification(db, { userId: user1, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId: user2, type: 'share_received', title: 'B' })
|
||||
|
||||
await markAllAsRead(db, user1)
|
||||
|
||||
@@ -203,8 +203,8 @@ describe('unreadCount', () => {
|
||||
|
||||
expect(await unreadCount(db, userId)).toBe(0)
|
||||
|
||||
const n = await createNotification(db, { userId, type: 'test', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'test', title: 'B' })
|
||||
const n = await createNotification(db, { userId, type: 'share_received', title: 'A' })
|
||||
await createNotification(db, { userId, type: 'share_received', title: 'B' })
|
||||
|
||||
expect(await unreadCount(db, userId)).toBe(2)
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { NotificationType } from '@shared/types'
|
||||
import { and, count, desc, eq, isNull } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { notifications } from '../db/schema'
|
||||
@@ -7,7 +8,7 @@ export type Notification = typeof notifications.$inferSelect
|
||||
|
||||
export type CreateNotificationInput = {
|
||||
userId: string
|
||||
type: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
body?: string
|
||||
refType?: string
|
||||
|
||||
@@ -52,7 +52,7 @@ export async function dispatchShareCreated(
|
||||
body: 'Click to open the share',
|
||||
refType: 'share',
|
||||
refId: share.id,
|
||||
metadata: JSON.stringify({ token: share.token, kind: share.kind }),
|
||||
metadata: JSON.stringify({ token: share.token, kind: share.kind, creatorName, matterName }),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { nanoid } from 'nanoid'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createTestApp } from '../test/setup.js'
|
||||
import { getMatter } from './matter.js'
|
||||
import { S3Service } from './s3.js'
|
||||
import { DEFAULT_TRASH_RETENTION_DAYS, purgeExpiredTrash, resolveTrashRetentionDays } from './trash-retention.js'
|
||||
|
||||
type TestDb = Awaited<ReturnType<typeof createTestApp>>['db']
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.spyOn(S3Service.prototype, 'deleteObjects').mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
async function insertStorage(db: TestDb) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO storages (id, title, mode, bucket, endpoint, region, access_key, secret_key, file_path, custom_host, capacity, used, status, created_at, updated_at)
|
||||
VALUES ('st-1', 'Test S3', 'private', 'b', 'https://s3.example.com', 'us-east-1', 'AKID', 'SECRET', '$UID/$RAW_NAME', '', 0, 0, 'active', ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
async function insertOrg(db: TestDb, orgId: string) {
|
||||
await db.run(sql`
|
||||
INSERT INTO organization (id, name, slug, created_at)
|
||||
VALUES (${orgId}, 'Org', ${`org-${orgId}`}, ${Date.now()})
|
||||
`)
|
||||
await db.run(sql`
|
||||
INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period)
|
||||
VALUES (${nanoid()}, ${orgId}, ${1024 * 1024}, 0, 0, 0, '1970-01')
|
||||
`)
|
||||
}
|
||||
|
||||
async function insertFile(
|
||||
db: TestDb,
|
||||
orgId: string,
|
||||
opts: { id: string; size: number; status: 'active' | 'trashed'; trashedAt?: number },
|
||||
) {
|
||||
const now = Date.now()
|
||||
await db.run(sql`
|
||||
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
|
||||
VALUES (${opts.id}, ${orgId}, ${`${opts.id}-alias`}, ${`${opts.id}.txt`}, 'text/plain', ${opts.size}, 0, '', ${`key/${opts.id}`}, 'st-1', ${opts.status}, ${opts.trashedAt ?? null}, ${now}, ${now})
|
||||
`)
|
||||
}
|
||||
|
||||
async function getUsed(db: TestDb, orgId: string): Promise<number> {
|
||||
const rows = await db.all<{ used: number }>(sql`SELECT used FROM org_quotas WHERE org_id = ${orgId}`)
|
||||
return rows[0]?.used ?? 0
|
||||
}
|
||||
|
||||
describe('resolveTrashRetentionDays', () => {
|
||||
it('defaults when unset, empty, or invalid', () => {
|
||||
expect(resolveTrashRetentionDays(undefined)).toBe(DEFAULT_TRASH_RETENTION_DAYS)
|
||||
expect(resolveTrashRetentionDays('')).toBe(DEFAULT_TRASH_RETENTION_DAYS)
|
||||
expect(resolveTrashRetentionDays('abc')).toBe(DEFAULT_TRASH_RETENTION_DAYS)
|
||||
expect(resolveTrashRetentionDays('-5')).toBe(DEFAULT_TRASH_RETENTION_DAYS)
|
||||
})
|
||||
|
||||
it('honors explicit values, including 0 (disabled)', () => {
|
||||
expect(resolveTrashRetentionDays('7')).toBe(7)
|
||||
expect(resolveTrashRetentionDays('0')).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('purgeExpiredTrash', () => {
|
||||
it('purges trash older than the window and reclaims quota, keeping recent trash and active files', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db)
|
||||
const orgId = nanoid()
|
||||
await insertOrg(db, orgId)
|
||||
const now = Date.now()
|
||||
await insertFile(db, orgId, { id: 'old', size: 100, status: 'trashed', trashedAt: now - 40 * DAY_MS })
|
||||
await insertFile(db, orgId, { id: 'recent', size: 200, status: 'trashed', trashedAt: now - 5 * DAY_MS })
|
||||
await insertFile(db, orgId, { id: 'active', size: 300, status: 'active' })
|
||||
|
||||
const purged = await purgeExpiredTrash(db, 30, now)
|
||||
|
||||
expect(purged).toBe(1)
|
||||
expect(await getMatter(db, 'old', orgId)).toBeNull()
|
||||
expect((await getMatter(db, 'recent', orgId))?.status).toBe('trashed')
|
||||
expect((await getMatter(db, 'active', orgId))?.status).toBe('active')
|
||||
expect(S3Service.prototype.deleteObjects).toHaveBeenCalled()
|
||||
// reconcile counts active + trashed: recent(200) + active(300); old(100) freed.
|
||||
expect(await getUsed(db, orgId)).toBe(500)
|
||||
})
|
||||
|
||||
it('is a no-op when retention is 0 (disabled)', async () => {
|
||||
const { db } = await createTestApp()
|
||||
await insertStorage(db)
|
||||
const orgId = nanoid()
|
||||
await insertOrg(db, orgId)
|
||||
await insertFile(db, orgId, { id: 'old', size: 100, status: 'trashed', trashedAt: Date.now() - 400 * DAY_MS })
|
||||
|
||||
const purged = await purgeExpiredTrash(db, 0)
|
||||
|
||||
expect(purged).toBe(0)
|
||||
expect(await getMatter(db, 'old', orgId)).not.toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { Database } from '../platform/interface'
|
||||
import { collectForPurge, listOrgIdsWithExpiredTrash, listTrashedRoots } from './matter'
|
||||
import { purgeRecursively } from './purge'
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
export const DEFAULT_TRASH_RETENTION_DAYS = 30
|
||||
|
||||
/** Parses ZPAN_TRASH_RETENTION_DAYS; falls back to the default, 0 disables purge. */
|
||||
export function resolveTrashRetentionDays(raw: string | undefined): number {
|
||||
if (raw === undefined || raw.trim() === '') return DEFAULT_TRASH_RETENTION_DAYS
|
||||
const days = Number(raw)
|
||||
if (!Number.isFinite(days) || days < 0) return DEFAULT_TRASH_RETENTION_DAYS
|
||||
return Math.floor(days)
|
||||
}
|
||||
|
||||
/**
|
||||
* Permanently purges trashed items older than `retentionDays` across all orgs,
|
||||
* reclaiming their quota. Retention of 0 disables auto-purge. Runs subtree at a
|
||||
* time via the same purge path as emptying the trash manually.
|
||||
*/
|
||||
export async function purgeExpiredTrash(db: Database, retentionDays: number, now = Date.now()): Promise<number> {
|
||||
if (retentionDays <= 0) return 0
|
||||
const cutoff = now - retentionDays * DAY_MS
|
||||
const orgIds = await listOrgIdsWithExpiredTrash(db, cutoff)
|
||||
|
||||
let purged = 0
|
||||
for (const orgId of orgIds) {
|
||||
const roots = await listTrashedRoots(db, orgId)
|
||||
for (const root of roots) {
|
||||
if ((root.trashedAt ?? 0) >= cutoff) continue
|
||||
const matters = await collectForPurge(db, orgId, root.id)
|
||||
if (!matters) continue
|
||||
purged += await purgeRecursively(db, orgId, matters)
|
||||
}
|
||||
}
|
||||
return purged
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from '@hono/zod-openapi'
|
||||
import { isSafeHttpUrl } from '../url-safety'
|
||||
|
||||
export const downloaderStatusSchema = z.enum(['online', 'offline', 'disabled'])
|
||||
export const downloaderEngineSchema = z.enum(['builtin', 'aria2', 'qbittorrent'])
|
||||
@@ -238,10 +239,27 @@ const targetFolderSchema = z
|
||||
.refine((value) => !value.split('/').includes('..'), { message: 'Target folder cannot contain ..' })
|
||||
|
||||
export const createDownloadTaskSchema = z.object({
|
||||
source: z.object({
|
||||
type: downloadSourceTypeSchema,
|
||||
uri: downloadUriSchema,
|
||||
}),
|
||||
source: z
|
||||
.object({
|
||||
type: downloadSourceTypeSchema,
|
||||
uri: downloadUriSchema,
|
||||
})
|
||||
.superRefine((source, ctx) => {
|
||||
if (source.type === 'magnet') {
|
||||
if (!/^magnet:\?/i.test(source.uri)) {
|
||||
ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['uri'], message: 'Magnet source must be a magnet: URI' })
|
||||
}
|
||||
return
|
||||
}
|
||||
// http and torrent_url both fetch over http(s); block internal/metadata targets (SSRF).
|
||||
if (!isSafeHttpUrl(source.uri)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['uri'],
|
||||
message: 'URL must be a public http(s) address',
|
||||
})
|
||||
}
|
||||
}),
|
||||
targetFolder: targetFolderSchema,
|
||||
name: z.string().min(1).max(255).optional(),
|
||||
category: downloadTaskCategorySchema.optional(),
|
||||
|
||||
@@ -481,10 +481,12 @@ export interface ShareView {
|
||||
recipients?: ShareRecipient[]
|
||||
}
|
||||
|
||||
export type NotificationType = 'share_received' | 'archive_job_completed' | 'archive_job_failed' | 'team_join'
|
||||
|
||||
export interface Notification {
|
||||
id: string
|
||||
userId: string
|
||||
type: string
|
||||
type: NotificationType
|
||||
title: string
|
||||
body: string
|
||||
refType: string | null
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isBlockedUrlHost, isSafeHttpUrl } from './url-safety'
|
||||
|
||||
describe('isBlockedUrlHost', () => {
|
||||
it('blocks loopback and localhost names', () => {
|
||||
expect(isBlockedUrlHost('localhost')).toBe(true)
|
||||
expect(isBlockedUrlHost('foo.localhost')).toBe(true)
|
||||
expect(isBlockedUrlHost('127.0.0.1')).toBe(true)
|
||||
expect(isBlockedUrlHost('127.5.5.5')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks the cloud metadata endpoint and link-local range', () => {
|
||||
expect(isBlockedUrlHost('169.254.169.254')).toBe(true)
|
||||
expect(isBlockedUrlHost('169.254.0.1')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks RFC 1918, CGNAT, and 0.0.0.0', () => {
|
||||
expect(isBlockedUrlHost('10.0.0.1')).toBe(true)
|
||||
expect(isBlockedUrlHost('172.16.0.1')).toBe(true)
|
||||
expect(isBlockedUrlHost('172.31.255.255')).toBe(true)
|
||||
expect(isBlockedUrlHost('192.168.1.1')).toBe(true)
|
||||
expect(isBlockedUrlHost('100.64.0.1')).toBe(true)
|
||||
expect(isBlockedUrlHost('0.0.0.0')).toBe(true)
|
||||
})
|
||||
|
||||
it('blocks IPv6 loopback, ULA, link-local, and mapped v4', () => {
|
||||
expect(isBlockedUrlHost('[::1]')).toBe(true)
|
||||
expect(isBlockedUrlHost('::1')).toBe(true)
|
||||
expect(isBlockedUrlHost('fd00::1')).toBe(true)
|
||||
expect(isBlockedUrlHost('fe80::1')).toBe(true)
|
||||
expect(isBlockedUrlHost('[::ffff:127.0.0.1]')).toBe(true)
|
||||
})
|
||||
|
||||
it('allows public hosts', () => {
|
||||
expect(isBlockedUrlHost('example.com')).toBe(false)
|
||||
expect(isBlockedUrlHost('8.8.8.8')).toBe(false)
|
||||
expect(isBlockedUrlHost('172.32.0.1')).toBe(false)
|
||||
expect(isBlockedUrlHost('173.16.0.1')).toBe(false)
|
||||
expect(isBlockedUrlHost('[2001:db8::1]')).toBe(false) // public IPv6
|
||||
})
|
||||
|
||||
it('treats malformed IPv4 (octet > 255) as a non-IP host', () => {
|
||||
expect(isBlockedUrlHost('256.1.1.1')).toBe(false)
|
||||
expect(isBlockedUrlHost('999.0.0.1')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isSafeHttpUrl', () => {
|
||||
it('accepts public http(s) URLs', () => {
|
||||
expect(isSafeHttpUrl('https://example.com/file.zip')).toBe(true)
|
||||
expect(isSafeHttpUrl('http://203.0.113.5/a.iso')).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects non-http schemes', () => {
|
||||
expect(isSafeHttpUrl('file:///etc/passwd')).toBe(false)
|
||||
expect(isSafeHttpUrl('gopher://example.com')).toBe(false)
|
||||
expect(isSafeHttpUrl('dict://127.0.0.1:11211')).toBe(false)
|
||||
expect(isSafeHttpUrl('ftp://example.com/x')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects internal hosts', () => {
|
||||
expect(isSafeHttpUrl('http://169.254.169.254/latest/meta-data/')).toBe(false)
|
||||
expect(isSafeHttpUrl('http://localhost:8080/admin')).toBe(false)
|
||||
expect(isSafeHttpUrl('http://10.0.0.5/')).toBe(false)
|
||||
expect(isSafeHttpUrl('http://[::1]/')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects malformed URLs', () => {
|
||||
expect(isSafeHttpUrl('not a url')).toBe(false)
|
||||
expect(isSafeHttpUrl('')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* SSRF guards for user-supplied URLs that the server (or a server-side agent)
|
||||
* will fetch — currently the remote-download source URI.
|
||||
*
|
||||
* The literal-host checks below stop the obvious attacks (metadata endpoint,
|
||||
* loopback, RFC 1918). They cannot stop DNS rebinding, where a public hostname
|
||||
* resolves to a private address at fetch time — that must be re-checked after
|
||||
* DNS resolution by whoever performs the actual fetch.
|
||||
*/
|
||||
|
||||
function ipv4Octets(host: string): [number, number, number, number] | null {
|
||||
if (!/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null
|
||||
const parts = host.split('.').map(Number)
|
||||
if (parts.some((n) => n > 255)) return null
|
||||
return parts as [number, number, number, number]
|
||||
}
|
||||
|
||||
function isBlockedIpv4(host: string): boolean {
|
||||
const octets = ipv4Octets(host)
|
||||
if (!octets) return false
|
||||
const [a, b] = octets
|
||||
if (a === 0) return true // 0.0.0.0/8 "this network"
|
||||
if (a === 10) return true // private
|
||||
if (a === 127) return true // loopback
|
||||
if (a === 169 && b === 254) return true // link-local incl. 169.254.169.254 metadata
|
||||
if (a === 172 && b >= 16 && b <= 31) return true // private
|
||||
if (a === 192 && b === 168) return true // private
|
||||
if (a === 100 && b >= 64 && b <= 127) return true // CGNAT shared address space
|
||||
return false
|
||||
}
|
||||
|
||||
function isBlockedIpv6(host: string): boolean {
|
||||
const h = host.toLowerCase()
|
||||
if (h === '::' || h === '::1') return true // unspecified / loopback
|
||||
if (h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) {
|
||||
return true // fe80::/10 link-local
|
||||
}
|
||||
if (h.startsWith('fc') || h.startsWith('fd')) return true // fc00::/7 unique local
|
||||
const mapped = h.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/)
|
||||
if (mapped) return isBlockedIpv4(mapped[1])
|
||||
return false
|
||||
}
|
||||
|
||||
/** True when `hostname` resolves to a non-routable / internal address we must not fetch. */
|
||||
export function isBlockedUrlHost(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase()
|
||||
if (host === 'localhost' || host.endsWith('.localhost')) return true
|
||||
const bare = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host
|
||||
if (isBlockedIpv4(bare)) return true
|
||||
if (bare.includes(':') && isBlockedIpv6(bare)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
/** Validates an http(s) URL is well-formed and not pointed at an internal host. */
|
||||
export function isSafeHttpUrl(value: string): boolean {
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(value)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false
|
||||
return !isBlockedUrlHost(url.hostname)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { Notification } from '@shared/types'
|
||||
import { useNavigate } from '@tanstack/react-router'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { markNotificationRead } from '@/lib/api'
|
||||
import { notificationContent } from '@/lib/notification-content'
|
||||
|
||||
function diffMinutes(dateStr: string): number {
|
||||
return Math.floor((Date.now() - new Date(dateStr).getTime()) / 60_000)
|
||||
@@ -29,6 +30,7 @@ export function NotificationItem({ notification, onRead }: NotificationItemProps
|
||||
const navigate = useNavigate()
|
||||
const isUnread = !notification.readAt
|
||||
const href = resolveHref(notification)
|
||||
const { title, body } = notificationContent(notification, t)
|
||||
|
||||
function relativeTime(): string {
|
||||
const mins = diffMinutes(notification.createdAt)
|
||||
@@ -57,8 +59,8 @@ export function NotificationItem({ notification, onRead }: NotificationItemProps
|
||||
{isUnread && <span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-primary" />}
|
||||
{!isUnread && <span className="mt-1.5 h-2 w-2 shrink-0" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={`text-sm truncate ${isUnread ? 'font-semibold' : 'font-medium'}`}>{notification.title}</p>
|
||||
{notification.body && <p className="text-xs text-muted-foreground truncate">{notification.body}</p>}
|
||||
<p className={`text-sm truncate ${isUnread ? 'font-semibold' : 'font-medium'}`}>{title}</p>
|
||||
{body && <p className="text-xs text-muted-foreground truncate">{body}</p>}
|
||||
<p className="text-xs text-muted-foreground mt-0.5">{relativeTime()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { uploadFileInParts } from './multipart-upload'
|
||||
import type { UploadRunnerContext } from './upload-queue'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
createObjectUploadSession: vi.fn(),
|
||||
presignObjectUploadParts: vi.fn(),
|
||||
patchObjectUploadSession: vi.fn(),
|
||||
uploadPartToS3: vi.fn(),
|
||||
cancelUpload: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => api)
|
||||
|
||||
function makeCtx(overrides: Partial<UploadRunnerContext> = {}): UploadRunnerContext & {
|
||||
cleanup?: () => Promise<void>
|
||||
progress: number[]
|
||||
} {
|
||||
const controller = new AbortController()
|
||||
const ctx = {
|
||||
signal: controller.signal,
|
||||
progress: [] as number[],
|
||||
cleanup: undefined as (() => Promise<void>) | undefined,
|
||||
onProgress: vi.fn((p: { loaded: number; total: number }) => {
|
||||
ctx.progress.push(p.loaded)
|
||||
}),
|
||||
setStatus: vi.fn(),
|
||||
registerCleanup: vi.fn((fn: () => Promise<void>) => {
|
||||
ctx.cleanup = fn
|
||||
}),
|
||||
...overrides,
|
||||
}
|
||||
return ctx as never
|
||||
}
|
||||
|
||||
describe('uploadFileInParts', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.createObjectUploadSession.mockResolvedValue({ id: 'session-1', partSize: 4 })
|
||||
api.presignObjectUploadParts.mockImplementation((_id, _sid, { partNumbers }: { partNumbers: number[] }) =>
|
||||
Promise.resolve({
|
||||
uploadId: 'mp-1',
|
||||
partSize: 4,
|
||||
parts: partNumbers.map((n) => ({ partNumber: n, url: `https://s3/part-${n}` })),
|
||||
}),
|
||||
)
|
||||
api.uploadPartToS3.mockImplementation((url: string) => Promise.resolve(`etag-${url.split('-').pop()}`))
|
||||
api.patchObjectUploadSession.mockResolvedValue({ id: 'session-1', status: 'completed' })
|
||||
api.cancelUpload.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
it('splits the file into parts and completes with ordered etags', async () => {
|
||||
// 10 bytes, partSize 4 -> 3 parts (4, 4, 2)
|
||||
const file = new File(['0123456789'], 'big.bin')
|
||||
const ctx = makeCtx()
|
||||
|
||||
await uploadFileInParts('obj-1', file, ctx)
|
||||
|
||||
expect(api.uploadPartToS3).toHaveBeenCalledTimes(3)
|
||||
// Each presigned part PUTs the correct slice size
|
||||
const sentSizes = api.uploadPartToS3.mock.calls.map(([, blob]) => (blob as Blob).size).sort()
|
||||
expect(sentSizes).toEqual([2, 4, 4])
|
||||
|
||||
const completeCall = api.patchObjectUploadSession.mock.calls.at(-1)
|
||||
expect(completeCall?.[2]).toEqual({
|
||||
action: 'complete',
|
||||
parts: [
|
||||
{ partNumber: 1, etag: 'etag-1' },
|
||||
{ partNumber: 2, etag: 'etag-2' },
|
||||
{ partNumber: 3, etag: 'etag-3' },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports cumulative progress up to the file size', async () => {
|
||||
const file = new File(['0123456789'], 'big.bin')
|
||||
const ctx = makeCtx()
|
||||
|
||||
await uploadFileInParts('obj-1', file, ctx)
|
||||
|
||||
expect(Math.max(...ctx.progress)).toBe(file.size)
|
||||
})
|
||||
|
||||
it('registers a cleanup that aborts the multipart and the draft', async () => {
|
||||
const file = new File(['0123456789'], 'big.bin')
|
||||
const ctx = makeCtx()
|
||||
|
||||
await uploadFileInParts('obj-1', file, ctx)
|
||||
expect(ctx.cleanup).toBeTypeOf('function')
|
||||
|
||||
await ctx.cleanup?.()
|
||||
expect(api.patchObjectUploadSession).toHaveBeenCalledWith('obj-1', 'session-1', { action: 'abort' })
|
||||
expect(api.cancelUpload).toHaveBeenCalledWith('obj-1')
|
||||
})
|
||||
|
||||
it('retries a failing part before succeeding', async () => {
|
||||
vi.useFakeTimers()
|
||||
api.uploadPartToS3.mockReset()
|
||||
api.uploadPartToS3.mockRejectedValueOnce(new Error('network blip')).mockResolvedValue('etag-retry')
|
||||
const file = new File(['0123'], 'small-multipart.bin') // 1 part
|
||||
const ctx = makeCtx()
|
||||
|
||||
const promise = uploadFileInParts('obj-1', file, ctx)
|
||||
await vi.runAllTimersAsync()
|
||||
await promise
|
||||
|
||||
expect(api.uploadPartToS3).toHaveBeenCalledTimes(2)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
cancelUpload,
|
||||
createObjectUploadSession,
|
||||
patchObjectUploadSession,
|
||||
presignObjectUploadParts,
|
||||
uploadPartToS3,
|
||||
} from '@/lib/api'
|
||||
import type { UploadRunnerContext } from './upload-queue'
|
||||
|
||||
/** Files larger than this use S3 multipart (chunked, resumable parts). */
|
||||
export const MULTIPART_THRESHOLD = 100 * 1024 * 1024
|
||||
/** Max part numbers presigned per request (matches presignObjectUploadPartsSchema). */
|
||||
const PRESIGN_BATCH = 100
|
||||
/** Concurrent part PUTs in flight. */
|
||||
const PART_CONCURRENCY = 4
|
||||
/** Retry attempts per part before giving up — survives transient network blips. */
|
||||
const PART_ATTEMPTS = 3
|
||||
|
||||
function isAbortError(err: unknown): boolean {
|
||||
return err instanceof DOMException && err.name === 'AbortError'
|
||||
}
|
||||
|
||||
async function uploadPartWithRetry(
|
||||
url: string,
|
||||
blob: Blob,
|
||||
options: { signal: AbortSignal; onProgress: (loaded: number) => void },
|
||||
): Promise<string> {
|
||||
let lastError: unknown
|
||||
for (let attempt = 0; attempt < PART_ATTEMPTS; attempt++) {
|
||||
try {
|
||||
return await uploadPartToS3(url, blob, {
|
||||
signal: options.signal,
|
||||
onProgress: (p) => options.onProgress(p.loaded),
|
||||
})
|
||||
} catch (error) {
|
||||
if (isAbortError(error)) throw error
|
||||
lastError = error
|
||||
options.onProgress(0)
|
||||
await new Promise((resolve) => setTimeout(resolve, 500 * (attempt + 1)))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
async function runPool<T>(items: T[], concurrency: number, worker: (item: T) => Promise<void>): Promise<void> {
|
||||
let cursor = 0
|
||||
const runners = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
||||
while (cursor < items.length) {
|
||||
const item = items[cursor++]
|
||||
await worker(item)
|
||||
}
|
||||
})
|
||||
await Promise.all(runners)
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads a draft object's bytes via S3 multipart: open session → presign parts
|
||||
* in batches → PUT each part (bounded concurrency, per-part retry) → complete.
|
||||
* On cancellation the registered cleanup aborts the multipart and the draft.
|
||||
*/
|
||||
export async function uploadFileInParts(objectId: string, file: File, ctx: UploadRunnerContext): Promise<void> {
|
||||
const session = await createObjectUploadSession(objectId, {})
|
||||
ctx.registerCleanup(async () => {
|
||||
await patchObjectUploadSession(objectId, session.id, { action: 'abort' }).catch(() => undefined)
|
||||
await cancelUpload(objectId).catch(() => undefined)
|
||||
})
|
||||
|
||||
const partSize = session.partSize
|
||||
const partCount = Math.max(1, Math.ceil(file.size / partSize))
|
||||
const completed: Array<{ partNumber: number; etag: string }> = []
|
||||
const loadedByPart = new Map<number, number>()
|
||||
|
||||
const reportProgress = () => {
|
||||
let loaded = 0
|
||||
for (const value of loadedByPart.values()) loaded += value
|
||||
ctx.onProgress({ loaded, total: file.size })
|
||||
}
|
||||
|
||||
for (let batchStart = 1; batchStart <= partCount; batchStart += PRESIGN_BATCH) {
|
||||
if (ctx.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
const partNumbers: number[] = []
|
||||
for (let n = batchStart; n < batchStart + PRESIGN_BATCH && n <= partCount; n++) partNumbers.push(n)
|
||||
|
||||
const { parts } = await presignObjectUploadParts(objectId, session.id, { partNumbers })
|
||||
await runPool(parts, PART_CONCURRENCY, async ({ partNumber, url }) => {
|
||||
const start = (partNumber - 1) * partSize
|
||||
const slice = file.slice(start, Math.min(start + partSize, file.size))
|
||||
const etag = await uploadPartWithRetry(url, slice, {
|
||||
signal: ctx.signal,
|
||||
onProgress: (loaded) => {
|
||||
loadedByPart.set(partNumber, loaded)
|
||||
reportProgress()
|
||||
},
|
||||
})
|
||||
loadedByPart.set(partNumber, slice.size)
|
||||
reportProgress()
|
||||
completed.push({ partNumber, etag })
|
||||
})
|
||||
}
|
||||
|
||||
completed.sort((a, b) => a.partNumber - b.partNumber)
|
||||
await patchObjectUploadSession(objectId, session.id, { action: 'complete', parts: completed })
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import type { Prompt } from '@/components/files/hooks/use-conflict-resolver'
|
||||
import { withConflictRetry } from '@/components/files/hooks/use-conflict-resolver'
|
||||
import { cancelUpload, confirmUpload, createObject, isNameConflictError, uploadToS3 } from '../../lib/api'
|
||||
import { MULTIPART_THRESHOLD, uploadFileInParts } from './multipart-upload'
|
||||
import { type UploadRunnerContext, useUploadQueue } from './upload-queue'
|
||||
|
||||
type DirectoryFile = File & {
|
||||
@@ -153,18 +154,24 @@ async function uploadFile(
|
||||
dirtype: DirType.FILE,
|
||||
})
|
||||
if (!created) return 'cancelled'
|
||||
if (!created.uploadUrl) throw new Error('No upload URL returned')
|
||||
ctx.registerCleanup(async () => {
|
||||
await cancelUpload(created.id)
|
||||
})
|
||||
if (ctx.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
|
||||
ctx.setStatus('uploading')
|
||||
await uploadToS3(created.uploadUrl, file, {
|
||||
onProgress: ctx.onProgress,
|
||||
signal: ctx.signal,
|
||||
contentDisposition: created.contentDisposition,
|
||||
})
|
||||
if (file.size > MULTIPART_THRESHOLD) {
|
||||
// Large files: chunked, resumable multipart. Single-PUT caps at 5 GiB and
|
||||
// fails the whole transfer on any network blip.
|
||||
await uploadFileInParts(created.id, file, ctx)
|
||||
} else {
|
||||
if (!created.uploadUrl) throw new Error('No upload URL returned')
|
||||
ctx.registerCleanup(async () => {
|
||||
await cancelUpload(created.id)
|
||||
})
|
||||
await uploadToS3(created.uploadUrl, file, {
|
||||
onProgress: ctx.onProgress,
|
||||
signal: ctx.signal,
|
||||
contentDisposition: created.contentDisposition,
|
||||
})
|
||||
}
|
||||
if (ctx.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
|
||||
|
||||
// Step 2: confirm. Another client may have activated the same name during our
|
||||
|
||||
@@ -30,6 +30,22 @@
|
||||
"auth.invitationMissing": "A valid invitation is required to create an account on this site.",
|
||||
"auth.invitationEmailLocked": "This invitation is bound to {{email}}.",
|
||||
"auth.captchaRequired": "Complete the captcha to continue.",
|
||||
"auth.forgotPassword": "Forgot password?",
|
||||
"auth.forgotPasswordSubtitle": "Enter your email and we'll send you a reset link.",
|
||||
"auth.sendResetLink": "Send reset link",
|
||||
"auth.sending": "Sending...",
|
||||
"auth.resetLinkSent": "If an account exists for that email, a reset link is on its way.",
|
||||
"auth.backToSignIn": "Back to sign in",
|
||||
"auth.resetPasswordTitle": "Reset password",
|
||||
"auth.resetPasswordSubtitle": "Choose a new password for your account.",
|
||||
"auth.newPassword": "New password",
|
||||
"auth.confirmPassword": "Confirm password",
|
||||
"auth.passwordsDoNotMatch": "Passwords do not match.",
|
||||
"auth.resetPassword": "Reset password",
|
||||
"auth.resetting": "Resetting...",
|
||||
"auth.resetSuccess": "Password reset. You can now sign in.",
|
||||
"auth.resetFailed": "Could not reset password. The link may have expired.",
|
||||
"auth.resetTokenMissing": "This reset link is invalid or has expired.",
|
||||
"nav.main": "Main",
|
||||
"nav.files": "My Files",
|
||||
"nav.shares": "Shares",
|
||||
@@ -63,7 +79,7 @@
|
||||
"files.transferFolderLabel": "Target folder",
|
||||
"files.transferConfirm": "Transfer",
|
||||
"files.transferCopyHint": "A copy will be created in the target space and count toward its quota. The two copies stay independent.",
|
||||
"files.transferMoveHint": "The original will be moved to this space's trash after a successful copy.",
|
||||
"files.transferMoveHint": "The original will be removed from this space and its quota freed after a successful copy.",
|
||||
"files.transferSuccessCopy": "Copied to the target space",
|
||||
"files.transferSuccessMove": "Moved to the target space",
|
||||
"files.transferSkipped": "{{count}} file(s) could not be transferred",
|
||||
@@ -1019,6 +1035,16 @@
|
||||
"notification.minutesAgo": "{{count}}m ago",
|
||||
"notification.hoursAgo": "{{count}}h ago",
|
||||
"notification.daysAgo": "{{count}}d ago",
|
||||
"notification.shareReceived.title": "{{creatorName}} shared \"{{matterName}}\" with you",
|
||||
"notification.shareReceived.body": "Click to open the share",
|
||||
"notification.action.extraction": "extraction",
|
||||
"notification.action.compression": "compression",
|
||||
"notification.archiveCompleted": "File {{action}} completed",
|
||||
"notification.archiveCompletedBody": "The background task is complete.",
|
||||
"notification.archiveFailed": "File {{action}} failed",
|
||||
"notification.archiveFailedBody": "The background task failed.",
|
||||
"notification.teamJoin.title": "You joined {{teamName}}",
|
||||
"notification.teamJoin.body": "You now have access to this team's space.",
|
||||
"announcement.title": "Site Announcements",
|
||||
"announcement.description": "Read current announcements from the site administrator.",
|
||||
"announcement.empty": "No announcements published yet",
|
||||
|
||||
@@ -30,6 +30,22 @@
|
||||
"auth.invitationMissing": "当前站点需要有效邀请才能注册。",
|
||||
"auth.invitationEmailLocked": "这个邀请仅限 {{email}} 使用。",
|
||||
"auth.captchaRequired": "请先完成人机验证。",
|
||||
"auth.forgotPassword": "忘记密码?",
|
||||
"auth.forgotPasswordSubtitle": "输入你的邮箱,我们会发送重置链接。",
|
||||
"auth.sendResetLink": "发送重置链接",
|
||||
"auth.sending": "发送中...",
|
||||
"auth.resetLinkSent": "如果该邮箱存在对应账户,重置链接已发出。",
|
||||
"auth.backToSignIn": "返回登录",
|
||||
"auth.resetPasswordTitle": "重置密码",
|
||||
"auth.resetPasswordSubtitle": "为你的账户设置新密码。",
|
||||
"auth.newPassword": "新密码",
|
||||
"auth.confirmPassword": "确认密码",
|
||||
"auth.passwordsDoNotMatch": "两次输入的密码不一致。",
|
||||
"auth.resetPassword": "重置密码",
|
||||
"auth.resetting": "重置中...",
|
||||
"auth.resetSuccess": "密码已重置,现在可以登录了。",
|
||||
"auth.resetFailed": "无法重置密码,链接可能已过期。",
|
||||
"auth.resetTokenMissing": "该重置链接无效或已过期。",
|
||||
"nav.main": "主要",
|
||||
"nav.files": "我的文件",
|
||||
"nav.shares": "我的分享",
|
||||
@@ -63,7 +79,7 @@
|
||||
"files.transferFolderLabel": "目标文件夹",
|
||||
"files.transferConfirm": "确认转移",
|
||||
"files.transferCopyHint": "将在目标空间创建副本并占用其配额,两份文件相互独立。",
|
||||
"files.transferMoveHint": "复制成功后,原件将移入当前空间的回收站。",
|
||||
"files.transferMoveHint": "复制成功后,原件将从当前空间移除并释放其配额。",
|
||||
"files.transferSuccessCopy": "已复制到目标空间",
|
||||
"files.transferSuccessMove": "已移动到目标空间",
|
||||
"files.transferSkipped": "{{count}} 个文件未能转移",
|
||||
@@ -1019,6 +1035,16 @@
|
||||
"notification.minutesAgo": "{{count}}分钟前",
|
||||
"notification.hoursAgo": "{{count}}小时前",
|
||||
"notification.daysAgo": "{{count}}天前",
|
||||
"notification.shareReceived.title": "{{creatorName}} 向你分享了 \"{{matterName}}\"",
|
||||
"notification.shareReceived.body": "点击打开分享",
|
||||
"notification.action.extraction": "解压",
|
||||
"notification.action.compression": "压缩",
|
||||
"notification.archiveCompleted": "文件{{action}}已完成",
|
||||
"notification.archiveCompletedBody": "后台任务已完成。",
|
||||
"notification.archiveFailed": "文件{{action}}失败",
|
||||
"notification.archiveFailedBody": "后台任务失败。",
|
||||
"notification.teamJoin.title": "你已加入 {{teamName}}",
|
||||
"notification.teamJoin.body": "你现在可以访问该团队的空间。",
|
||||
"announcement.title": "站点公告",
|
||||
"announcement.description": "查看站点管理员发布的当前公告。",
|
||||
"announcement.empty": "暂无已发布公告",
|
||||
|
||||
+274
-1
@@ -1,6 +1,7 @@
|
||||
// Tests for src/lib/api.ts — covers all public API helper functions
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
ApiError,
|
||||
batchDeleteUsers,
|
||||
batchUpdateUserStatus,
|
||||
buildShareObjectUrl,
|
||||
@@ -29,10 +30,12 @@ import {
|
||||
createStorage,
|
||||
createWebDavAppPassword,
|
||||
deleteAnnouncement,
|
||||
deleteAuthProvider,
|
||||
deleteAvatar,
|
||||
deleteDownloader,
|
||||
deleteIhostConfig,
|
||||
deleteIhostImage,
|
||||
deleteInviteCode,
|
||||
deleteObject,
|
||||
deleteShare,
|
||||
deleteStorage,
|
||||
@@ -42,6 +45,7 @@ import {
|
||||
downloadTaskEventsUrl,
|
||||
emptyTrash,
|
||||
enableIhostFeature,
|
||||
generateInviteCodes,
|
||||
getAnnouncement,
|
||||
getBackgroundJob,
|
||||
getBranding,
|
||||
@@ -63,9 +67,11 @@ import {
|
||||
getUserQuota,
|
||||
grantOrgEntitlement,
|
||||
grantUserEntitlement,
|
||||
isNameConflictError,
|
||||
listActiveAnnouncements,
|
||||
listAdminAnnouncements,
|
||||
listAdminAuditLogs,
|
||||
listAdminAuthProviders,
|
||||
listAnnouncements,
|
||||
listAuthProviders,
|
||||
listBackgroundJobs,
|
||||
@@ -78,8 +84,10 @@ import {
|
||||
listDownloadTasks,
|
||||
listIhostApiKeys,
|
||||
listIhostImages,
|
||||
listInviteCodes,
|
||||
listNotifications,
|
||||
listObjects,
|
||||
listObjectsByPath,
|
||||
listOrgEntitlements,
|
||||
listQuotas,
|
||||
listReceivedShares,
|
||||
@@ -89,6 +97,7 @@ import {
|
||||
listSiteInvitations,
|
||||
listStorages,
|
||||
listSystemOptions,
|
||||
listTeamActivities,
|
||||
listTeams,
|
||||
listUserEntitlements,
|
||||
listUsers,
|
||||
@@ -129,8 +138,10 @@ import {
|
||||
updateUserEntitlement,
|
||||
updateUserStatus,
|
||||
uploadAvatar,
|
||||
uploadPartToS3,
|
||||
uploadTeamLogo,
|
||||
uploadToS3,
|
||||
upsertAuthProvider,
|
||||
verifySharePassword,
|
||||
} from './api'
|
||||
|
||||
@@ -767,6 +778,104 @@ describe('api', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('uploadPartToS3', () => {
|
||||
class MockPartXHR {
|
||||
static instances: MockPartXHR[] = []
|
||||
upload = { onprogress: null as ((event: ProgressEvent) => void) | null }
|
||||
onload: (() => void) | null = null
|
||||
onerror: (() => void) | null = null
|
||||
onabort: (() => void) | null = null
|
||||
status = 200
|
||||
method = ''
|
||||
url = ''
|
||||
body: unknown
|
||||
responseHeaders: Record<string, string> = { ETag: '"etag-abc"' }
|
||||
|
||||
constructor() {
|
||||
MockPartXHR.instances.push(this)
|
||||
}
|
||||
open(method: string, url: string) {
|
||||
this.method = method
|
||||
this.url = url
|
||||
}
|
||||
getResponseHeader(key: string) {
|
||||
return this.responseHeaders[key] ?? null
|
||||
}
|
||||
send(body: unknown) {
|
||||
this.body = body
|
||||
}
|
||||
abort() {
|
||||
this.onabort?.()
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
MockPartXHR.instances = []
|
||||
vi.stubGlobal('XMLHttpRequest', MockPartXHR)
|
||||
})
|
||||
|
||||
it('PUTs the blob and resolves with the unquoted ETag', async () => {
|
||||
const blob = new Blob(['chunk'])
|
||||
const promise = uploadPartToS3('https://s3/part-1', blob)
|
||||
const xhr = MockPartXHR.instances[0]
|
||||
xhr.onload?.()
|
||||
|
||||
await expect(promise).resolves.toBe('etag-abc')
|
||||
expect(xhr.method).toBe('PUT')
|
||||
expect(xhr.url).toBe('https://s3/part-1')
|
||||
expect(xhr.body).toBe(blob)
|
||||
})
|
||||
|
||||
it('rejects when the ETag header is not exposed', async () => {
|
||||
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']))
|
||||
const xhr = MockPartXHR.instances[0]
|
||||
xhr.responseHeaders = {}
|
||||
xhr.onload?.()
|
||||
|
||||
await expect(promise).rejects.toThrow(/ETag/)
|
||||
})
|
||||
|
||||
it('rejects when the part upload fails', async () => {
|
||||
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']))
|
||||
const xhr = MockPartXHR.instances[0]
|
||||
xhr.status = 500
|
||||
xhr.onload?.()
|
||||
|
||||
await expect(promise).rejects.toThrow('Upload failed')
|
||||
})
|
||||
|
||||
it('rejects immediately when the signal is already aborted', async () => {
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']), { signal: controller.signal })
|
||||
|
||||
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(MockPartXHR.instances[0]?.body).toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects on a network error', async () => {
|
||||
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']))
|
||||
const xhr = MockPartXHR.instances[0]
|
||||
xhr.onerror?.()
|
||||
|
||||
await expect(promise).rejects.toThrow('Upload failed')
|
||||
})
|
||||
|
||||
it('reports progress and rejects on abort', async () => {
|
||||
const onProgress = vi.fn()
|
||||
const controller = new AbortController()
|
||||
const promise = uploadPartToS3('https://s3/part-1', new Blob(['x']), {
|
||||
onProgress,
|
||||
signal: controller.signal,
|
||||
})
|
||||
const xhr = MockPartXHR.instances[0]
|
||||
xhr.upload.onprogress?.({ loaded: 2, total: 8, lengthComputable: true } as ProgressEvent)
|
||||
expect(onProgress).toHaveBeenCalledWith({ loaded: 2, total: 8 })
|
||||
controller.abort()
|
||||
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('restoreObject', () => {
|
||||
it('sends PATCH with action: restore for the given id', async () => {
|
||||
const obj = { id: 'id1', status: 'active' }
|
||||
@@ -826,7 +935,11 @@ describe('api', () => {
|
||||
})
|
||||
|
||||
it('presigns upload session parts', async () => {
|
||||
const payload = { parts: [{ partNumber: 1, uploadUrl: 'https://s3/part-1' }] }
|
||||
const payload = {
|
||||
uploadId: 'mp-1',
|
||||
partSize: 5 * 1024 * 1024,
|
||||
parts: [{ partNumber: 1, url: 'https://s3/part-1' }],
|
||||
}
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload))
|
||||
|
||||
const result = await presignObjectUploadParts('obj-1', 'upload-1', { partNumbers: [1] })
|
||||
@@ -3646,4 +3759,164 @@ describe('api', () => {
|
||||
await expect(listAdminAuditLogs()).rejects.toMatchObject({ status: 402 })
|
||||
})
|
||||
})
|
||||
|
||||
describe('listObjectsByPath', () => {
|
||||
it('sends path and optional filters as query params', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 500 }))
|
||||
|
||||
await listObjectsByPath('a/b', 'trashed', 2, 50, { type: 'dir', search: 'doc', orgId: 'org-1' })
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/objects?')
|
||||
expect(url).toContain('path=a%2Fb')
|
||||
expect(url).toContain('status=trashed')
|
||||
expect(url).toContain('page=2')
|
||||
expect(url).toContain('pageSize=50')
|
||||
expect(url).toContain('type=dir')
|
||||
expect(url).toContain('search=doc')
|
||||
expect(url).toContain('orgId=org-1')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('omits absent optional filters', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 500 }))
|
||||
|
||||
await listObjectsByPath('root')
|
||||
|
||||
const [url] = vi.mocked(fetch).mock.calls[0] as [string]
|
||||
expect(url).not.toContain('type=')
|
||||
expect(url).not.toContain('search=')
|
||||
expect(url).not.toContain('orgId=')
|
||||
})
|
||||
|
||||
it('throws ApiError on failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
||||
|
||||
await expect(listObjectsByPath('root')).rejects.toThrow('forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('isNameConflictError', () => {
|
||||
it('returns true only for 409 NAME_CONFLICT ApiErrors', () => {
|
||||
const conflict = new ApiError(409, { code: 'NAME_CONFLICT', conflictingName: 'a', conflictingId: 'id1' })
|
||||
expect(isNameConflictError(conflict)).toBe(true)
|
||||
})
|
||||
|
||||
it('returns false for other ApiErrors and non-errors', () => {
|
||||
expect(isNameConflictError(new ApiError(409, { code: 'OTHER' }))).toBe(false)
|
||||
expect(isNameConflictError(new ApiError(404, { code: 'NAME_CONFLICT' }))).toBe(false)
|
||||
expect(isNameConflictError(new Error('nope'))).toBe(false)
|
||||
expect(isNameConflictError(null)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('admin auth providers api', () => {
|
||||
it('lists admin auth providers', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [] }))
|
||||
|
||||
await listAdminAuthProviders()
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/admin/auth-providers')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('upserts an auth provider', async () => {
|
||||
const data = { enabled: true, clientId: 'cid', clientSecret: 'secret' }
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ providerId: 'google', ...data }))
|
||||
|
||||
await upsertAuthProvider('google', data as never)
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/admin/auth-providers/google')
|
||||
expect(init.method).toBe('PUT')
|
||||
expect(init.body).toBe(JSON.stringify(data))
|
||||
})
|
||||
|
||||
it('deletes an auth provider', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ providerId: 'google', deleted: true }))
|
||||
|
||||
await deleteAuthProvider('google')
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/admin/auth-providers/google')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('throws ApiError on failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
||||
|
||||
await expect(listAdminAuthProviders()).rejects.toThrow('forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('invite codes api', () => {
|
||||
it('lists invite codes with pagination', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0 }))
|
||||
|
||||
await listInviteCodes(3, 25)
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/admin/invite-codes?')
|
||||
expect(url).toContain('page=3')
|
||||
expect(url).toContain('pageSize=25')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('generates invite codes with count only', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ codes: [] }))
|
||||
|
||||
await generateInviteCodes(5)
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/admin/invite-codes')
|
||||
expect(init.method).toBe('POST')
|
||||
expect(init.body).toBe(JSON.stringify({ count: 5 }))
|
||||
})
|
||||
|
||||
it('includes expiresInDays when provided', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ codes: [] }))
|
||||
|
||||
await generateInviteCodes(2, 7)
|
||||
|
||||
const [, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(init.body).toBe(JSON.stringify({ count: 2, expiresInDays: 7 }))
|
||||
})
|
||||
|
||||
it('deletes an invite code', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ id: 'code-1', deleted: true }))
|
||||
|
||||
await deleteInviteCode('code-1')
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toBe('/api/admin/invite-codes/code-1')
|
||||
expect(init.method).toBe('DELETE')
|
||||
})
|
||||
|
||||
it('throws ApiError on failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
||||
|
||||
await expect(generateInviteCodes(1)).rejects.toThrow('forbidden')
|
||||
})
|
||||
})
|
||||
|
||||
describe('listTeamActivities', () => {
|
||||
it('fetches team activity with pagination', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ items: [], total: 0, page: 1, pageSize: 20 }))
|
||||
|
||||
await listTeamActivities('team-1', 2, 15)
|
||||
|
||||
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
|
||||
expect(url).toContain('/api/teams/team-1/activity?')
|
||||
expect(url).toContain('page=2')
|
||||
expect(url).toContain('pageSize=15')
|
||||
expect(init.method).toBe('GET')
|
||||
})
|
||||
|
||||
it('throws ApiError on failure', async () => {
|
||||
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'forbidden' }, false, 403))
|
||||
|
||||
await expect(listTeamActivities('team-1')).rejects.toThrow('forbidden')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+55
-2
@@ -221,7 +221,7 @@ export function copyObject(id: string, parent: string, onConflict?: ConflictStra
|
||||
export interface TransferObjectResult {
|
||||
saved: StorageObject[]
|
||||
skipped: Array<{ name: string; reason: string }>
|
||||
sourceTrashed: boolean
|
||||
sourceDeleted: boolean
|
||||
}
|
||||
|
||||
export function transferObject(
|
||||
@@ -252,7 +252,7 @@ export function createObjectUploadSession(id: string, data: CreateObjectUploadSe
|
||||
}
|
||||
|
||||
export function presignObjectUploadParts(id: string, uploadSessionId: string, data: PresignObjectUploadPartsInput) {
|
||||
return unwrap<{ parts: Array<{ partNumber: number; uploadUrl: string }> }>(
|
||||
return unwrap<{ uploadId: string; partSize: number; parts: Array<{ partNumber: number; url: string }> }>(
|
||||
objects[':id'].uploads[':uploadSessionId'].parts.$post({
|
||||
param: { id, uploadSessionId },
|
||||
json: data,
|
||||
@@ -1240,6 +1240,59 @@ export function uploadToS3(url: string, file: File, options: UploadToS3Options =
|
||||
})
|
||||
}
|
||||
|
||||
export interface UploadPartOptions {
|
||||
onProgress?: (progress: UploadProgress) => void
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/**
|
||||
* PUTs a single multipart part (external presigned URL) and resolves with its
|
||||
* ETag, which the multipart-complete call needs. The S3 bucket's CORS config
|
||||
* must expose the ETag response header for this to be readable from the browser.
|
||||
*/
|
||||
export function uploadPartToS3(url: string, blob: Blob, options: UploadPartOptions = {}): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest()
|
||||
const abort = () => {
|
||||
xhr.abort()
|
||||
reject(new DOMException('Upload cancelled', 'AbortError'))
|
||||
}
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
reject(new DOMException('Upload cancelled', 'AbortError'))
|
||||
return
|
||||
}
|
||||
|
||||
options.signal?.addEventListener('abort', abort, { once: true })
|
||||
xhr.upload.onprogress = (event) => {
|
||||
options.onProgress?.({ loaded: event.loaded, total: event.lengthComputable ? event.total : blob.size })
|
||||
}
|
||||
xhr.onload = () => {
|
||||
options.signal?.removeEventListener('abort', abort)
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
const etag = xhr.getResponseHeader('ETag')
|
||||
if (!etag) {
|
||||
reject(new Error('Missing ETag — the storage bucket must expose the ETag header via CORS'))
|
||||
return
|
||||
}
|
||||
options.onProgress?.({ loaded: blob.size, total: blob.size })
|
||||
resolve(etag.replace(/"/g, ''))
|
||||
return
|
||||
}
|
||||
reject(new Error('Upload failed'))
|
||||
}
|
||||
xhr.onerror = () => {
|
||||
options.signal?.removeEventListener('abort', abort)
|
||||
reject(new Error('Upload failed'))
|
||||
}
|
||||
xhr.onabort = () => {
|
||||
options.signal?.removeEventListener('abort', abort)
|
||||
}
|
||||
xhr.open('PUT', url)
|
||||
xhr.send(blob)
|
||||
})
|
||||
}
|
||||
|
||||
// Image Host Images API
|
||||
|
||||
export type { ImageHosting }
|
||||
|
||||
@@ -9,7 +9,7 @@ export const authClient = createAuthClient({
|
||||
|
||||
import { clearSessionCache } from './api'
|
||||
|
||||
export const { signIn, signUp, useSession } = authClient
|
||||
export const { signIn, signUp, useSession, requestPasswordReset, resetPassword } = authClient
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: generic wrapping requires any
|
||||
function wrapAuthFunction<T extends (...args: any[]) => any>(fn: T): T {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { Notification } from '@shared/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { notificationContent } from './notification-content'
|
||||
|
||||
// Minimal i18n stub: echoes the key with interpolated params so assertions can
|
||||
// verify which key + params were chosen without depending on locale text.
|
||||
const t = ((key: string, params?: Record<string, unknown>) =>
|
||||
params ? `${key}:${JSON.stringify(params)}` : key) as never
|
||||
|
||||
function notif(overrides: Partial<Notification>): Notification {
|
||||
return {
|
||||
id: 'n1',
|
||||
userId: 'u1',
|
||||
type: 'share_received',
|
||||
title: 'STORED TITLE',
|
||||
body: 'STORED BODY',
|
||||
refType: null,
|
||||
refId: null,
|
||||
metadata: null,
|
||||
readAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('notificationContent', () => {
|
||||
it('localizes share_received from metadata', () => {
|
||||
const result = notificationContent(
|
||||
notif({ type: 'share_received', metadata: JSON.stringify({ creatorName: 'Ada', matterName: 'plan.pdf' }) }),
|
||||
t,
|
||||
)
|
||||
expect(result.title).toBe('notification.shareReceived.title:{"creatorName":"Ada","matterName":"plan.pdf"}')
|
||||
expect(result.body).toBe('notification.shareReceived.body')
|
||||
})
|
||||
|
||||
it('localizes archive completed/failed with the right action', () => {
|
||||
const completed = notificationContent(
|
||||
notif({ type: 'archive_job_completed', metadata: JSON.stringify({ jobType: 'archive_extract' }) }),
|
||||
t,
|
||||
)
|
||||
expect(completed.title).toBe('notification.archiveCompleted:{"action":"notification.action.extraction"}')
|
||||
|
||||
const failed = notificationContent(
|
||||
notif({
|
||||
type: 'archive_job_failed',
|
||||
body: 'disk full',
|
||||
metadata: JSON.stringify({ jobType: 'archive_compress' }),
|
||||
}),
|
||||
t,
|
||||
)
|
||||
expect(failed.title).toBe('notification.archiveFailed:{"action":"notification.action.compression"}')
|
||||
// Failed body keeps the stored error message when present.
|
||||
expect(failed.body).toBe('disk full')
|
||||
})
|
||||
|
||||
it('localizes team_join from metadata', () => {
|
||||
const result = notificationContent(notif({ type: 'team_join', metadata: JSON.stringify({ teamName: 'Acme' }) }), t)
|
||||
expect(result.title).toBe('notification.teamJoin.title:{"teamName":"Acme"}')
|
||||
})
|
||||
|
||||
it('falls back to stored title/body when metadata is missing', () => {
|
||||
const result = notificationContent(notif({ type: 'share_received', metadata: null }), t)
|
||||
expect(result).toEqual({ title: 'STORED TITLE', body: 'STORED BODY' })
|
||||
})
|
||||
|
||||
it('falls back to stored strings on malformed metadata', () => {
|
||||
const result = notificationContent(notif({ type: 'team_join', metadata: '{not json' }), t)
|
||||
expect(result).toEqual({ title: 'STORED TITLE', body: 'STORED BODY' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Notification } from '@shared/types'
|
||||
import type { TFunction } from 'i18next'
|
||||
|
||||
function parseMeta(notification: Notification): Record<string, unknown> {
|
||||
if (!notification.metadata) return {}
|
||||
try {
|
||||
return JSON.parse(notification.metadata) as Record<string, unknown>
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function archiveAction(meta: Record<string, unknown>, t: TFunction): string {
|
||||
return t(meta.jobType === 'archive_extract' ? 'notification.action.extraction' : 'notification.action.compression')
|
||||
}
|
||||
|
||||
/**
|
||||
* Localizes a notification for display. Server-stored title/body are English
|
||||
* fallbacks; when the type and metadata are known we render from i18n instead,
|
||||
* so notifications respect the user's language. Unknown types fall back to the
|
||||
* stored strings (keeps older notifications working).
|
||||
*/
|
||||
export function notificationContent(notification: Notification, t: TFunction): { title: string; body: string } {
|
||||
const meta = parseMeta(notification)
|
||||
switch (notification.type) {
|
||||
case 'share_received':
|
||||
if (typeof meta.creatorName === 'string' && typeof meta.matterName === 'string') {
|
||||
return {
|
||||
title: t('notification.shareReceived.title', { creatorName: meta.creatorName, matterName: meta.matterName }),
|
||||
body: t('notification.shareReceived.body'),
|
||||
}
|
||||
}
|
||||
break
|
||||
case 'archive_job_completed':
|
||||
return {
|
||||
title: t('notification.archiveCompleted', { action: archiveAction(meta, t) }),
|
||||
body: t('notification.archiveCompletedBody'),
|
||||
}
|
||||
case 'archive_job_failed':
|
||||
return {
|
||||
title: t('notification.archiveFailed', { action: archiveAction(meta, t) }),
|
||||
body: notification.body || t('notification.archiveFailedBody'),
|
||||
}
|
||||
case 'team_join':
|
||||
if (typeof meta.teamName === 'string') {
|
||||
return {
|
||||
title: t('notification.teamJoin.title', { teamName: meta.teamName }),
|
||||
body: t('notification.teamJoin.body'),
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
return { title: notification.title, body: notification.body }
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { Route as STokenRouteImport } from './routes/s/$token'
|
||||
import { Route as AuthenticatedStorageRouteImport } from './routes/_authenticated/storage'
|
||||
import { Route as authSignUpRouteImport } from './routes/(auth)/sign-up'
|
||||
import { Route as authSignInRouteImport } from './routes/(auth)/sign-in'
|
||||
import { Route as authResetPasswordRouteImport } from './routes/(auth)/reset-password'
|
||||
import { Route as authForgotPasswordRouteImport } from './routes/(auth)/forgot-password'
|
||||
import { Route as AuthenticatedSettingsRouteRouteImport } from './routes/_authenticated/settings/route'
|
||||
import { Route as AuthenticatedAdminRouteRouteImport } from './routes/_authenticated/admin/route'
|
||||
import { Route as AuthenticatedUsersIndexRouteImport } from './routes/_authenticated/users/index'
|
||||
@@ -106,6 +108,16 @@ const authSignInRoute = authSignInRouteImport.update({
|
||||
path: '/sign-in',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authResetPasswordRoute = authResetPasswordRouteImport.update({
|
||||
id: '/(auth)/reset-password',
|
||||
path: '/reset-password',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const authForgotPasswordRoute = authForgotPasswordRouteImport.update({
|
||||
id: '/(auth)/forgot-password',
|
||||
path: '/forgot-password',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AuthenticatedSettingsRouteRoute =
|
||||
AuthenticatedSettingsRouteRouteImport.update({
|
||||
id: '/settings',
|
||||
@@ -326,6 +338,8 @@ export interface FileRoutesByFullPath {
|
||||
'/device': typeof DeviceRoute
|
||||
'/admin': typeof AuthenticatedAdminRouteRouteWithChildren
|
||||
'/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
|
||||
'/forgot-password': typeof authForgotPasswordRoute
|
||||
'/reset-password': typeof authResetPasswordRoute
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/storage': typeof AuthenticatedStorageRoute
|
||||
@@ -371,6 +385,8 @@ export interface FileRoutesByFullPath {
|
||||
export interface FileRoutesByTo {
|
||||
'/s': typeof SRouteRouteWithChildren
|
||||
'/device': typeof DeviceRoute
|
||||
'/forgot-password': typeof authForgotPasswordRoute
|
||||
'/reset-password': typeof authResetPasswordRoute
|
||||
'/sign-in': typeof authSignInRoute
|
||||
'/sign-up': typeof authSignUpRoute
|
||||
'/storage': typeof AuthenticatedStorageRoute
|
||||
@@ -420,6 +436,8 @@ export interface FileRoutesById {
|
||||
'/device': typeof DeviceRoute
|
||||
'/_authenticated/admin': typeof AuthenticatedAdminRouteRouteWithChildren
|
||||
'/_authenticated/settings': typeof AuthenticatedSettingsRouteRouteWithChildren
|
||||
'/(auth)/forgot-password': typeof authForgotPasswordRoute
|
||||
'/(auth)/reset-password': typeof authResetPasswordRoute
|
||||
'/(auth)/sign-in': typeof authSignInRoute
|
||||
'/(auth)/sign-up': typeof authSignUpRoute
|
||||
'/_authenticated/storage': typeof AuthenticatedStorageRoute
|
||||
@@ -471,6 +489,8 @@ export interface FileRouteTypes {
|
||||
| '/device'
|
||||
| '/admin'
|
||||
| '/settings'
|
||||
| '/forgot-password'
|
||||
| '/reset-password'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/storage'
|
||||
@@ -516,6 +536,8 @@ export interface FileRouteTypes {
|
||||
to:
|
||||
| '/s'
|
||||
| '/device'
|
||||
| '/forgot-password'
|
||||
| '/reset-password'
|
||||
| '/sign-in'
|
||||
| '/sign-up'
|
||||
| '/storage'
|
||||
@@ -564,6 +586,8 @@ export interface FileRouteTypes {
|
||||
| '/device'
|
||||
| '/_authenticated/admin'
|
||||
| '/_authenticated/settings'
|
||||
| '/(auth)/forgot-password'
|
||||
| '/(auth)/reset-password'
|
||||
| '/(auth)/sign-in'
|
||||
| '/(auth)/sign-up'
|
||||
| '/_authenticated/storage'
|
||||
@@ -612,6 +636,8 @@ export interface RootRouteChildren {
|
||||
AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren
|
||||
SRouteRoute: typeof SRouteRouteWithChildren
|
||||
DeviceRoute: typeof DeviceRoute
|
||||
authForgotPasswordRoute: typeof authForgotPasswordRoute
|
||||
authResetPasswordRoute: typeof authResetPasswordRoute
|
||||
authSignInRoute: typeof authSignInRoute
|
||||
authSignUpRoute: typeof authSignUpRoute
|
||||
StoreCheckoutRoute: typeof StoreCheckoutRoute
|
||||
@@ -690,6 +716,20 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof authSignInRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/reset-password': {
|
||||
id: '/(auth)/reset-password'
|
||||
path: '/reset-password'
|
||||
fullPath: '/reset-password'
|
||||
preLoaderRoute: typeof authResetPasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/(auth)/forgot-password': {
|
||||
id: '/(auth)/forgot-password'
|
||||
path: '/forgot-password'
|
||||
fullPath: '/forgot-password'
|
||||
preLoaderRoute: typeof authForgotPasswordRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/_authenticated/settings': {
|
||||
id: '/_authenticated/settings'
|
||||
path: '/settings'
|
||||
@@ -1093,6 +1133,8 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren,
|
||||
SRouteRoute: SRouteRouteWithChildren,
|
||||
DeviceRoute: DeviceRoute,
|
||||
authForgotPasswordRoute: authForgotPasswordRoute,
|
||||
authResetPasswordRoute: authResetPasswordRoute,
|
||||
authSignInRoute: authSignInRoute,
|
||||
authSignUpRoute: authSignUpRoute,
|
||||
StoreCheckoutRoute: StoreCheckoutRoute,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { DEFAULT_SITE_NAME } from '@shared/constants'
|
||||
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useSiteOptions } from '@/hooks/use-site-options'
|
||||
import { requestPasswordReset } from '@/lib/auth-client'
|
||||
|
||||
export const Route = createFileRoute('/(auth)/forgot-password')({
|
||||
component: ForgotPassword,
|
||||
})
|
||||
|
||||
function ForgotPassword() {
|
||||
const { t } = useTranslation()
|
||||
const { siteName } = useSiteOptions()
|
||||
const [email, setEmail] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [sent, setSent] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
try {
|
||||
// Always report success regardless of outcome — do not reveal whether
|
||||
// an account exists for the address.
|
||||
await requestPasswordReset({ email, redirectTo: '/reset-password' })
|
||||
setSent(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="w-full max-w-sm space-y-6 p-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-2xl font-bold">{siteName || DEFAULT_SITE_NAME}</h1>
|
||||
<p className="text-muted-foreground">{t('auth.forgotPasswordSubtitle')}</p>
|
||||
</div>
|
||||
{sent ? (
|
||||
<p className="rounded-md bg-muted p-4 text-center text-sm text-muted-foreground">{t('auth.resetLinkSent')}</p>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">{t('auth.email')}</Label>
|
||||
<Input id="email" type="email" value={email} onChange={(e) => setEmail(e.target.value)} required />
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? t('auth.sending') : t('auth.sendResetLink')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link to="/sign-in" className="underline hover:text-foreground">
|
||||
{t('auth.backToSignIn')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { DEFAULT_SITE_NAME } from '@shared/constants'
|
||||
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { useSiteOptions } from '@/hooks/use-site-options'
|
||||
import { resetPassword } from '@/lib/auth-client'
|
||||
|
||||
export const Route = createFileRoute('/(auth)/reset-password')({
|
||||
component: ResetPassword,
|
||||
})
|
||||
|
||||
function ResetPassword() {
|
||||
const { t } = useTranslation()
|
||||
const navigate = useNavigate()
|
||||
const { siteName } = useSiteOptions()
|
||||
const token = new URLSearchParams(window.location.search).get('token')
|
||||
const [password, setPassword] = useState('')
|
||||
const [confirm, setConfirm] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
if (!token) {
|
||||
setError(t('auth.resetTokenMissing'))
|
||||
return
|
||||
}
|
||||
if (password !== confirm) {
|
||||
setError(t('auth.passwordsDoNotMatch'))
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await resetPassword({ newPassword: password, token })
|
||||
if (result.error) {
|
||||
setError(result.error.message ?? t('auth.resetFailed'))
|
||||
return
|
||||
}
|
||||
navigate({ to: '/sign-in' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="w-full max-w-sm space-y-6 p-6">
|
||||
<div className="space-y-2 text-center">
|
||||
<h1 className="text-2xl font-bold">{siteName || DEFAULT_SITE_NAME}</h1>
|
||||
<p className="text-muted-foreground">{t('auth.resetPasswordSubtitle')}</p>
|
||||
</div>
|
||||
{token ? (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="new-password">{t('auth.newPassword')}</Label>
|
||||
<Input
|
||||
id="new-password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="confirm-password">{t('auth.confirmPassword')}</Label>
|
||||
<Input
|
||||
id="confirm-password"
|
||||
type="password"
|
||||
value={confirm}
|
||||
onChange={(e) => setConfirm(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? t('auth.resetting') : t('auth.resetPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<p className="text-center text-sm text-destructive">{t('auth.resetTokenMissing')}</p>
|
||||
)}
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
<Link to="/sign-in" className="underline hover:text-foreground">
|
||||
{t('auth.backToSignIn')}
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -104,7 +104,12 @@ function SignIn() {
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="password">{t('auth.password')}</Label>
|
||||
<Link to="/forgot-password" className="text-xs text-muted-foreground underline hover:text-foreground">
|
||||
{t('auth.forgotPassword')}
|
||||
</Link>
|
||||
</div>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
|
||||
@@ -6,6 +6,7 @@ import { resetExpiredTrafficQuotas } from '../server/services/effective-quota'
|
||||
import { INSTANCE_TELEMETRY_CRON, reportInstanceTelemetry } from '../server/services/instance-telemetry'
|
||||
import { runLicensingRefresh } from '../server/services/licensing-refresh-runner'
|
||||
import { syncPendingRemoteDownloadUsageReports } from '../server/services/remote-download-usage'
|
||||
import { purgeExpiredTrash, resolveTrashRetentionDays } from '../server/services/trash-retention'
|
||||
import { ZPAN_CLOUD_URL_DEFAULT } from '../shared/constants'
|
||||
|
||||
// Subset of the worker Env used by the scheduled handler.
|
||||
@@ -14,11 +15,13 @@ export interface ScheduledEnv {
|
||||
DB: D1Database
|
||||
ZPAN_CLOUD_URL?: string
|
||||
ZPAN_TELEMETRY_ALLOW_IP?: string
|
||||
ZPAN_TRASH_RETENTION_DAYS?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const TRAFFIC_SYNC_CRON = '*/10 * * * *'
|
||||
const QUOTA_RESET_CRON = '0 0 1 * *'
|
||||
const TRASH_PURGE_CRON = '0 4 * * *'
|
||||
type ScheduledTrigger = Pick<ScheduledEvent, 'cron'>
|
||||
|
||||
function envAllowsIp(value: string | undefined): boolean {
|
||||
@@ -39,6 +42,11 @@ export async function handleScheduled(event: ScheduledTrigger, env: ScheduledEnv
|
||||
return
|
||||
}
|
||||
|
||||
if (event.cron === TRASH_PURGE_CRON) {
|
||||
await purgeExpiredTrash(platform.db, resolveTrashRetentionDays(env.ZPAN_TRASH_RETENTION_DAYS))
|
||||
return
|
||||
}
|
||||
|
||||
if (event.cron === INSTANCE_TELEMETRY_CRON) {
|
||||
await reportInstanceTelemetry({
|
||||
db: platform.db,
|
||||
|
||||
+1
-1
@@ -38,7 +38,7 @@ max_retries = 3
|
||||
enabled = true
|
||||
|
||||
[triggers]
|
||||
crons = ["*/10 * * * *", "0 */6 * * *", "0 */12 * * *", "0 0 1 * *"]
|
||||
crons = ["*/10 * * * *", "0 */6 * * *", "0 */12 * * *", "0 0 1 * *", "0 4 * * *"]
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Staging environment — used by non-production branch builds.
|
||||
|
||||
Reference in New Issue
Block a user