Compare commits
1 Commits
chore/reve
...
bugfix/man
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3495190854 |
@@ -51,8 +51,3 @@ MAX_FILE_BYTES=20971520
|
|||||||
# internal docker network. Override only if you're running the
|
# internal docker network. Override only if you're running the
|
||||||
# frontend container against a backend somewhere else.
|
# frontend container against a backend somewhere else.
|
||||||
BACKEND_URL=http://backend:8080
|
BACKEND_URL=http://backend:8080
|
||||||
# Per-request wall-clock cap for the /api/* reverse proxy (milliseconds).
|
|
||||||
# Default 300000 (5 min) covers a typical 200 MiB chapter upload over
|
|
||||||
# 25 Mbps; raise for users on slower upstream links or lower if a
|
|
||||||
# tighter front proxy already bounds the request lifetime.
|
|
||||||
BACKEND_PROXY_TIMEOUT_MS=300000
|
|
||||||
|
|||||||
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.34.0"
|
version = "0.34.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.34.0"
|
version = "0.34.1"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -196,16 +196,14 @@ async fn create(
|
|||||||
|
|
||||||
async fn update(
|
async fn update(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(patch): Json<MangaPatch>,
|
Json(patch): Json<MangaPatch>,
|
||||||
) -> AppResult<Json<MangaDetail>> {
|
) -> AppResult<Json<MangaDetail>> {
|
||||||
// TODO(auth): until uploaders are tracked (Phase 5), any signed-in
|
|
||||||
// user can edit any manga. Restrict to uploader + admin once that
|
|
||||||
// column lands.
|
|
||||||
if !repo::manga::exists(&state.db, id).await? {
|
if !repo::manga::exists(&state.db, id).await? {
|
||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
}
|
}
|
||||||
|
require_can_edit(&state, id, user.id).await?;
|
||||||
|
|
||||||
if let Some(ref status) = patch.status {
|
if let Some(ref status) = patch.status {
|
||||||
let trimmed = status.trim();
|
let trimmed = status.trim();
|
||||||
@@ -269,16 +267,14 @@ async fn update(
|
|||||||
/// `MangaDetail`.
|
/// `MangaDetail`.
|
||||||
async fn put_cover(
|
async fn put_cover(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
mut multipart: Multipart,
|
mut multipart: Multipart,
|
||||||
) -> AppResult<Json<MangaDetail>> {
|
) -> AppResult<Json<MangaDetail>> {
|
||||||
// TODO(auth): until uploaders are tracked (Phase 5), any signed-in
|
|
||||||
// user can edit any manga's cover. Restrict to uploader + admin
|
|
||||||
// once that column lands.
|
|
||||||
if !repo::manga::exists(&state.db, id).await? {
|
if !repo::manga::exists(&state.db, id).await? {
|
||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
}
|
}
|
||||||
|
require_can_edit(&state, id, user.id).await?;
|
||||||
|
|
||||||
let mut cover: Option<UploadedImage> = None;
|
let mut cover: Option<UploadedImage> = None;
|
||||||
while let Some(field) = next_field(&mut multipart).await? {
|
while let Some(field) = next_field(&mut multipart).await? {
|
||||||
@@ -320,13 +316,13 @@ async fn put_cover(
|
|||||||
/// with the unchanged detail.
|
/// with the unchanged detail.
|
||||||
async fn delete_cover(
|
async fn delete_cover(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
CurrentUser(_user): CurrentUser,
|
CurrentUser(user): CurrentUser,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> AppResult<Json<MangaDetail>> {
|
) -> AppResult<Json<MangaDetail>> {
|
||||||
// TODO(auth): same caveat as put_cover.
|
|
||||||
if !repo::manga::exists(&state.db, id).await? {
|
if !repo::manga::exists(&state.db, id).await? {
|
||||||
return Err(AppError::NotFound);
|
return Err(AppError::NotFound);
|
||||||
}
|
}
|
||||||
|
require_can_edit(&state, id, user.id).await?;
|
||||||
if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path {
|
if let Some(key) = repo::manga::get(&state.db, id).await?.cover_image_path {
|
||||||
match state.storage.delete(&key).await {
|
match state.storage.delete(&key).await {
|
||||||
Ok(()) | Err(StorageError::NotFound) => {}
|
Ok(()) | Err(StorageError::NotFound) => {}
|
||||||
@@ -413,6 +409,30 @@ fn validate_new_manga(input: &NewManga) -> AppResult<()> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authorisation gate for manga mutations. The manga is assumed to
|
||||||
|
/// exist (the caller runs [`repo::manga::exists`] first so a missing id
|
||||||
|
/// surfaces as `NotFound`, not `Forbidden`).
|
||||||
|
///
|
||||||
|
/// Rule: a non-NULL `uploaded_by` must match the current user. Legacy
|
||||||
|
/// rows with `uploaded_by IS NULL` (pre-migration-0011) are still
|
||||||
|
/// editable by any signed-in user — there's nobody to gate on yet, and
|
||||||
|
/// the historical-data note in 0011 acknowledges the gap. Once an
|
||||||
|
/// admin role lands the NULL case can flip to admin-only.
|
||||||
|
///
|
||||||
|
/// Returns `Forbidden` (not `NotFound`) on owner mismatch — mangas
|
||||||
|
/// are listable via `GET /mangas`, so existence isn't a secret and
|
||||||
|
/// the more accurate 403 is fine. This deliberately differs from
|
||||||
|
/// `repo::collection::require_owner`, which collapses both states to
|
||||||
|
/// `NotFound` because collections are private to a user and existence
|
||||||
|
/// itself is information worth hiding from non-owners.
|
||||||
|
async fn require_can_edit(state: &AppState, manga_id: Uuid, user_id: Uuid) -> AppResult<()> {
|
||||||
|
match repo::manga::uploaded_by(&state.db, manga_id).await? {
|
||||||
|
Some(owner) if owner != user_id => Err(AppError::Forbidden),
|
||||||
|
// Some(owner) == user_id (good) or None (legacy row, no owner).
|
||||||
|
_ => Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn validate_genre_ids(state: &AppState, ids: &[Uuid]) -> AppResult<()> {
|
async fn validate_genre_ids(state: &AppState, ids: &[Uuid]) -> AppResult<()> {
|
||||||
if ids.is_empty() {
|
if ids.is_empty() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -281,3 +281,17 @@ pub async fn exists(pool: &PgPool, id: Uuid) -> AppResult<bool> {
|
|||||||
.await?;
|
.await?;
|
||||||
Ok(exists)
|
Ok(exists)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Returns the uploader's user id for a manga. `None` either when the
|
||||||
|
/// manga doesn't exist or when the row predates the `uploaded_by`
|
||||||
|
/// column (historical NULL — see migration 0011). Callers must
|
||||||
|
/// distinguish "manga missing" via [`exists`] before relying on this
|
||||||
|
/// to make an authz decision.
|
||||||
|
pub async fn uploaded_by(pool: &PgPool, id: Uuid) -> AppResult<Option<Uuid>> {
|
||||||
|
let row: Option<(Option<Uuid>,)> =
|
||||||
|
sqlx::query_as("SELECT uploaded_by FROM mangas WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.and_then(|(u,)| u))
|
||||||
|
}
|
||||||
|
|||||||
@@ -410,3 +410,53 @@ async fn delete_cover_404_on_unknown_id(pool: PgPool) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Authz: PUT /mangas/:id/cover must be uploader-only.
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn put_cover_forbidden_for_non_uploader(pool: PgPool) {
|
||||||
|
let h = harness(pool);
|
||||||
|
let (_, owner_cookie) = register_user(&h.app).await;
|
||||||
|
let (_, intruder_cookie) = register_user(&h.app).await;
|
||||||
|
|
||||||
|
let manga =
|
||||||
|
create_manga_with_cover(&h.app, &owner_cookie, "Mine", None).await;
|
||||||
|
let id = id_of(&manga);
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(put_multipart_with_cookie(
|
||||||
|
&format!("/api/v1/mangas/{id}/cover"),
|
||||||
|
cover_form(&fake_png_bytes()),
|
||||||
|
&intruder_cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Authz: DELETE /mangas/:id/cover must be uploader-only.
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn delete_cover_forbidden_for_non_uploader(pool: PgPool) {
|
||||||
|
let h = harness(pool);
|
||||||
|
let (_, owner_cookie) = register_user(&h.app).await;
|
||||||
|
let (_, intruder_cookie) = register_user(&h.app).await;
|
||||||
|
|
||||||
|
let manga = create_manga_with_cover(
|
||||||
|
&h.app,
|
||||||
|
&owner_cookie,
|
||||||
|
"Mine",
|
||||||
|
Some(("image/jpeg", &fake_jpeg_bytes())),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let id = id_of(&manga);
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(delete_with_cookie(
|
||||||
|
&format!("/api/v1/mangas/{id}/cover"),
|
||||||
|
&intruder_cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|||||||
@@ -566,3 +566,78 @@ async fn patch_requires_authentication(pool: PgPool) {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A signed-in user who didn't upload the manga must not be able to
|
||||||
|
/// PATCH it. Without the uploader-gate this returned 200 — see
|
||||||
|
/// REVIEW.md "manga PATCH / cover endpoints don't check ownership".
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn patch_forbidden_for_non_uploader(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, owner_cookie) = common::register_user(&h.app).await;
|
||||||
|
let (_, intruder_cookie) = common::register_user(&h.app).await;
|
||||||
|
|
||||||
|
let created = create_manga(&h.app, &owner_cookie, json!({ "title": "Mine" })).await;
|
||||||
|
let id = id_of(&created);
|
||||||
|
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::patch_json_with_cookie(
|
||||||
|
&format!("/api/v1/mangas/{id}"),
|
||||||
|
json!({ "status": "completed" }),
|
||||||
|
&intruder_cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Owner can still edit their own manga (regression guard for the
|
||||||
|
/// authz fix).
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn patch_allowed_for_uploader(pool: PgPool) {
|
||||||
|
let h = common::harness(pool);
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let created = create_manga(&h.app, &cookie, json!({ "title": "Owned" })).await;
|
||||||
|
let id = id_of(&created);
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::patch_json_with_cookie(
|
||||||
|
&format!("/api/v1/mangas/{id}"),
|
||||||
|
json!({ "status": "completed" }),
|
||||||
|
&cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy rows with `uploaded_by IS NULL` (created before migration
|
||||||
|
/// 0011) remain editable by any signed-in user. Without this carve-out
|
||||||
|
/// the historical-data note in 0011 would be broken.
|
||||||
|
#[sqlx::test(migrations = "./migrations")]
|
||||||
|
async fn patch_allowed_on_legacy_null_uploader(pool: PgPool) {
|
||||||
|
let h = common::harness(pool.clone());
|
||||||
|
let (_, cookie) = common::register_user(&h.app).await;
|
||||||
|
let created = create_manga(&h.app, &cookie, json!({ "title": "Legacy" })).await;
|
||||||
|
let id = id_of(&created);
|
||||||
|
|
||||||
|
// Simulate a row uploaded before the column existed: clear
|
||||||
|
// uploaded_by directly via SQL.
|
||||||
|
sqlx::query("UPDATE mangas SET uploaded_by = NULL WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let (_, other_cookie) = common::register_user(&h.app).await;
|
||||||
|
let resp = h
|
||||||
|
.app
|
||||||
|
.oneshot(common::patch_json_with_cookie(
|
||||||
|
&format!("/api/v1/mangas/{id}"),
|
||||||
|
json!({ "status": "completed" }),
|
||||||
|
&other_cookie,
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resp.status(), StatusCode::OK);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.34.0",
|
"version": "0.34.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -118,77 +118,4 @@ describe('hooks.server proxy', () => {
|
|||||||
expect(body.error.code).toBe('upstream_unavailable');
|
expect(body.error.code).toBe('upstream_unavailable');
|
||||||
expect(errSpy).toHaveBeenCalled();
|
expect(errSpy).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('strips every hop-by-hop header listed in RFC 7230 §6.1', async () => {
|
|
||||||
// Defence in depth: axum doesn't emit these, but a future
|
|
||||||
// middleware that did would otherwise leak per-connection
|
|
||||||
// state across the proxy boundary.
|
|
||||||
fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 }));
|
|
||||||
const resolve = vi.fn();
|
|
||||||
await handle({
|
|
||||||
event: makeEvent('/api/v1/health', {
|
|
||||||
headers: {
|
|
||||||
host: 'app.example.com',
|
|
||||||
'content-length': '0',
|
|
||||||
connection: 'keep-alive',
|
|
||||||
'keep-alive': 'timeout=5',
|
|
||||||
'proxy-authenticate': 'Basic realm=x',
|
|
||||||
'proxy-authorization': 'Basic xyz',
|
|
||||||
te: 'trailers',
|
|
||||||
trailer: 'Expires',
|
|
||||||
'transfer-encoding': 'chunked',
|
|
||||||
upgrade: 'websocket',
|
|
||||||
// A non-hop-by-hop header to ensure non-targets
|
|
||||||
// aren't accidentally stripped.
|
|
||||||
'x-custom': 'pass-through'
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
resolve
|
|
||||||
});
|
|
||||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
||||||
const headers = init.headers as Headers;
|
|
||||||
for (const h of [
|
|
||||||
'host',
|
|
||||||
'content-length',
|
|
||||||
'connection',
|
|
||||||
'keep-alive',
|
|
||||||
'proxy-authenticate',
|
|
||||||
'proxy-authorization',
|
|
||||||
'te',
|
|
||||||
'trailer',
|
|
||||||
'transfer-encoding',
|
|
||||||
'upgrade'
|
|
||||||
]) {
|
|
||||||
expect(headers.get(h), `${h} should be stripped`).toBeNull();
|
|
||||||
}
|
|
||||||
expect(headers.get('x-custom')).toBe('pass-through');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('aborts and returns 502 when the upstream stalls past the timeout', async () => {
|
|
||||||
const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
||||||
// Simulate an aborted fetch (AbortController.abort() raises a
|
|
||||||
// DOMException with name 'AbortError' on Node's fetch). The
|
|
||||||
// handler should treat it as the same upstream_unavailable
|
|
||||||
// 502 it uses for any other network failure.
|
|
||||||
const abortErr = new DOMException('aborted', 'AbortError');
|
|
||||||
fetchSpy.mockRejectedValueOnce(abortErr);
|
|
||||||
|
|
||||||
const resolve = vi.fn();
|
|
||||||
const resp = await handle({ event: makeEvent('/api/v1/slow'), resolve });
|
|
||||||
expect(resp.status).toBe(502);
|
|
||||||
const body = await resp.json();
|
|
||||||
expect(body.error.code).toBe('upstream_unavailable');
|
|
||||||
expect(errSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('attaches an AbortSignal to the upstream fetch so it can time out', async () => {
|
|
||||||
fetchSpy.mockResolvedValueOnce(new Response('[]', { status: 200 }));
|
|
||||||
const resolve = vi.fn();
|
|
||||||
await handle({ event: makeEvent('/api/v1/health'), resolve });
|
|
||||||
const init = fetchSpy.mock.calls[0][1] as RequestInit;
|
|
||||||
expect(init.signal).toBeInstanceOf(AbortSignal);
|
|
||||||
// The signal hasn't fired (handler returned in time), but its
|
|
||||||
// presence is the contract this test is pinning.
|
|
||||||
expect(init.signal?.aborted).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -12,66 +12,20 @@ import type { Handle } from '@sveltejs/kit';
|
|||||||
|
|
||||||
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080';
|
const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:8080';
|
||||||
|
|
||||||
/**
|
|
||||||
* Hop-by-hop headers per RFC 7230 §6.1. These are scoped to a single
|
|
||||||
* transport-level connection and must not be forwarded by a proxy.
|
|
||||||
* Plus `host` and `content-length`: `host` would mislead the backend
|
|
||||||
* about its origin, and `content-length` is recomputed by the upstream
|
|
||||||
* fetch from the body stream.
|
|
||||||
*/
|
|
||||||
const HOP_BY_HOP_HEADERS = [
|
|
||||||
'host',
|
|
||||||
'content-length',
|
|
||||||
'connection',
|
|
||||||
'keep-alive',
|
|
||||||
'proxy-authenticate',
|
|
||||||
'proxy-authorization',
|
|
||||||
'te',
|
|
||||||
'trailer',
|
|
||||||
'transfer-encoding',
|
|
||||||
'upgrade'
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cap each proxied request at 5 minutes. The bound exists to surface
|
|
||||||
* a wedged backend (stuck on a slow DB query, deadlocked, etc.) as a
|
|
||||||
* 502 rather than letting the browser request hang indefinitely.
|
|
||||||
*
|
|
||||||
* The default leans toward the slow-upload end of the spectrum: at a
|
|
||||||
* 1 Mbps upstream, a 200 MiB chapter upload (the default
|
|
||||||
* `MAX_REQUEST_BYTES` cap) needs ~27 minutes; 300 s covers the more
|
|
||||||
* realistic 25 Mbps urban-broadband case (~64 s for the same upload)
|
|
||||||
* with comfortable headroom. Operators serving very slow clients
|
|
||||||
* should raise `BACKEND_PROXY_TIMEOUT_MS`; operators behind a
|
|
||||||
* tighter upstream proxy may want to lower it. A future improvement
|
|
||||||
* is an idle-based timeout (reset per chunk) instead of this
|
|
||||||
* wall-clock budget — that's a fair bit more code, deferred.
|
|
||||||
*/
|
|
||||||
const PROXY_TIMEOUT_MS = (() => {
|
|
||||||
const raw = process.env.BACKEND_PROXY_TIMEOUT_MS;
|
|
||||||
const n = raw ? Number(raw) : 300_000;
|
|
||||||
return Number.isFinite(n) && n > 0 ? n : 300_000;
|
|
||||||
})();
|
|
||||||
|
|
||||||
export const handle: Handle = async ({ event, resolve }) => {
|
export const handle: Handle = async ({ event, resolve }) => {
|
||||||
if (event.url.pathname.startsWith('/api/')) {
|
if (event.url.pathname.startsWith('/api/')) {
|
||||||
const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`;
|
const target = `${BACKEND_URL}${event.url.pathname}${event.url.search}`;
|
||||||
|
|
||||||
|
// Strip hop-by-hop headers — `host` would mislead the backend
|
||||||
|
// about the origin, and `content-length` will be recomputed.
|
||||||
const headers = new Headers(event.request.headers);
|
const headers = new Headers(event.request.headers);
|
||||||
for (const h of HOP_BY_HOP_HEADERS) headers.delete(h);
|
headers.delete('host');
|
||||||
|
headers.delete('content-length');
|
||||||
// AbortController times the upstream fetch out so a backend
|
|
||||||
// wedged on a slow DB query doesn't keep the browser request
|
|
||||||
// hanging forever. The `signal` is also wired into the
|
|
||||||
// RequestInit so the body stream is cancelled cleanly.
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
const timeoutHandle = setTimeout(() => ctrl.abort(), PROXY_TIMEOUT_MS);
|
|
||||||
|
|
||||||
const init: RequestInit & { duplex?: 'half' } = {
|
const init: RequestInit & { duplex?: 'half' } = {
|
||||||
method: event.request.method,
|
method: event.request.method,
|
||||||
headers,
|
headers,
|
||||||
redirect: 'manual',
|
redirect: 'manual'
|
||||||
signal: ctrl.signal
|
|
||||||
};
|
};
|
||||||
if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
|
if (event.request.method !== 'GET' && event.request.method !== 'HEAD') {
|
||||||
init.body = event.request.body;
|
init.body = event.request.body;
|
||||||
@@ -85,13 +39,11 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
upstream = await fetch(target, init);
|
upstream = await fetch(target, init);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Network-layer failure (DNS / connection refused / TLS
|
// Network-layer failure (DNS / connection refused / TLS
|
||||||
// handshake / abort by timeout) — most commonly "backend
|
// handshake) — most commonly "backend container restarting".
|
||||||
// container restarting". SvelteKit's default 500 would be
|
// SvelteKit's default 500 would be an HTML page that
|
||||||
// an HTML page that client.ts can't .json(), which masks
|
// client.ts can't .json(), which masks the real cause. Emit
|
||||||
// the real cause. Emit the standard envelope with a
|
// the standard envelope with a dedicated code instead.
|
||||||
// dedicated code instead.
|
|
||||||
console.error('Proxy to backend failed:', e);
|
console.error('Proxy to backend failed:', e);
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
return new Response(
|
return new Response(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: {
|
error: {
|
||||||
@@ -106,7 +58,6 @@ export const handle: Handle = async ({ event, resolve }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(timeoutHandle);
|
|
||||||
return new Response(upstream.body, {
|
return new Response(upstream.body, {
|
||||||
status: upstream.status,
|
status: upstream.status,
|
||||||
statusText: upstream.statusText,
|
statusText: upstream.statusText,
|
||||||
|
|||||||
Reference in New Issue
Block a user