Initial Commit

This commit is contained in:
2026-03-12 22:11:36 +07:00 Unverified
commit f75d249f7c
20 changed files with 3294 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
export function ConstsSvg({ locked = false }: { locked?: boolean }) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 14 14"
style={{ width: "2.5em" }}
className={locked ? "locked" : ""}
>
<title>Consts</title>
<defs id="svg-Const__defs17">
<filter
id="svg-Const__filter852"
width="1.284"
height="1.284"
x="-.142"
y="-.142"
style={{ colorInterpolationFilters: "sRGB" }}
>
<feGaussianBlur
id="svg-Const__feGaussianBlur854"
stdDeviation=".808"
></feGaussianBlur>
</filter>
</defs>
<style>
{" "}
{
".locked .blur{fill:black;opacity:0.2!important}.locked .tria1{display:none}.locked .tria2{display:none}.locked .tria3{display:none}.locked .lock{opacity:1!important}.locked .spikes{fill:black;opacity:0.2!important}.locked .c1{display:none}.locked .c2{fill:currentColor;filter:brightness(0.8)}"
}
</style>
<g id="svg-Const__layer1" style={{ display: "inline" }}>
<path
fill="currentColor"
d="M7.005 1.858c2.838 0 5.123 2.323 5.123 5.16a5.113 5.113 0 0 1-5.123 5.124c-2.838 0-5.16-2.285-5.16-5.123s2.322-5.161 5.16-5.161"
className="blur"
style={{ filter: "url(#svg-Const__filter852)", opacity: 0.50089286 }}
></path>
<path
fill="currentColor"
d="M6.977.064c-.03.787-.985 1.686-.985 1.686l.21.152a5.14 5.14 0 0 0-2.74 1.325l.142.275a4.85 4.85 0 0 1 2.937-1.34c.217.186.423.399.436.557.012-.158.208-.371.425-.557 1.139.1 2.166.595 2.944 1.344l.142-.281a5.15 5.15 0 0 0-2.746-1.323c.125-.09.217-.152.217-.152S7.006.845 6.977.064M2.498 4.438a5.14 5.14 0 0 0 .152 5.408c-.181-.041-.312-.08-.312-.08s-.056 1.274-.6 1.824c.626-.465 1.889-.334 1.889-.334s-.023-.143-.037-.332a5.15 5.15 0 0 0 2.592 1.199l.127-.287a4.85 4.85 0 0 1-2.733-1.332c.003-.264.036-.523.139-.627-.119.089-.381.085-.647.049a4.9 4.9 0 0 1-.687-1.29l.775-.591V7.4l-.088-.064.088-.066v-.332l-.304.23-.73-.564q.018-.22.058-.434l.976.752v-.324l-.92-.717c.118-.494.31-.958.565-1.381zm8.947 0-.302.062c.254.423.446.887.564 1.38l-.984.76v.587l.144.109-.144.115v.543l.84.65a5 5 0 0 1-.696 1.288c-.255.032-.505.029-.619-.057v.002c.1.099.137.346.143.598a4.85 4.85 0 0 1-2.756 1.36l.127.288a5.16 5.16 0 0 0 2.615-1.22c-.014.204-.037.353-.037.353s1.262-.13 1.889.334c-.547-.549-.602-1.824-.602-1.824s-.139.045-.332.088a5.14 5.14 0 0 0 .846-2.836c0-.941-.257-1.821-.696-2.58m.32 1.726q.038.213.057.434l-.736.57-.313-.238zm-9.658.758.536.414-.485.375a5 5 0 0 1-.05-.693zm9.73 0v.096c0 .24-.009.47-.042.7l-.5-.382zm-8.985.58.283.225-.84.65a5 5 0 0 1-.094-.37zm8.234 0 .65.514a5 5 0 0 1-.086.361l-.847-.65z"
className="spikes"
style={{
filter: "saturate(1.2) brightness(1.2)",
opacity: 0.34,
strokeWidth: 14,
paintOrder: "stroke markers fill",
}}
></path>
<path
fill="currentColor"
d="m6.977 11.611-.436.854.428.861.433-.853Z"
className="tria1"
style={{
opacity: 0.34,
strokeWidth: 14,
paintOrder: "stroke markers fill",
}}
></path>
<path
fill="currentColor"
d="m11.867 3.074-.926.225-.384.875.933-.217z"
className="tria2"
style={{
opacity: 0.34,
strokeWidth: 14,
paintOrder: "stroke markers fill",
}}
></path>
<path
fill="currentColor"
d="m2.078 3.074.383.883.926.217-.375-.875z"
className="tria3"
style={{
opacity: 0.34,
strokeWidth: 14,
paintOrder: "stroke markers fill",
}}
></path>
<path
fill="currentColor"
d="M7 3.119c2.142 0 3.867 1.753 3.867 3.895A3.86 3.86 0 0 1 7 10.881c-2.142 0-3.895-1.725-3.895-3.867A3.907 3.907 0 0 1 7 3.12"
className="backdrop"
></path>
<path
fill="rgba(0,0,0,0.8)"
d="M7 3.119c2.142 0 3.867 1.753 3.867 3.895A3.86 3.86 0 0 1 7 10.881c-2.142 0-3.895-1.725-3.895-3.867A3.907 3.907 0 0 1 7 3.12"
className="backdropBlack"
style={{
filter: "brightness(0.2)",
strokeWidth: 14.452,
paintOrder: "stroke markers fill",
}}
></path>
<path
fill="currentColor"
d="M7 2.502c-2.475 0-4.484 2.037-4.484 4.512A4.487 4.487 0 0 0 7 11.498a4.487 4.487 0 0 0 4.484-4.484c0-2.475-2.01-4.512-4.484-4.512m0 .738c2.084 0 3.746 1.69 3.746 3.774A3.727 3.727 0 0 1 7 10.76c-2.084 0-3.773-1.663-3.773-3.746A3.774 3.774 0 0 1 7 3.24"
className="c1"
style={{
opacity: 0.9,
strokeWidth: 14,
paintOrder: "stroke markers fill",
}}
></path>
<path
fill="#fff"
d="M7 2.967a4.06 4.06 0 0 0-4.047 4.047c0 2.223 1.824 4.02 4.047 4.02a4.015 4.015 0 0 0 4.02-4.02c0-2.223-1.797-4.047-4.02-4.047m0 .273c2.075 0 3.746 1.699 3.746 3.774A3.74 3.74 0 0 1 7 10.76c-2.075 0-3.773-1.671-3.773-3.746A3.785 3.785 0 0 1 7 3.24"
className="c2"
style={{ strokeWidth: 14, paintOrder: "stroke markers fill" }}
></path>
<path
fill="#fff"
d="M7.017 5.224a.9.9 0 0 0-.897.897v.213h-.346a.117.117 0 0 0-.118.117v1.94a.117.117 0 0 0 .118.117h2.452a.117.117 0 0 0 .117-.117V6.45a.117.117 0 0 0-.117-.117h-.31V6.12a.9.9 0 0 0-.9-.897m0 .343a.57.57 0 0 1 .573.57v.197H6.446v-.196a.57.57 0 0 1 .57-.571M7 6.978a.32.32 0 0 1 .316.318.32.32 0 0 1-.188.289v.285c0 .05-.039.087-.088.087h-.083a.087.087 0 0 1-.088-.087v-.285a.32.32 0 0 1-.188-.289A.32.32 0 0 1 7 6.978"
className="lock"
style={{
opacity: 0.00000001,
strokeWidth: 0.0631447,
paintOrder: "stroke markers fill",
}}
></path>
</g>
</svg>
);
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="515" height="596" viewBox="0 0 136.26 157.692"><defs><clipPath id="a" clipPathUnits="userSpaceOnUse"><path d="M0 0h136.26v157.692H0z" style="opacity:1;fill:#fff;fill-opacity:1;stroke:none;stroke-width:.02;paint-order:stroke markers fill"/></clipPath><filter id="b" width="1.285" height="1.248" x="-.143" y="-.124" style="color-interpolation-filters:sRGB"><feGaussianBlur stdDeviation="11.948"/></filter></defs><path d="M23.9 133.254C6.144 109.126 10.415 49.44 38.903 23.5 62.722-.756 157.412-35.53 181.544-46.292L-19.41-26.26v211.105l164.577-.979c-16.631-6.196-98.961-26.949-121.267-50.612Z" clip-path="url(#a)" style="opacity:1;mix-blend-mode:normal;fill:#000;fill-opacity:1;stroke:none;stroke-width:.214241px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1;filter:url(#b)"/></svg>

