132 lines
4.5 KiB
TypeScript
132 lines
4.5 KiB
TypeScript
/**
|
|
* Reads other sites for the client, as `api/remote/` does for the server: the
|
|
* previews of links on the create form, the images its thumbnails can't load
|
|
* directly, and the tracks the players list. On any failure of the last two,
|
|
* the player falls back to the embed.
|
|
*
|
|
* - `GET /api/remote/preview?url=` — signed in. Answers the preview of a link,
|
|
* and whether its page could be reached.
|
|
* - `GET /api/remote/image?url=` — public. Serves a remote raster image
|
|
* through this site; 400 when it isn't one, 502 when its host can't be
|
|
* reached.
|
|
* - `GET /api/remote/bandcamp?url=&force=` — public. Answers a Bandcamp
|
|
* page's tracks and their stream URLs, for the native player; `force=1`
|
|
* skips the server's cache.
|
|
* - `GET /api/remote/youtube?list=` — public. Answers a YouTube playlist's
|
|
* tracklist, which the global player lists beside the embed.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { Router } from "@oak/oak";
|
|
import { isValidHttpUrl } from "../remote/fetch.ts";
|
|
import { fetchProxiedImage } from "../remote/image-proxy.ts";
|
|
import { resolveBandcamp } from "../remote/bandcamp-streams.ts";
|
|
import { resolveYouTubePlaylist } from "../remote/youtube-playlist.ts";
|
|
import { tryFetchRichContent } from "../services/rich-content-service.ts";
|
|
import {
|
|
APIErrorCode,
|
|
type APIFailure,
|
|
type APIResponse,
|
|
} from "../../shared/api.ts";
|
|
import type { LinkPreview } from "../../shared/dump.ts";
|
|
import type { Tralbum, YouTubePlaylist } from "../../shared/player.ts";
|
|
import { authMiddleware } from "../middleware/auth.ts";
|
|
|
|
/**
|
|
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
|
* An `APIException` thrown by a handler becomes the JSON error response there.
|
|
*/
|
|
const router: Router = new Router({ prefix: "/api/remote" });
|
|
|
|
// Signed-in only: an open preview would let anyone make the server fetch pages.
|
|
router.get("/preview", authMiddleware, async (ctx) => {
|
|
const url = ctx.request.url.searchParams.get("url") ?? "";
|
|
if (!isValidHttpUrl(url)) {
|
|
const responseBody: APIFailure = {
|
|
success: false,
|
|
error: { code: APIErrorCode.BAD_REQUEST, message: "Invalid URL" },
|
|
};
|
|
ctx.response.status = 400;
|
|
ctx.response.body = responseBody;
|
|
return;
|
|
}
|
|
const { ok, content } = await tryFetchRichContent(url);
|
|
const responseBody: APIResponse<LinkPreview> = {
|
|
success: true,
|
|
data: { reached: ok, richContent: content ?? null },
|
|
};
|
|
ctx.response.body = responseBody;
|
|
});
|
|
|
|
/**
|
|
* Answers with a proxied image. It is sent from this site's origin, so it is
|
|
* sandboxed in case a browser opens it as a document.
|
|
*/
|
|
function sendImage(
|
|
ctx: { response: { headers: Headers; body?: unknown } },
|
|
bytes: Uint8Array,
|
|
contentType: string,
|
|
): void {
|
|
ctx.response.headers.set("Content-Type", contentType);
|
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
|
ctx.response.headers.set(
|
|
"Content-Security-Policy",
|
|
"sandbox; default-src 'none'",
|
|
);
|
|
ctx.response.headers.set("Cache-Control", "public, max-age=86400");
|
|
ctx.response.body = bytes;
|
|
}
|
|
|
|
// The frontend sends a thumbnail here when it is plain HTTP, which an HTTPS
|
|
// page won't load, or when its host's hotlink protection refused the browser.
|
|
router.get("/image", async (ctx) => {
|
|
const url = ctx.request.url.searchParams.get("url") ?? "";
|
|
if (!isValidHttpUrl(url)) {
|
|
ctx.response.status = 400;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const image = await fetchProxiedImage(url);
|
|
if (!image) {
|
|
ctx.response.status = 400;
|
|
return;
|
|
}
|
|
sendImage(ctx, image.bytes, image.contentType);
|
|
} catch {
|
|
ctx.response.status = 502;
|
|
}
|
|
});
|
|
|
|
// Public: it reads a public page and answers the URLs that page gives any
|
|
// visitor.
|
|
router.get("/bandcamp", async (ctx) => {
|
|
const url = ctx.request.url.searchParams.get("url") ?? "";
|
|
const force = ctx.request.url.searchParams.get("force") === "1";
|
|
|
|
const responseBody: APIResponse<Tralbum> = {
|
|
success: true,
|
|
data: await resolveBandcamp(url, { force }),
|
|
};
|
|
|
|
// Not in a shared cache: the signed URLs belong to one page fetch. An hour
|
|
// is well inside their 24 hours.
|
|
ctx.response.headers.set("Cache-Control", "private, max-age=3600");
|
|
ctx.response.body = responseBody;
|
|
});
|
|
|
|
// Public, like /bandcamp: it only reads a public page.
|
|
router.get("/youtube", async (ctx) => {
|
|
const listId = ctx.request.url.searchParams.get("list") ?? "";
|
|
const responseBody: APIResponse<YouTubePlaylist> = {
|
|
success: true,
|
|
data: await resolveYouTubePlaylist(listId),
|
|
};
|
|
|
|
ctx.response.headers.set("Cache-Control", "public, max-age=3600");
|
|
ctx.response.body = responseBody;
|
|
});
|
|
|
|
export default router;
|