dfssdfsfdsf
This commit is contained in:
2026-04-09 21:00:04 +02:00
parent 983b67e6fc
commit 89bc86b615
27 changed files with 2921 additions and 408 deletions

View File

@@ -1,29 +1,92 @@
import { getApiJwt } from './appwrite'
/**
* Replace every `/api/api` in the path with `/api` until stable (avoids Express catch-all 404).
* Call on every outgoing API URL before `fetch`.
*/
export function collapseDoubleApi(url: string): string {
if (!url) return url
if (/^https?:\/\//i.test(url)) {
try {
const u = new URL(url)
let p = u.pathname
let prev = ''
while (p !== prev && p.includes('/api/api')) {
prev = p
p = p.replace(/\/api\/api/g, '/api')
}
// z. B. //api → /api (sonst 404 auf dem Server)
p = p.replace(/\/+/g, '/')
if (p !== '/' && !p.startsWith('/')) {
p = `/${p}`
}
u.pathname = p
return u.toString()
} catch {
return url
}
}
let s = url
let prev = ''
while (s !== prev && s.includes('/api/api')) {
prev = s
s = s.replace(/\/api\/api/g, '/api')
}
s = s.replace(/\/+/g, '/')
return s
}
/**
* `import.meta.env.VITE_DEV_BACKEND_ORIGIN` (from Vite define) must be origin ONLY, e.g. `http://127.0.0.1:3030`.
* Do NOT set a trailing `/api` in .env / .env.local (`VITE_DEV_API_ORIGIN`) — the client appends `/api` once.
*/
function stripTrailingApiFromOrigin(origin: string): string {
let o = origin.replace(/\/+$/, '')
while (o.endsWith('/api')) {
o = o.slice(0, -4).replace(/\/+$/, '')
}
return o
}
/**
* Endpoints in this file are paths like `/subscription/status`.
* Express mounts the API under `/api`, so the base must end with `/api`.
* If VITE_API_URL is `http://localhost:3000` (missing /api), requests would 404.
*/
function resolveApiBase(): string {
const raw = (import.meta.env.VITE_API_URL as string | undefined)?.trim()
if (!raw) return '/api'
/** Empty / unset → use Vite define (VITE_DEV_BACKEND_ORIGIN) or relative /api */
const hasExplicitViteApiUrl = Boolean(raw)
if (import.meta.env.DEV && !hasExplicitViteApiUrl) {
const origin = (
import.meta.env as { VITE_DEV_BACKEND_ORIGIN?: string }
).VITE_DEV_BACKEND_ORIGIN?.trim()
if (origin) {
const o = stripTrailingApiFromOrigin(origin).replace(/\/+$/, '')
const withApi = `${o}/api`
return collapseDoubleApi(withApi)
}
}
if (!hasExplicitViteApiUrl) return collapseDoubleApi('/api')
if (!raw) return collapseDoubleApi('/api')
if (raw.startsWith('/')) {
const p = raw.replace(/\/+$/, '') || '/api'
return p
return collapseDoubleApi(p)
}
if (!/^https?:\/\//i.test(raw)) {
const p = raw.replace(/^\/+/, '').replace(/\/+$/, '') || 'api'
return p.startsWith('api') ? `/${p}` : `/api`
const rel = p.startsWith('api') ? `/${p}` : `/api`
return collapseDoubleApi(rel)
}
const normalized = raw.replace(/\/+$/, '')
const normalized = collapseDoubleApi(raw.replace(/\/+$/, '').trim())
try {
const u = new URL(normalized)
const path = u.pathname.replace(/\/$/, '') || '/'
// Same host as Vite + /v1 = Appwrite-Proxy; never append /api (would hit /v1/api/… → 404)
const path = u.pathname.replace(/\/+/g, '/').replace(/\/$/, '') || '/'
const localVite =
/^(localhost|127\.0\.0\.1)$/i.test(u.hostname) &&
(u.port === '5173' || (u.port === '' && u.hostname === 'localhost'))
@@ -31,31 +94,40 @@ function resolveApiBase(): string {
return '/api'
}
if (path === '/' || path === '') {
return `${normalized}/api`
const originOnly = normalized.replace(/\/+$/, '')
return collapseDoubleApi(`${originOnly}/api`)
}
if (path.endsWith('/api')) {
return normalized
return collapseDoubleApi(normalized)
}
return `${normalized}/api`
const originOnly = normalized.replace(/\/+$/, '')
return collapseDoubleApi(`${originOnly}/api`)
} catch {
return '/api'
}
}
const API_BASE = resolveApiBase()
export const API_BASE = collapseDoubleApi(resolveApiBase())
/** Root-relative or absolute API URL; avoids `api/foo` (relative to current route). */
/** Join API base (ends with `/api`) and endpoint (`/email/...`). If endpoint starts with `/api`, strip it once so we never produce `/api/api`. */
function joinApiUrl(base: string, endpoint: string): string {
const ep = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
let ep = endpoint.startsWith('/') ? endpoint : `/${endpoint}`
if (ep === '/api' || ep.startsWith('/api/')) {
ep = ep === '/api' ? '/' : ep.slice(4)
if (ep !== '' && !ep.startsWith('/')) {
ep = `/${ep}`
}
}
if (/^https?:\/\//i.test(base)) {
return `${base.replace(/\/+$/, '')}${ep}`
const joined = `${base.replace(/\/+$/, '')}${ep}`
return collapseDoubleApi(joined)
}
let b = base.trim()
if (!b.startsWith('/')) {
b = `/${b.replace(/^\/+/, '')}`
}
b = b.replace(/\/+$/, '') || '/api'
return `${b}${ep}`
return collapseDoubleApi(`${b}${ep}`)
}
interface ApiResponse<T> {
@@ -86,7 +158,7 @@ async function fetchApi<T>(
headers['Authorization'] = `Bearer ${jwt}`
}
const urlJoined = joinApiUrl(API_BASE, endpoint)
const urlJoined = collapseDoubleApi(joinApiUrl(API_BASE, endpoint))
const response = await fetch(urlJoined, {
...options,
@@ -100,12 +172,16 @@ async function fetchApi<T>(
: { success: false as const, error: undefined }
if (!isJson) {
const devHint =
import.meta.env.DEV && response.status === 404
? ` API_BASE=${API_BASE}`
: ''
return {
error: {
code: response.status === 404 ? 'NOT_FOUND' : 'INVALID_RESPONSE',
message:
response.status === 404
? 'API 404: backend unreachable or wrong port — check server is running and VITE_DEV_API_ORIGIN matches PORT.'
? `API 404: Backend antwortet nicht (falscher Port oder alter Prozess). Server starten: cd server && npm run start. PORT wie in server/.env.${devHint}`
: `Expected JSON, got ${ct || 'unknown'} (HTTP ${response.status})`,
},
}
@@ -122,10 +198,15 @@ async function fetchApi<T>(
return { success: true, data: data.data ?? data }
} catch (error) {
const base = error instanceof Error ? error.message : 'Network error'
const devHint =
import.meta.env.DEV
? ` API_BASE=${API_BASE} — Ist der Server an diesem Port gestartet? (cd server && npm run start)`
: ''
return {
error: {
code: 'NETWORK_ERROR',
message: error instanceof Error ? error.message : 'Network error',
message: `${base}${devHint}`,
},
}
}
@@ -217,6 +298,30 @@ export const api = {
})
},
async recoverEmails(accountId: string) {
return fetchApi<{
recovered: number
folders: Array<{ folder: string; count: number }>
message: string
}>(`/email/recover/${accountId}`, {
method: 'POST',
body: JSON.stringify({}),
})
},
/** Move mail from sort-related folders (Junk, Archive, MailFlow/…) back to INBOX and strip $MailFlow-* keywords (IMAP only). */
async reSortEmails(accountId: string) {
return fetchApi<{
recovered: number
folders: Array<{ folder: string; count: number }>
mailFlowKeywordsStripped: number
message: string
}>(`/email/re-sort/${accountId}`, {
method: 'POST',
body: JSON.stringify({}),
})
},
async sortDemo(count: number = 10) {
return fetchApi<{
sorted: number
@@ -514,6 +619,17 @@ export const api = {
return fetchApi<{ isAdmin: boolean }>('/me')
},
async resetSortData(email: string) {
return fetchApi<{
reset: boolean
deleted?: { stats: number; digests: number; usage: number }
imapCleared?: number
}>('/admin/reset-user-sort-data', {
method: 'POST',
body: JSON.stringify({ email }),
})
},
// ═══════════════════════════════════════════════════════════════════════════
// NAME LABELS (Workers Admin only)
// ═══════════════════════════════════════════════════════════════════════════