After

Width:  |  Height:  |  Size: 840 B

Binary file not shown.
+10
View File
@@ -0,0 +1,10 @@
import cluster from "node:cluster";
import os from "node:os";
import process from "node:process";
if (cluster.isPrimary) {
for (let i = 0; i < os.availableParallelism(); i++) cluster.fork();
} else {
await import("./server");
console.log(`Worker ${process.pid} started`);
}
+69
View File
@@ -0,0 +1,69 @@
import { env } from "elysia";
const keys = {
[env.PRIVATE_API_KEY]: ["card"],
[env.PUBLIC_API_KEY]: ["card"],
} as const;
declare module "bun" {
interface Env {
PUBLIC_API_KEY: string;
PRIVATE_API_KEY: string;
}
}
function validateKeyList(
keys: unknown,
): asserts keys is Record<string, string[]> {
if (typeof keys !== "object" || !keys) throw "key list is not an object";
for (const [i, [key, perms]] of Object.entries(Object.entries(keys))) {
if (!(typeof key === "string") || !key) throw `key ${i} is invalid`;
if (!Array.isArray(perms))
throw `key ${i} doesn't have valid permission list`;
if (perms.some((p) => !(typeof p === "string")))
throw `key ${i} has invalid permission`;
}
}
validateKeyList(keys);
type Permission = (typeof keys)[string][number];
class Key {
readonly valid = true;
readonly permissions: Permission[];
constructor(permissions: Permission[]) {
this.permissions = permissions;
}
has(permission: Permission) {
return this.permissions.includes(permission);
}
toJSON() {
return { valid: this.valid, permissions: this.permissions };
}
}
export class InvalidKey {
readonly valid = false;
has() {
return false;
}
toJSON() {
return { valid: false };
}
}
class PermissionManager {
readonly invalid = new InvalidKey();
validate(key: string) {
if (!Object.keys(keys).includes(key)) return this.invalid;
return new Key([...keys[key]]);
}
}
export const pm = new PermissionManager();
+2241
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
import openapi from "@elysiajs/openapi";
import { Elysia, t } from "elysia";
import { version } from "../package.json";
import { pm } from "./lib/auth";
import { v1routes } from "./v1";
const app = new Elysia()
.use(
openapi({
exclude: { paths: ["/", "/health"] },
documentation: { info: { title: "Astral API", version } },
path: "/docs",
}),
)
.guard({
headers: t.Object({
authorization: t.Optional(
t.String({
pattern: "^Bearer [a-zA-Z0-9-_+=]+",
description: "API Key",
}),
),
}),
})
.derive(({ headers }) => {
const auth = headers.authorization;
const bearer = auth?.startsWith("Bearer ") ? auth.slice(7) : null;
return {
bearer,
key: bearer ? pm.validate(bearer) : pm.invalid,
};
})
.get("/", ({ redirect }) => redirect("/docs"))
.get("/health", () => "OK")
.listen(3000);
v1routes(app);
export type App = typeof app;
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
);
+197
View File
@@ -0,0 +1,197 @@
import { ImageResponse } from "@takumi-rs/image-response";
import { file } from "bun";
import { NotFoundError, status, t } from "elysia";
import {
fetchEnka,
InternalError,
InvalidFormatError,
MaintenanceError,
RatelimitError,
} from "enka.ts";
import { twj } from "tw-to-css";
import type { App } from "@";
import { ConstsSvg } from "@/assets/consts";
import { mockData } from "@/lib/mock";
const font = file("src/assets/shinshin.woff2");
const shade = file("src/assets/shade.svg");
export function card(app: App) {
return app
.group("/card", (app) =>
app
// .onBeforeHandle(({ key }) => {
// if (!key.valid) throw status(401);
// if (!key.has("card")) throw status(403);
// })
.get(
"/genshin/:uid/:char",
async ({ params: { uid, char: charId } }) => {
try {
// const data = await fetchEnka(uid, false);
const data = mockData;
if (typeof data.avatarInfoList === "undefined")
return status(400, "The showcase for this UID is private");
const char = data.avatarInfoList.find(
(c) => c.avatar.avatarId === Number(charId),
);
if (!char) return status(400, "Character not found in showcase");
return new ImageResponse(
<div style={twj("relative text-[14.8667px] text-white")}>
<div style={twj("aspect-[1218/508] h-full")}></div>
<div
style={{
...twj(
"absolute top-0 bottom-0 left-0 w-[27.4em] p-[1.4em] bg-red-500/50",
"flex flex-col",
),
textShadow:
"0 .08em .1em black,0 .1em .3em rgba(0,0,0,.4)",
}}
>
<div style={twj("absolute top-0 left-0 bottom-0 right-0")}>
<img src="shade" alt="shade" style={twj("opacity-40")} />
</div>
<div
style={twj(
"text-[130%] pb-[.5%] flex items-center gap-[.5em]",
)}
>
{char.avatar.name}
<div style={twj("text-[11.596px] opacity-80")}>
&nbsp;&nbsp;▴&nbsp;&nbsp; {data.playerInfo.nickname}
</div>
</div>
<div>
Lv. 80 /<span style={twj("opacity-60 pl-1")}>80</span>
</div>
<div style={twj("flex gap-1 items-end")}>
<svg
xmlns="http://www.w3.org/2000/svg"
style={twj("h-[1em] mb-1")}
viewBox="0 0 14 14"
>
<title>fren</title>
<path
fill="white"
d="M6.922 12.375c0-.134-.97-1.102-1.669-1.663l-.697-.56.728-.045c.426-.027.931-.11 1.218-.203l.49-.157.457.155a4.4 4.4 0 0 0 1.2.202l.744.048-.77.618c-.825.663-1.555 1.4-1.555 1.571 0 .058-.033.105-.073.105a.07.07 0 0 1-.073-.071M4.163 9.43q-1.678-.451-2.552-2.236C1.225 6.407.679 5.763.39 5.756c-.082-.002-.032-.055.135-.143.348-.184.749-.66.979-1.162.373-.814.44-.915.94-1.426.553-.566 1.156-.909 1.86-1.06.449-.096 1.677-.051 1.99.073l.21.083-.468.428c-.257.236-.558.56-.668.718-.177.255-.253.3-.63.375a2.094 2.094 0 0 0-1.487 3c.798 1.591 3.044 1.548 3.83-.072.137-.28.163-.45.155-1.006-.009-.664-.007-.67.247-.967.285-.333.755-.606.983-.572.367.056.632 1.656.422 2.545-.323 1.37-1.443 2.51-2.818 2.868-.519.135-1.39.132-1.907-.007Zm3.65-.017-.384-.123.48-.427c.264-.235.562-.553.663-.706.154-.232.246-.29.56-.358.526-.112.95-.332 1.21-.628.965-1.1.677-2.697-.607-3.36-.306-.158-.471-.193-.92-.193-.626 0-1.013.156-1.48.6-.477.454-.59.748-.602 1.557-.009.651-.026.73-.207.968-.25.327-.687.62-.957.639-.19.014-.223-.023-.388-.422-.255-.62-.264-1.744-.018-2.459C5.713 2.906 7.125 1.9 8.816 1.9c1.538 0 2.764.79 3.47 2.236.41.84.74 1.274 1.09 1.44l.273.13-.262.135c-.37.19-.722.651-1.067 1.4-.497 1.08-1.461 1.905-2.567 2.2-.525.139-1.462.126-1.94-.027z"
></path>
</svg>
8
</div>
<div style={twj("grow")} />
<div
style={twj("flex align-start flex-col text-[1.12em]")}
className={`${char.avatar.element} locked`}
>
{Array(6)
.fill(0)
.map((_, i) => (
<div>
<ConstsSvg
locked={i >= (char.talentIdList?.length || 0)}
/>
</div>
))}
</div>
<div
style={twj(
"mb-[-1em] ml-[-.3em] mt-[.3em] text-[.9em] opacity-80",
)}
>
{data.uid}
</div>
</div>
<div
style={twj(
"absolute top-0 bottom-0 right-[23.1em] w-[21.5em] p-[1.4em_.7em_.7em] bg-green-500/50",
)}
></div>
<div
style={twj(
"absolute top-0 bottom-0 right-0 w-[23.1em] p-[.7em] bg-blue-500/50",
)}
></div>
</div>,
{
fonts: [await font.arrayBuffer()],
height: 508,
persistentImages: [
{ data: await shade.arrayBuffer(), src: "shade" },
],
// drawDebugBorder: true,
stylesheets: [
`
.Rock { color: #ffc64a; }
.Wind { color: #71fdec; }
.Ice { color: #98efff; }
.Water { color: #56a2ff; }
.Electric { color: #ffa1ff; }
.Fire { color: #f58453; }
.Grass { color: #a5ffab; }
`,
],
},
);
} catch (err) {
if (err instanceof InvalidFormatError)
return status(400, "Invalid UID format");
else if (err instanceof NotFoundError)
return status(404, "UID not found on enka.network");
else if (err instanceof MaintenanceError)
return status(424, "enka.network is under maintenance");
else if (err instanceof RatelimitError)
return status(
429,
"Too many requests to enka.network, please try again later",
);
else if (err instanceof InternalError)
return status(500, "Internal server error");
throw err;
}
},
{
params: t.Object({
uid: t.String(),
char: t.Number(),
}),
detail: {
tags: ["Card"],
description:
"Generate a Genshin Impact enka.network card image from a UID.",
},
},
),
)
.get(
"/health/enka",
async () => {
try {
fetchEnka("888888888");
return "OK";
} catch (err) {
if (err instanceof InvalidFormatError)
return status(200, "enka.network API is reachable");
else if (err instanceof NotFoundError)
return status(200, "enka.network API is reachable");
else if (err instanceof MaintenanceError)
return status(503, "enka.network is under maintenance");
else if (err instanceof RatelimitError)
return status(
503,
"Too many requests to enka.network, please try again later",
);
else if (err instanceof InternalError)
return status(503, "enka.network API is unreachable");
}
},
{
detail: {
tags: ["Card"],
description: "Check if enka.network API is reachable.",
},
},
);
}
+6
View File
@@ -0,0 +1,6 @@
import type { App } from "@";
import { card } from "./card";
export function v1routes(app: App) {
app.group("/v1", (app) => app.use(card));
}