diff --git a/Dockerfile b/Dockerfile index ef64fe5..5011607 100644 --- a/Dockerfile +++ b/Dockerfile @@ -117,6 +117,7 @@ COPY ./static/*.css ./static/ COPY ./static/*.ts ./static/ COPY ./static/*.ico ./static/ COPY ./static/*.svg ./static/ +COPY ./static/*.js ./static/ COPY ./tasks ./tasks COPY ./thumbnail ./thumbnail COPY ./translation ./translation @@ -126,6 +127,7 @@ COPY ./deno.json ./ COPY ./import_map.json ./ COPY ./LICENSE ./ COPY ./docker_entrypoint.sh ./ +COPY ./api.yml ./ ENV LD_LIBRARY_PATH=/app/lib ENV PATH=/app/bin:$PATH @@ -136,7 +138,8 @@ ENV DENO_SQLITE_PATH=/app/lib/libsqlite3.so ENV DENO_LIBZIP_PATH=/app/lib/libzip.so ENV LC_ALL=C.utf8 -RUN deno task server-build && deno task prebuild && \ +RUN deno task download_swagger && \ + deno task server-build && deno task prebuild && \ deno task cache && rm -rf ~/.cache && \ mkdir -p ./thumbnails && chmod 777 ./thumbnails && \ mkdir -p ./downloads && chmod 777 ./downloads && \ diff --git a/deno.json b/deno.json index 0d27297..80e8073 100644 --- a/deno.json +++ b/deno.json @@ -12,7 +12,8 @@ "gen_meili_server_key": "deno run --allow-net scripts/gen_meili_server_key.ts", "server-build": "deno run -A server-dev.ts build", "prebuild": "deno run -A scripts/prebuild.ts", - "download_ffi": "deno run --allow-read=./ --allow-write=./lib --allow-net scripts/download_ffi.ts" + "download_ffi": "deno run --allow-read=./ --allow-write=./lib --allow-net scripts/download_ffi.ts", + "download_swagger": "deno run --allow-read=./ --allow-net --allow-write=./static/swagger scripts/download_swagger.ts" }, "fmt": { "indentWidth": 4, @@ -25,7 +26,8 @@ "api.yml", "docker-compose.yml", ".github/workflows/deno.yml", - ".github/workflows/docker.yaml" + ".github/workflows/docker.yaml", + "static/swagger-initializer.js" ] }, "compilerOptions": { @@ -37,7 +39,7 @@ "rules": { "tags": ["fresh", "recommended"] }, - "exclude": ["_fresh", "static/sw.js"] + "exclude": ["_fresh", "static/sw.js", "static/swagger-initializer.js"] }, "unstable": ["ffi"] } diff --git a/import_map.json b/import_map.json index c40fadb..f4e2013 100644 --- a/import_map.json +++ b/import_map.json @@ -8,6 +8,7 @@ "@std/jsonc": "jsr:/@std/jsonc@1.0.1", "@std/path": "jsr:/@std/path@1.0.7", "@std/semver": "jsr:/@std/semver@1.0.3", + "@std/yaml/": "jsr:/@std/yaml@1.0.5/", "deno_dom/": "jsr:/@b-fuze/deno-dom@0.1.48/", "sqlite/": "https://deno.land/x/sqlite@v3.9.1/", "zipjs/": "https://deno.land/x/zipjs@v2.7.53/", diff --git a/routes/_middleware.ts b/routes/_middleware.ts index 900ba24..d69c725 100644 --- a/routes/_middleware.ts +++ b/routes/_middleware.ts @@ -1,5 +1,5 @@ import { FreshContext } from "$fresh/server.ts"; -import { join } from "@std/path"; +import { basename, join } from "@std/path"; import { get_file_response, GetFileResponseOptions, @@ -13,9 +13,12 @@ import { initDOMParser } from "../utils.ts"; import { DOMParser } from "deno_dom/wasm-noinit"; import { get_host, return_error } from "../server/utils.ts"; import { base_logger } from "../utils/logger.ts"; +import { parse as parseYaml } from "@std/yaml/parse"; const STATIC_FILES = ["/common.css", "/scrollBar.css", "/sw.js", "/sw.js.map"]; +const logger = base_logger.get_logger("middleware"); + async function default_handler(req: Request, ctx: FreshContext) { const url = new URL(req.url); const m = get_task_manager(); @@ -171,6 +174,80 @@ async function default_handler(req: Request, ctx: FreshContext) { opts.if_unmodified_since = req.headers.get("If-Unmodified-Since"); return await get_file_response(p, opts); } + if (url.pathname == "/swagger" || url.pathname.startsWith("/swagger/")) { + let swagger_base = import.meta.resolve("../static/swagger").slice(7); + if (Deno.build.os === "windows") { + swagger_base = swagger_base.slice(1); + } + const u = new URL(req.url); + let p = join(swagger_base, u.pathname.slice(9)); + if (basename(p) == "swagger-initializer.js") { + p = join(swagger_base, "../swagger-initializer.js"); + } + if (!(await exists(p)) || p === swagger_base) { + p = join(swagger_base, "/index.html"); + } + if (basename(p) == "index.html") { + const html = await Deno.readTextFile(p); + await initDOMParser(); + try { + const dom = (new DOMParser()).parseFromString( + html, + "text/html", + ); + const doc = dom.documentElement!; + const head = doc.querySelector("head"); + if (!head) { + throw new Error("head not found"); + } + const base = dom.createElement("base"); + base.setAttribute("href", "/swagger/"); + head?.append(base); + const css_links = doc.querySelectorAll("link[rel=stylesheet]"); + for (const link of css_links) { + const href = link.getAttribute("href"); + if (href) { + if (href.startsWith("/")) continue; + link.setAttribute("href", `/swagger/${href}`); + } + } + return new Response( + "\n" + doc.outerHTML, + { + headers: { + "Content-Type": "text/html; charset=UTF-8", + }, + }, + ); + } catch (e) { + logger.warn("Failed to handle swagger index.html:", e); + if (u.pathname == "/swagger") { + return Response.redirect( + `${get_host(req)}/swagger/index.html`, + 302, + ); + } + } + } + const opts: GetFileResponseOptions = {}; + opts.range = req.headers.get("range"); + opts.if_modified_since = req.headers.get("If-Modified-Since"); + opts.if_unmodified_since = req.headers.get("If-Unmodified-Since"); + return await get_file_response(p, opts); + } + if (url.pathname == "/api.json") { + let filepath = import.meta.resolve("../api.yml").slice(7); + if (Deno.build.os === "windows") { + filepath = filepath.slice(1); + } + const data = > parseYaml( + await Deno.readTextFile(filepath), + ); + data["servers"] = [{ url: "/api", description: "API Server" }]; + return new Response(JSON.stringify(data), { + headers: { "Content-Type": "application/json" }, + }); + } const res = await ctx.next(); if (enable_server_timing) { if (res.status === 101) return res; diff --git a/scripts/download_swagger.ts b/scripts/download_swagger.ts new file mode 100644 index 0000000..81c0e14 --- /dev/null +++ b/scripts/download_swagger.ts @@ -0,0 +1,64 @@ +import { join } from "@std/path"; +import { configure, HttpReader, ZipReader } from "zipjs/index.js"; +import { sure_dir } from "../utils.ts"; + +async function get_latest_version() { + const re = await fetch( + "https://api.github.com/repos/swagger-api/swagger-ui/releases/latest", + ); + if (!re.ok) { + throw new Error( + `Failed to fetch latest version: ${re.status} ${re.statusText}`, + ); + } + const json = await re.json(); + return json.tag_name; +} + +async function get_download_url() { + const version = await get_latest_version(); + return `https://github.com/swagger-api/swagger-ui/archive/refs/tags/${version}.zip`; +} + +const DIST = /swagger-ui-[1-9\.]+\/dist/; + +async function unzip(url: string) { + const zip_reader = new ZipReader(new HttpReader(url)); + const entries = await zip_reader.getEntries(); + let swagger_base = import.meta.resolve("../static/swagger").slice(7); + if (Deno.build.os === "windows") { + swagger_base = swagger_base.slice(1); + } + await sure_dir(swagger_base); + for (const entry of entries) { + const m = entry.filename.match(DIST); + if (!m) { + continue; + } + const filename = entry.filename.replace(DIST, ""); + if (filename.endsWith("/")) { + const path = join(swagger_base, filename); + await sure_dir(path); + continue; + } + if (!entry.getData) { + continue; + } + const path = join(swagger_base, filename); + console.log("Extracting", entry.filename, "to", path); + const file = await Deno.open(path, { write: true, create: true }); + try { + await entry.getData(file.writable); + } finally { + try { + file.close(); + } catch (_) { + null; + } + } + } +} + +configure({ useWebWorkers: false }); +const download_url = await get_download_url(); +await unzip(download_url); diff --git a/static/.gitignore b/static/.gitignore index 1189c50..436e80c 100644 --- a/static/.gitignore +++ b/static/.gitignore @@ -3,3 +3,4 @@ flutter/ sw.js sw.js.map sw.meta.json +swagger/ diff --git a/static/swagger-initializer.js b/static/swagger-initializer.js new file mode 100644 index 0000000..a295c6d --- /dev/null +++ b/static/swagger-initializer.js @@ -0,0 +1,21 @@ +window.onload = function() { + // + + // the following lines will be replaced by docker/configurator, when it runs in a docker-container + window.ui = SwaggerUIBundle({ + url: "/api.json", + dom_id: '#swagger-ui', + deepLinking: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], + layout: "StandaloneLayout", + validatorUrl: null, + }); + + // +};