From 5b6a1a3471c97be060af0da1dec7a7b73806838b Mon Sep 17 00:00:00 2001 From: pinks Date: Mon, 20 Nov 2023 02:14:14 +0000 Subject: [PATCH] refactor: rewrite API to Elysia (#25) https://elysiajs.com/ Reviewed-on: https://git.foxo.me/pinks/eris/pulls/25 Co-authored-by: pinks Co-committed-by: pinks --- api/adminsRoute.ts | 237 ++++++++++++------------ api/botRoute.ts | 19 +- api/getUser.ts | 45 +++++ api/jobsRoute.ts | 48 +++-- api/mod.ts | 4 +- api/paramsRoute.ts | 49 ++--- api/serveApi.ts | 37 ++-- api/sessionsRoute.ts | 63 ++++--- api/statsRoute.ts | 250 ++++++++++++------------- api/usersRoute.ts | 97 +++++----- api/withUser.ts | 40 ---- api/workersRoute.ts | 360 ++++++++++++++++++------------------ app/adminStore.ts | 29 +-- app/config.ts | 80 ++++---- app/dailyStatsStore.ts | 26 ++- app/globalStats.ts | 23 +-- app/userDailyStatsStore.ts | 29 +-- app/userStatsStore.ts | 39 ++-- app/workerInstanceStore.ts | 46 ++--- bot/broadcastCommand.ts | 4 +- bot/txt2imgCommand.ts | 4 +- deno.json | 5 +- deno.lock | 22 ++- ui/AdminsPage.tsx | 68 +++---- ui/App.tsx | 10 +- ui/AppHeader.tsx | 24 ++- ui/QueuePage.tsx | 6 +- ui/SettingsPage.tsx | 12 +- ui/StatsPage.tsx | 2 +- ui/WorkersPage.tsx | 80 ++++---- ui/apiClient.ts | 24 ++- utils/Tkv.ts | 76 ++++++++ {app => utils}/kvMemoize.ts | 0 33 files changed, 961 insertions(+), 897 deletions(-) create mode 100644 api/getUser.ts delete mode 100644 api/withUser.ts create mode 100644 utils/Tkv.ts rename {app => utils}/kvMemoize.ts (100%) diff --git a/api/adminsRoute.ts b/api/adminsRoute.ts index 9666c7c..72b04f2 100644 --- a/api/adminsRoute.ts +++ b/api/adminsRoute.ts @@ -1,129 +1,128 @@ -import { Model } from "indexed_kv"; import { info } from "std/log/mod.ts"; -import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; +import { Elysia, Static, t } from "elysia"; import { Admin, adminSchema, adminStore } from "../app/adminStore.ts"; -import { getUser, withAdmin, withUser } from "./withUser.ts"; +import { getUser, withSessionAdmin, withSessionUser } from "./getUser.ts"; +import { TkvEntry } from "../utils/Tkv.ts"; -export type AdminData = Admin & { id: string }; +const adminDataSchema = t.Intersect([adminSchema, t.Object({ tgUserId: t.Number() })]); -function getAdminData(adminEntry: Model): AdminData { - return { id: adminEntry.id, ...adminEntry.value }; +export type AdminData = Static; + +function getAdminData(adminEntry: TkvEntry<["admins", number], Admin>): AdminData { + return { tgUserId: adminEntry.key[1], ...adminEntry.value }; } -export const adminsRoute = createPathFilter({ - "": createMethodFilter({ - GET: createEndpoint( - {}, - async () => { - const adminEntries = await adminStore.getAll(); - const admins = adminEntries.map(getAdminData); - return { - status: 200, - body: { type: "application/json", data: admins satisfies AdminData[] }, - }; +export const adminsRoute = new Elysia() + .get( + "", + async () => { + const adminEntries = await Array.fromAsync(adminStore.list({ prefix: ["admins"] })); + const admins = adminEntries.map(getAdminData); + return admins; + }, + { + response: t.Array(adminDataSchema), + }, + ) + .post( + "", + async ({ query, body, set }) => { + return withSessionAdmin({ query, set }, async (sessionUser, sessionAdminEntry) => { + const newAdminUser = await getUser(body.tgUserId); + const newAdminKey = ["admins", body.tgUserId] as const; + const newAdminValue = { promotedBy: sessionAdminEntry.key[1] }; + const newAdminResult = await adminStore.atomicSet(newAdminKey, null, newAdminValue); + if (!newAdminResult.ok) { + set.status = 409; + return "User is already an admin"; + } + info(`User ${sessionUser.first_name} promoted user ${newAdminUser.first_name} to admin`); + return getAdminData({ ...newAdminResult, key: newAdminKey, value: newAdminValue }); + }); + }, + { + query: t.Object({ sessionId: t.String() }), + body: t.Object({ + tgUserId: t.Number(), + }), + response: { + 200: adminDataSchema, + 401: t.Literal("Must be logged in"), + 403: t.Literal("Must be an admin"), + 409: t.Literal("User is already an admin"), }, - ), - POST: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - body: { - type: "application/json", - schema: { - type: "object", - properties: { - tgUserId: adminSchema.properties.tgUserId, - }, - required: ["tgUserId"], - }, - }, + }, + ) + .post( + "/promote_self", + // if there are no admins, allow any user to promote themselves + async ({ query, set }) => { + return withSessionUser({ query, set }, async (sessionUser) => { + const adminEntries = await Array.fromAsync(adminStore.list({ prefix: ["admins"] })); + if (adminEntries.length !== 0) { + set.status = 409; + return "You are not allowed to promote yourself"; + } + const newAdminKey = ["admins", sessionUser.id] as const; + const newAdminValue = { promotedBy: null }; + const newAdminResult = await adminStore.set(newAdminKey, newAdminValue); + info(`User ${sessionUser.first_name} promoted themselves to admin`); + return getAdminData({ ...newAdminResult, key: newAdminKey, value: newAdminValue }); + }); + }, + { + query: t.Object({ sessionId: t.String() }), + response: { + 200: adminDataSchema, + 401: t.Literal("Must be logged in"), + 403: t.Literal("Must be an admin"), + 409: t.Literal("You are not allowed to promote yourself"), }, - async ({ query, body }) => { - return withAdmin(query, async (user, adminEntry) => { - const newAdminUser = await getUser(body.data.tgUserId); - const newAdminEntry = await adminStore.create({ - tgUserId: body.data.tgUserId, - promotedBy: adminEntry.id, - }); - info(`User ${user.first_name} promoted user ${newAdminUser.first_name} to admin`); - return { - status: 200, - body: { type: "application/json", data: getAdminData(newAdminEntry) }, - }; - }); + }, + ) + .get( + "/:adminId", + async ({ params, set }) => { + const adminEntry = await adminStore.get(["admins", Number(params.adminId)]); + if (!adminEntry.versionstamp) { + set.status = 404; + return "Admin not found"; + } + return getAdminData(adminEntry); + }, + { + params: t.Object({ adminId: t.String() }), + response: { + 200: adminDataSchema, + 404: t.Literal("Admin not found"), }, - ), - }), - - "promote_self": createMethodFilter({ - POST: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, + }, + ) + .delete( + "/:adminId", + async ({ params, query, set }) => { + return withSessionAdmin({ query, set }, async (sessionUser) => { + const deletedAdminEntry = await adminStore.get(["admins", Number(params.adminId)]); + if (!deletedAdminEntry.versionstamp) { + set.status = 404; + return "Admin not found"; + } + const deletedAdminUser = await getUser(deletedAdminEntry.key[1]); + await adminStore.delete(["admins", Number(params.adminId)]); + info( + `User ${sessionUser.first_name} demoted user ${deletedAdminUser.first_name} from admin`, + ); + return null; + }); + }, + { + params: t.Object({ adminId: t.String() }), + query: t.Object({ sessionId: t.String() }), + response: { + 200: t.Null(), + 401: t.Literal("Must be logged in"), + 403: t.Literal("Must be an admin"), + 404: t.Literal("Admin not found"), }, - // if there are no admins, allow any user to promote themselves - async ({ query }) => { - return withUser(query, async (user) => { - const adminEntries = await adminStore.getAll(); - if (adminEntries.length === 0) { - const newAdminEntry = await adminStore.create({ - tgUserId: user.id, - promotedBy: null, - }); - info(`User ${user.first_name} promoted themselves to admin`); - return { - status: 200, - body: { type: "application/json", data: getAdminData(newAdminEntry) }, - }; - } - return { - status: 403, - body: { type: "text/plain", data: `You are not allowed to promote yourself` }, - }; - }); - }, - ), - }), - - "{adminId}": createPathFilter({ - "": createMethodFilter({ - GET: createEndpoint( - {}, - async ({ params }) => { - const adminEntry = await adminStore.getById(params.adminId!); - if (!adminEntry) { - return { status: 404, body: { type: "text/plain", data: `Admin not found` } }; - } - return { - status: 200, - body: { type: "application/json", data: getAdminData(adminEntry) }, - }; - }, - ), - DELETE: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - }, - async ({ params, query }) => { - return withAdmin(query, async (chat) => { - const deletedAdminEntry = await adminStore.getById(params.adminId!); - if (!deletedAdminEntry) { - return { status: 404, body: { type: "text/plain", data: `Admin not found` } }; - } - const deletedAdminUser = await getUser(deletedAdminEntry.value.tgUserId); - await deletedAdminEntry.delete(); - info(`User ${chat.first_name} demoted user ${deletedAdminUser.first_name} from admin`); - return { - status: 200, - body: { type: "application/json", data: null }, - }; - }); - }, - ), - }), - }), -}); + }, + ); diff --git a/api/botRoute.ts b/api/botRoute.ts index f5466b1..181c5e8 100644 --- a/api/botRoute.ts +++ b/api/botRoute.ts @@ -1,9 +1,14 @@ -import { createEndpoint, createMethodFilter } from "t_rest/server"; +import { Elysia, t } from "elysia"; import { bot } from "../bot/mod.ts"; -export const botRoute = createMethodFilter({ - GET: createEndpoint({ query: null, body: null }, async () => { - const username = bot.botInfo.username; - return { status: 200, body: { type: "application/json", data: { username } } }; - }), -}); +export const botRoute = new Elysia() + .get( + "", + async () => { + const username = bot.botInfo.username; + return { username }; + }, + { + response: t.Object({ username: t.String() }), + }, + ); diff --git a/api/getUser.ts b/api/getUser.ts new file mode 100644 index 0000000..0f84101 --- /dev/null +++ b/api/getUser.ts @@ -0,0 +1,45 @@ +import { Chat } from "grammy_types"; +import { Admin, adminStore } from "../app/adminStore.ts"; +import { bot } from "../bot/mod.ts"; +import { sessions } from "./sessionsRoute.ts"; +import { TkvEntry } from "../utils/Tkv.ts"; + +export async function withSessionUser( + { query, set }: { query: { sessionId: string }; set: { status?: string | number } }, + cb: (sessionUser: Chat.PrivateGetChat) => Promise, +) { + const session = sessions.get(query.sessionId); + if (!session?.userId) { + set.status = 401; + return "Must be logged in"; + } + const user = await getUser(session.userId); + return cb(user); +} + +export async function withSessionAdmin( + { query, set }: { query: { sessionId: string }; set: { status?: string | number } }, + cb: ( + sessionUser: Chat.PrivateGetChat, + sessionAdminEntry: TkvEntry<["admins", number], Admin>, + ) => Promise, +) { + const session = sessions.get(query.sessionId); + if (!session?.userId) { + set.status = 401; + return "Must be logged in"; + } + const sessionUser = await getUser(session.userId); + const sessionAdminEntry = await adminStore.get(["admins", sessionUser.id]); + if (!sessionAdminEntry.versionstamp) { + set.status = 403; + return "Must be an admin"; + } + return cb(sessionUser, sessionAdminEntry); +} + +export async function getUser(userId: number): Promise { + const chat = await bot.api.getChat(userId); + if (chat.type !== "private") throw new Error("Chat is not private"); + return chat; +} diff --git a/api/jobsRoute.ts b/api/jobsRoute.ts index e7811c4..bda3936 100644 --- a/api/jobsRoute.ts +++ b/api/jobsRoute.ts @@ -1,32 +1,40 @@ -import { createEndpoint, createMethodFilter } from "t_rest/server"; +import { Elysia, t } from "elysia"; import { generationQueue } from "../app/generationQueue.ts"; -export const jobsRoute = createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, +export const jobsRoute = new Elysia() + .get( + "", async () => { const allJobs = await generationQueue.getAllJobs(); - const filteredJobsData = allJobs.map((job) => ({ - id: job.id, + return allJobs.map((job) => ({ + id: job.id.join(":"), place: job.place, state: { from: { - language_code: job.state.from.language_code, + language_code: job.state.from.language_code ?? null, first_name: job.state.from.first_name, - last_name: job.state.from.last_name, - username: job.state.from.username, + last_name: job.state.from.last_name ?? null, + username: job.state.from.username ?? null, }, - progress: job.state.progress, - workerInstanceKey: job.state.workerInstanceKey, + progress: job.state.progress ?? null, + workerInstanceKey: job.state.workerInstanceKey ?? null, }, })); - return { - status: 200, - body: { - type: "application/json", - data: filteredJobsData, - }, - }; }, - ), -}); + { + response: t.Array(t.Object({ + id: t.String(), + place: t.Number(), + state: t.Object({ + from: t.Object({ + language_code: t.Nullable(t.String()), + first_name: t.String(), + last_name: t.Nullable(t.String()), + username: t.Nullable(t.String()), + }), + progress: t.Nullable(t.Number()), + workerInstanceKey: t.Nullable(t.String()), + }), + })), + }, + ); diff --git a/api/mod.ts b/api/mod.ts index 13043fd..d59b472 100644 --- a/api/mod.ts +++ b/api/mod.ts @@ -1,12 +1,12 @@ import { route } from "reroute"; import { serveSpa } from "serve_spa"; -import { serveApi } from "./serveApi.ts"; +import { api } from "./serveApi.ts"; import { fromFileUrl } from "std/path/mod.ts"; export async function serveUi() { const server = Deno.serve({ port: 5999 }, (request) => route(request, { - "/api/*": (request) => serveApi(request), + "/api/*": (request) => api.fetch(request), "/*": (request) => serveSpa(request, { fsRoot: fromFileUrl(new URL("../ui/", import.meta.url)), diff --git a/api/paramsRoute.ts b/api/paramsRoute.ts index 23068d1..fe82bfd 100644 --- a/api/paramsRoute.ts +++ b/api/paramsRoute.ts @@ -1,34 +1,39 @@ -import { deepMerge } from "std/collections/deep_merge.ts"; +import { Elysia, t } from "elysia"; import { info } from "std/log/mod.ts"; -import { createEndpoint, createMethodFilter } from "t_rest/server"; -import { configSchema, getConfig, setConfig } from "../app/config.ts"; -import { withAdmin } from "./withUser.ts"; +import { defaultParamsSchema, getConfig, setConfig } from "../app/config.ts"; +import { withSessionAdmin } from "./getUser.ts"; -export const paramsRoute = createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, +export const paramsRoute = new Elysia() + .get( + "", async () => { const config = await getConfig(); - return { status: 200, body: { type: "application/json", data: config.defaultParams } }; + return config.defaultParams; }, - ), - - PATCH: createEndpoint( { - query: { sessionId: { type: "string" } }, - body: { - type: "application/json", - schema: configSchema.properties.defaultParams, + response: { + 200: defaultParamsSchema, }, }, - async ({ query, body }) => { - return withAdmin(query, async (user) => { + ) + .patch( + "", + async ({ query, body, set }) => { + return withSessionAdmin({ query, set }, async (user) => { const config = await getConfig(); - info(`User ${user.first_name} updated default params: ${JSON.stringify(body.data)}`); - const defaultParams = deepMerge(config.defaultParams ?? {}, body.data); + info(`User ${user.first_name} updated default params: ${JSON.stringify(body)}`); + const defaultParams = { ...config.defaultParams, ...body }; await setConfig({ defaultParams }); - return { status: 200, body: { type: "application/json", data: config.defaultParams } }; + return config.defaultParams; }); }, - ), -}); + { + query: t.Object({ sessionId: t.String() }), + body: defaultParamsSchema, + response: { + 200: defaultParamsSchema, + 401: t.Literal("Must be logged in"), + 403: t.Literal("Must be an admin"), + }, + }, + ); diff --git a/api/serveApi.ts b/api/serveApi.ts index 8559f46..36ae5dd 100644 --- a/api/serveApi.ts +++ b/api/serveApi.ts @@ -1,4 +1,5 @@ -import { createLoggerMiddleware, createPathFilter } from "t_rest/server"; +import { Elysia } from "elysia"; +import { swagger } from "elysia/swagger"; import { adminsRoute } from "./adminsRoute.ts"; import { botRoute } from "./botRoute.ts"; import { jobsRoute } from "./jobsRoute.ts"; @@ -8,18 +9,24 @@ import { statsRoute } from "./statsRoute.ts"; import { usersRoute } from "./usersRoute.ts"; import { workersRoute } from "./workersRoute.ts"; -export const serveApi = createLoggerMiddleware( - createPathFilter({ - "admins": adminsRoute, - "bot": botRoute, - "jobs": jobsRoute, - "sessions": sessionsRoute, - "settings/params": paramsRoute, - "stats": statsRoute, - "users": usersRoute, - "workers": workersRoute, - }), - { filterStatus: (status) => status >= 400 }, -); +export const api = new Elysia() + .use( + swagger({ + path: "/docs", + swaggerOptions: { url: "docs/json" } as never, + documentation: { + info: { title: "Eris API", version: "0.1" }, + servers: [{ url: "/api" }], + }, + }), + ) + .group("/admins", (api) => api.use(adminsRoute)) + .group("/bot", (api) => api.use(botRoute)) + .group("/jobs", (api) => api.use(jobsRoute)) + .group("/sessions", (api) => api.use(sessionsRoute)) + .group("/settings/params", (api) => api.use(paramsRoute)) + .group("/stats", (api) => api.use(statsRoute)) + .group("/users", (api) => api.use(usersRoute)) + .group("/workers", (api) => api.use(workersRoute)); -export type ApiHandler = typeof serveApi; +export type Api = typeof api; diff --git a/api/sessionsRoute.ts b/api/sessionsRoute.ts index a2be5c8..6f551fe 100644 --- a/api/sessionsRoute.ts +++ b/api/sessionsRoute.ts @@ -1,4 +1,4 @@ -import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; +import { Elysia, NotFoundError, t } from "elysia"; import { ulid } from "ulid"; export const sessions = new Map(); @@ -7,30 +7,37 @@ export interface Session { userId?: number | undefined; } -export const sessionsRoute = createPathFilter({ - "": createMethodFilter({ - POST: createEndpoint( - { query: null, body: null }, - async () => { - const id = ulid(); - const session: Session = {}; - sessions.set(id, session); - return { status: 200, body: { type: "application/json", data: { id, ...session } } }; - }, - ), - }), - - "{sessionId}": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const id = params.sessionId!; - const session = sessions.get(id); - if (!session) { - return { status: 401, body: { type: "text/plain", data: "Session not found" } }; - } - return { status: 200, body: { type: "application/json", data: { id, ...session } } }; - }, - ), - }), -}); +export const sessionsRoute = new Elysia() + .post( + "", + async () => { + const id = ulid(); + const session: Session = {}; + sessions.set(id, session); + return { id, userId: session.userId ?? null }; + }, + { + response: t.Object({ + id: t.String(), + userId: t.Nullable(t.Number()), + }), + }, + ) + .get( + "/:sessionId", + async ({ params }) => { + const id = params.sessionId!; + const session = sessions.get(id); + if (!session) { + throw new NotFoundError("Session not found"); + } + return { id, userId: session.userId ?? null }; + }, + { + params: t.Object({ sessionId: t.String() }), + response: t.Object({ + id: t.String(), + userId: t.Nullable(t.Number()), + }), + }, + ); diff --git a/api/statsRoute.ts b/api/statsRoute.ts index 09f814e..729fa32 100644 --- a/api/statsRoute.ts +++ b/api/statsRoute.ts @@ -1,146 +1,134 @@ +import { Elysia, t } from "elysia"; import { subMinutes } from "date-fns"; -import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; -import { getDailyStats } from "../app/dailyStatsStore.ts"; +import { dailyStatsSchema, getDailyStats } from "../app/dailyStatsStore.ts"; import { generationStore } from "../app/generationStore.ts"; import { globalStats } from "../app/globalStats.ts"; -import { getUserDailyStats } from "../app/userDailyStatsStore.ts"; -import { getUserStats } from "../app/userStatsStore.ts"; -import { withAdmin } from "./withUser.ts"; +import { getUserDailyStats, userDailyStatsSchema } from "../app/userDailyStatsStore.ts"; +import { getUserStats, userStatsSchema } from "../app/userStatsStore.ts"; +import { withSessionAdmin } from "./getUser.ts"; const STATS_INTERVAL_MIN = 3; -export const statsRoute = createPathFilter({ - "": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async () => { - const after = subMinutes(new Date(), STATS_INTERVAL_MIN); - const generations = await generationStore.getAll({ after }); +export const statsRoute = new Elysia() + .get( + "", + async () => { + const after = subMinutes(new Date(), STATS_INTERVAL_MIN); + const generations = await generationStore.getAll({ after }); - const imagesPerMinute = generations.length / STATS_INTERVAL_MIN; + const imagesPerMinute = generations.length / STATS_INTERVAL_MIN; - const stepsPerMinute = generations - .map((generation) => generation.value.info?.steps ?? 0) - .reduce((sum, steps) => sum + steps, 0) / STATS_INTERVAL_MIN; + const stepsPerMinute = generations + .map((generation) => generation.value.info?.steps ?? 0) + .reduce((sum, steps) => sum + steps, 0) / STATS_INTERVAL_MIN; - const pixelsPerMinute = generations - .map((generation) => - (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) - ) - .reduce((sum, pixels) => sum + pixels, 0) / STATS_INTERVAL_MIN; + const pixelsPerMinute = generations + .map((generation) => + (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) + ) + .reduce((sum, pixels) => sum + pixels, 0) / STATS_INTERVAL_MIN; - const pixelStepsPerMinute = generations - .map((generation) => - (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) * - (generation.value.info?.steps ?? 0) - ) - .reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN; + const pixelStepsPerMinute = generations + .map((generation) => + (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0) * + (generation.value.info?.steps ?? 0) + ) + .reduce((sum, pixelSteps) => sum + pixelSteps, 0) / STATS_INTERVAL_MIN; + return { + imageCount: globalStats.imageCount, + stepCount: globalStats.stepCount, + pixelCount: globalStats.pixelCount, + pixelStepCount: globalStats.pixelStepCount, + userCount: globalStats.userIds.length, + imagesPerMinute, + stepsPerMinute, + pixelsPerMinute, + pixelStepsPerMinute, + }; + }, + { + response: { + 200: t.Object({ + imageCount: t.Number(), + stepCount: t.Number(), + pixelCount: t.Number(), + pixelStepCount: t.Number(), + userCount: t.Number(), + imagesPerMinute: t.Number(), + stepsPerMinute: t.Number(), + pixelsPerMinute: t.Number(), + pixelStepsPerMinute: t.Number(), + }), + }, + }, + ) + .get( + "/daily/:year/:month/:day", + async ({ params }) => { + return getDailyStats(params.year, params.month, params.day); + }, + { + params: t.Object({ + year: t.Number(), + month: t.Number(), + day: t.Number(), + }), + response: { + 200: dailyStatsSchema, + }, + }, + ) + .get( + "/users/:userId", + async ({ params }) => { + const userId = params.userId; + // deno-lint-ignore no-unused-vars + const { tagCountMap, ...stats } = await getUserStats(userId); + return stats; + }, + { + params: t.Object({ userId: t.Number() }), + response: { + 200: t.Omit(userStatsSchema, ["tagCountMap"]), + }, + }, + ) + .get( + "/users/:userId/tagcount", + async ({ params, query, set }) => { + return withSessionAdmin({ query, set }, async () => { + const stats = await getUserStats(params.userId); return { - status: 200, - body: { - type: "application/json", - data: { - imageCount: globalStats.imageCount, - stepCount: globalStats.stepCount, - pixelCount: globalStats.pixelCount, - pixelStepCount: globalStats.pixelStepCount, - userCount: globalStats.userIds.length, - imagesPerMinute, - stepsPerMinute, - pixelsPerMinute, - pixelStepsPerMinute, - }, - }, + tagCountMap: stats.tagCountMap, + timestamp: stats.timestamp, }; + }); + }, + { + params: t.Object({ userId: t.Number() }), + query: t.Object({ sessionId: t.String() }), + response: { + 200: t.Pick(userStatsSchema, ["tagCountMap", "timestamp"]), + 401: t.Literal("Must be logged in"), + 403: t.Literal("Must be an admin"), }, - ), - }), - "daily/{year}/{month}/{day}": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const year = Number(params.year); - const month = Number(params.month); - const day = Number(params.day); - const stats = await getDailyStats(year, month, day); - return { - status: 200, - body: { - type: "application/json", - data: { - imageCount: stats.imageCount, - pixelCount: stats.pixelCount, - userCount: stats.userIds.length, - timestamp: stats.timestamp, - }, - }, - }; + }, + ) + .get( + "/users/:userId/daily/:year/:month/:day", + async ({ params }) => { + return getUserDailyStats(params.userId, params.year, params.month, params.day); + }, + { + params: t.Object({ + userId: t.Number(), + year: t.Number(), + month: t.Number(), + day: t.Number(), + }), + response: { + 200: userDailyStatsSchema, }, - ), - }), - "users/{userId}": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const userId = Number(params.userId); - const stats = await getUserStats(userId); - return { - status: 200, - body: { - type: "application/json", - data: { - imageCount: stats.imageCount, - pixelCount: stats.pixelCount, - timestamp: stats.timestamp, - }, - }, - }; - }, - ), - }), - "users/{userId}/tagcount": createMethodFilter({ - GET: createEndpoint( - { query: { sessionId: { type: "string" } }, body: null }, - async ({ params, query }) => { - return withAdmin(query, async () => { - const userId = Number(params.userId); - const stats = await getUserStats(userId); - return { - status: 200, - body: { - type: "application/json", - data: { - tagCountMap: stats.tagCountMap, - timestamp: stats.timestamp, - }, - }, - }; - }); - }, - ), - }), - "users/{userId}/daily/{year}/{month}/{day}": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const userId = Number(params.userId); - const year = Number(params.year); - const month = Number(params.month); - const day = Number(params.day); - const stats = await getUserDailyStats(userId, year, month, day); - return { - status: 200, - body: { - type: "application/json", - data: { - imageCount: stats.imageCount, - pixelCount: stats.pixelCount, - timestamp: stats.timestamp, - }, - }, - }; - }, - ), - }), -}); + }, + ); diff --git a/api/usersRoute.ts b/api/usersRoute.ts index 0f4cc69..3d79b3e 100644 --- a/api/usersRoute.ts +++ b/api/usersRoute.ts @@ -1,50 +1,55 @@ -import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; +import { Elysia, t } from "elysia"; +import { adminSchema, adminStore } from "../app/adminStore.ts"; import { bot } from "../bot/mod.ts"; -import { getUser } from "./withUser.ts"; -import { adminStore } from "../app/adminStore.ts"; +import { getUser } from "./getUser.ts"; -export const usersRoute = createPathFilter({ - "{userId}/photo": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const user = await getUser(Number(params.userId!)); - const photoData = user.photo?.small_file_id - ? await fetch( - `https://api.telegram.org/file/bot${bot.token}/${await bot.api.getFile( - user.photo.small_file_id, - ).then((file) => file.file_path)}`, - ).then((resp) => resp.arrayBuffer()) - : undefined; - if (!photoData) { - return { status: 404, body: { type: "text/plain", data: "User has no photo" } }; +export const usersRoute = new Elysia() + .get( + "/:userId/photo", + async ({ params }) => { + const user = await getUser(Number(params.userId)); + if (!user.photo) { + throw new Error("User has no photo"); + } + const photoFile = await bot.api.getFile(user.photo.small_file_id); + const photoData = await fetch( + `https://api.telegram.org/file/bot${bot.token}/${photoFile.file_path}`, + ).then((resp) => { + if (!resp.ok) { + throw new Error("Failed to fetch photo"); } - return { - status: 200, - body: { - type: "image/jpeg", - data: new Blob([photoData], { type: "image/jpeg" }), - }, - }; - }, - ), - }), + return resp; + }).then((resp) => resp.arrayBuffer()); - "{userId}": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const user = await getUser(Number(params.userId!)); - const [adminEntry] = await adminStore.getBy("tgUserId", { value: user.id }); - const admin = adminEntry?.value; - return { - status: 200, - body: { - type: "application/json", - data: { ...user, admin }, - }, - }; - }, - ), - }), -}); + return new Response(new File([photoData], "avatar.jpg", { type: "image/jpeg" })); + }, + { + params: t.Object({ userId: t.String() }), + }, + ) + .get( + "/:userId", + async ({ params }) => { + const user = await getUser(Number(params.userId)); + const adminEntry = await adminStore.get(["admins", user.id]); + return { + id: user.id, + first_name: user.first_name, + last_name: user.last_name ?? null, + username: user.username ?? null, + bio: user.bio ?? null, + admin: adminEntry.value ?? null, + }; + }, + { + params: t.Object({ userId: t.String() }), + response: t.Object({ + id: t.Number(), + first_name: t.String(), + last_name: t.Nullable(t.String()), + username: t.Nullable(t.String()), + bio: t.Nullable(t.String()), + admin: t.Nullable(adminSchema), + }), + }, + ); diff --git a/api/withUser.ts b/api/withUser.ts deleted file mode 100644 index ab637ac..0000000 --- a/api/withUser.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { Chat } from "grammy_types"; -import { Model } from "indexed_kv"; -import { Output } from "t_rest/client"; -import { Admin, adminStore } from "../app/adminStore.ts"; -import { bot } from "../bot/mod.ts"; -import { sessions } from "./sessionsRoute.ts"; - -export async function withUser( - query: { sessionId: string }, - cb: (user: Chat.PrivateGetChat) => Promise, -) { - const session = sessions.get(query.sessionId); - if (!session?.userId) { - return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const; - } - const user = await getUser(session.userId); - return cb(user); -} - -export async function withAdmin( - query: { sessionId: string }, - cb: (user: Chat.PrivateGetChat, admin: Model) => Promise, -) { - const session = sessions.get(query.sessionId); - if (!session?.userId) { - return { status: 401, body: { type: "text/plain", data: "Must be logged in" } } as const; - } - const user = await getUser(session.userId); - const [admin] = await adminStore.getBy("tgUserId", { value: session.userId }); - if (!admin) { - return { status: 403, body: { type: "text/plain", data: "Must be an admin" } } as const; - } - return cb(user, admin); -} - -export async function getUser(userId: number): Promise { - const chat = await bot.api.getChat(userId); - if (chat.type !== "private") throw new Error("Chat is not private"); - return chat; -} diff --git a/api/workersRoute.ts b/api/workersRoute.ts index bf31c5a..b626a31 100644 --- a/api/workersRoute.ts +++ b/api/workersRoute.ts @@ -2,7 +2,7 @@ import { subMinutes } from "date-fns"; import { Model } from "indexed_kv"; import createOpenApiFetch from "openapi_fetch"; import { info } from "std/log/mod.ts"; -import { createEndpoint, createMethodFilter, createPathFilter } from "t_rest/server"; +import { Elysia, NotFoundError, Static, t } from "elysia"; import { activeGenerationWorkers } from "../app/generationQueue.ts"; import { generationStore } from "../app/generationStore.ts"; import * as SdApi from "../app/sdApi.ts"; @@ -13,20 +13,27 @@ import { } from "../app/workerInstanceStore.ts"; import { getAuthHeader } from "../utils/getAuthHeader.ts"; import { omitUndef } from "../utils/omitUndef.ts"; -import { withAdmin } from "./withUser.ts"; +import { withSessionAdmin } from "./getUser.ts"; -export type WorkerData = Omit & { - id: string; - isActive: boolean; - imagesPerMinute: number; - stepsPerMinute: number; - pixelsPerMinute: number; - pixelStepsPerMinute: number; -}; +const workerResponseSchema = t.Intersect([ + t.Object({ id: t.String() }), + t.Omit(workerInstanceSchema, ["sdUrl", "sdAuth"]), + t.Object({ + isActive: t.Boolean(), + imagesPerMinute: t.Number(), + stepsPerMinute: t.Number(), + pixelsPerMinute: t.Number(), + pixelStepsPerMinute: t.Number(), + }), +]); + +export type WorkerResponse = Static; + +const workerRequestSchema = t.Omit(workerInstanceSchema, ["lastOnlineTime", "lastError"]); const STATS_INTERVAL_MIN = 10; -async function getWorkerData(workerInstance: Model): Promise { +async function getWorkerResponse(workerInstance: Model): Promise { const after = subMinutes(new Date(), STATS_INTERVAL_MIN); const generations = await generationStore.getBy("workerInstanceKey", { @@ -51,7 +58,7 @@ async function getWorkerData(workerInstance: Model): Promise sum + pixelSteps, 0) / STATS_INTERVAL_MIN; - return { + return omitUndef({ id: workerInstance.id, key: workerInstance.value.key, name: workerInstance.value.name, @@ -62,178 +69,177 @@ async function getWorkerData(workerInstance: Model): Promise { - const workerInstances = await workerInstanceStore.getAll(); - const workers = await Promise.all(workerInstances.map(getWorkerData)); - return { - status: 200, - body: { type: "application/json", data: workers satisfies WorkerData[] }, - }; +export const workersRoute = new Elysia() + .get( + "", + async () => { + const workerInstances = await workerInstanceStore.getAll(); + const workers = await Promise.all(workerInstances.map(getWorkerResponse)); + return workers; + }, + { + response: t.Array(workerResponseSchema), + }, + ) + .post( + "", + async ({ query, body, set }) => { + return withSessionAdmin({ query, set }, async (sessionUser) => { + const workerInstance = await workerInstanceStore.create(body); + info(`User ${sessionUser.first_name} created worker ${workerInstance.value.name}`); + return await getWorkerResponse(workerInstance); + }); + }, + { + query: t.Object({ sessionId: t.String() }), + body: workerRequestSchema, + response: { + 200: workerResponseSchema, + 401: t.Literal("Must be logged in"), + 403: t.Literal("Must be an admin"), }, - ), - POST: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - body: { - type: "application/json", - schema: workerInstanceSchema, - }, + }, + ) + .get( + "/:workerId", + async ({ params }) => { + const workerInstance = await workerInstanceStore.getById(params.workerId); + if (!workerInstance) { + throw new NotFoundError("Worker not found"); + } + return await getWorkerResponse(workerInstance); + }, + { + params: t.Object({ workerId: t.String() }), + response: { + 200: workerResponseSchema, }, - async ({ query, body }) => { - return withAdmin(query, async (user) => { - const workerInstance = await workerInstanceStore.create(body.data); - info(`User ${user.first_name} created worker ${workerInstance.value.name}`); - return { - status: 200, - body: { type: "application/json", data: await getWorkerData(workerInstance) }, - }; - }); + }, + ) + .patch( + "/:workerId", + async ({ params, query, body, set }) => { + const workerInstance = await workerInstanceStore.getById(params.workerId); + if (!workerInstance) { + throw new NotFoundError("Worker not found"); + } + return withSessionAdmin({ query, set }, async (sessionUser) => { + info( + `User ${sessionUser.first_name} updated worker ${workerInstance.value.name}: ${ + JSON.stringify(body) + }`, + ); + await workerInstance.update(body); + return await getWorkerResponse(workerInstance); + }); + }, + { + params: t.Object({ workerId: t.String() }), + query: t.Object({ sessionId: t.String() }), + body: t.Partial(workerRequestSchema), + response: { + 200: workerResponseSchema, + 401: t.Literal("Must be logged in"), + 403: t.Literal("Must be an admin"), }, - ), - }), - - "{workerId}": createPathFilter({ - "": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const workerInstance = await workerInstanceStore.getById(params.workerId!); - if (!workerInstance) { - return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; - } - return { - status: 200, - body: { type: "application/json", data: await getWorkerData(workerInstance) }, - }; - }, + }, + ) + .delete( + "/:workerId", + async ({ params, query, set }) => { + const workerInstance = await workerInstanceStore.getById(params.workerId); + if (!workerInstance) { + throw new Error("Worker not found"); + } + return withSessionAdmin({ query, set }, async (sessionUser) => { + info(`User ${sessionUser.first_name} deleted worker ${workerInstance.value.name}`); + await workerInstance.delete(); + return null; + }); + }, + { + params: t.Object({ workerId: t.String() }), + query: t.Object({ sessionId: t.String() }), + response: { + 200: t.Null(), + 401: t.Literal("Must be logged in"), + 403: t.Literal("Must be an admin"), + }, + }, + ) + .get( + "/:workerId/loras", + async ({ params }) => { + const workerInstance = await workerInstanceStore.getById(params.workerId); + if (!workerInstance) { + throw new NotFoundError("Worker not found"); + } + const sdClient = createOpenApiFetch({ + baseUrl: workerInstance.value.sdUrl, + headers: getAuthHeader(workerInstance.value.sdAuth), + }); + const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {}); + if (lorasResponse.error) { + throw new Error( + `Loras request failed: ${lorasResponse["error"]}`, + ); + } + const loras = (lorasResponse.data as Lora[]).map((lora) => ({ + name: lora.name, + alias: lora.alias ?? null, + })); + return loras; + }, + { + params: t.Object({ workerId: t.String() }), + response: t.Array( + t.Object({ + name: t.String(), + alias: t.Nullable(t.String()), + }), ), - PATCH: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - body: { - type: "application/json", - schema: { ...workerInstanceSchema, required: [] }, - }, - }, - async ({ params, query, body }) => { - const workerInstance = await workerInstanceStore.getById(params.workerId!); - if (!workerInstance) { - return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; - } - return withAdmin(query, async (user) => { - info( - `User ${user.first_name} updated worker ${workerInstance.value.name}: ${ - JSON.stringify(body.data) - }`, - ); - await workerInstance.update(omitUndef(body.data)); - return { - status: 200, - body: { type: "application/json", data: await getWorkerData(workerInstance) }, - }; - }); - }, + }, + ) + .get( + "/:workerId/models", + async ({ params }) => { + const workerInstance = await workerInstanceStore.getById(params.workerId); + if (!workerInstance) { + throw new NotFoundError("Worker not found"); + } + const sdClient = createOpenApiFetch({ + baseUrl: workerInstance.value.sdUrl, + headers: getAuthHeader(workerInstance.value.sdAuth), + }); + const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {}); + if (modelsResponse.error) { + throw new Error( + `Models request failed: ${modelsResponse["error"]}`, + ); + } + const models = modelsResponse.data.map((model) => ({ + title: model.title, + modelName: model.model_name, + hash: model.hash ?? null, + sha256: model.sha256 ?? null, + })); + return models; + }, + { + params: t.Object({ workerId: t.String() }), + response: t.Array( + t.Object({ + title: t.String(), + modelName: t.String(), + hash: t.Nullable(t.String()), + sha256: t.Nullable(t.String()), + }), ), - DELETE: createEndpoint( - { - query: { - sessionId: { type: "string" }, - }, - body: null, - }, - async ({ params, query }) => { - const workerInstance = await workerInstanceStore.getById(params.workerId!); - if (!workerInstance) { - return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; - } - return withAdmin(query, async (user) => { - info(`User ${user.first_name} deleted worker ${workerInstance.value.name}`); - await workerInstance.delete(); - return { status: 200, body: { type: "application/json", data: null } }; - }); - }, - ), - }), - - "loras": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const workerInstance = await workerInstanceStore.getById(params.workerId!); - if (!workerInstance) { - return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; - } - const sdClient = createOpenApiFetch({ - baseUrl: workerInstance.value.sdUrl, - headers: getAuthHeader(workerInstance.value.sdAuth), - }); - const lorasResponse = await sdClient.GET("/sdapi/v1/loras", {}); - if (lorasResponse.error) { - return { - status: 500, - body: { type: "text/plain", data: `Loras request failed: ${lorasResponse["error"]}` }, - }; - } - const loras = (lorasResponse.data as Lora[]).map((lora) => ({ - name: lora.name, - alias: lora.alias ?? null, - })); - return { - status: 200, - body: { type: "application/json", data: loras }, - }; - }, - ), - }), - - "models": createMethodFilter({ - GET: createEndpoint( - { query: null, body: null }, - async ({ params }) => { - const workerInstance = await workerInstanceStore.getById(params.workerId!); - if (!workerInstance) { - return { status: 404, body: { type: "text/plain", data: `Worker not found` } }; - } - const sdClient = createOpenApiFetch({ - baseUrl: workerInstance.value.sdUrl, - headers: getAuthHeader(workerInstance.value.sdAuth), - }); - const modelsResponse = await sdClient.GET("/sdapi/v1/sd-models", {}); - if (modelsResponse.error) { - return { - status: 500, - body: { - type: "text/plain", - data: `Models request failed: ${modelsResponse["error"]}`, - }, - }; - } - const models = modelsResponse.data.map((model) => ({ - title: model.title, - modelName: model.model_name, - hash: model.hash, - sha256: model.sha256, - })); - return { - status: 200, - body: { type: "application/json", data: models }, - }; - }, - ), - }), - }), -}); + }, + ); export interface Lora { name: string; diff --git a/app/adminStore.ts b/app/adminStore.ts index 4d85c70..2219dab 100644 --- a/app/adminStore.ts +++ b/app/adminStore.ts @@ -1,24 +1,11 @@ -import { Store } from "indexed_kv"; -import { JsonSchema, jsonType } from "t_rest/server"; +import { Static, t } from "elysia"; import { db } from "./db.ts"; +import { Tkv } from "../utils/Tkv.ts"; -export const adminSchema = { - type: "object", - properties: { - tgUserId: { type: "number" }, - promotedBy: { type: ["string", "null"] }, - }, - required: ["tgUserId", "promotedBy"], -} as const satisfies JsonSchema; - -export type Admin = jsonType; - -type AdminIndices = { - tgUserId: number; -}; - -export const adminStore = new Store(db, "adminUsers", { - indices: { - tgUserId: { getValue: (adminUser) => adminUser.tgUserId }, - }, +export const adminSchema = t.Object({ + promotedBy: t.Nullable(t.Number()), }); + +export type Admin = Static; + +export const adminStore = new Tkv<["admins", number], Admin>(db); diff --git a/app/config.ts b/app/config.ts index 652887a..1b4c3a3 100644 --- a/app/config.ts +++ b/app/config.ts @@ -1,51 +1,45 @@ +import { Static, t } from "elysia"; +import { Tkv } from "../utils/Tkv.ts"; import { db } from "./db.ts"; -import { JsonSchema, jsonType } from "t_rest/server"; -export const configSchema = { - type: "object", - properties: { - pausedReason: { type: ["string", "null"] }, - maxUserJobs: { type: "number" }, - maxJobs: { type: "number" }, - defaultParams: { - type: "object", - properties: { - batch_size: { type: "number" }, - n_iter: { type: "number" }, - width: { type: "number" }, - height: { type: "number" }, - steps: { type: "number" }, - cfg_scale: { type: "number" }, - sampler_name: { type: "string" }, - negative_prompt: { type: "string" }, - }, - }, - }, - required: ["adminUsernames", "maxUserJobs", "maxJobs", "defaultParams"], -} as const satisfies JsonSchema; +export const defaultParamsSchema = t.Partial(t.Object({ + batch_size: t.Number(), + n_iter: t.Number(), + width: t.Number(), + height: t.Number(), + steps: t.Number(), + cfg_scale: t.Number(), + sampler_name: t.String(), + negative_prompt: t.String(), +})); -export type Config = jsonType; +export type DefaultParams = Static; + +export const configSchema = t.Object({ + pausedReason: t.Nullable(t.String()), + maxUserJobs: t.Number(), + maxJobs: t.Number(), + defaultParams: defaultParamsSchema, +}); + +export type Config = Static; + +export const configStore = new Tkv<["config"], Config>(db); + +const defaultConfig: Config = { + pausedReason: null, + maxUserJobs: Infinity, + maxJobs: Infinity, + defaultParams: {}, +}; export async function getConfig(): Promise { - const configEntry = await db.get(["config"]); - const config = configEntry?.value; - return { - pausedReason: config?.pausedReason ?? null, - maxUserJobs: config?.maxUserJobs ?? Infinity, - maxJobs: config?.maxJobs ?? Infinity, - defaultParams: config?.defaultParams ?? {}, - }; + const configEntry = await configStore.get(["config"]); + return { ...defaultConfig, ...configEntry.value }; } -export async function setConfig(newConfig: Partial): Promise { - const oldConfig = await getConfig(); - const config: Config = { - pausedReason: newConfig.pausedReason === undefined - ? oldConfig.pausedReason - : newConfig.pausedReason, - maxUserJobs: newConfig.maxUserJobs ?? oldConfig.maxUserJobs, - maxJobs: newConfig.maxJobs ?? oldConfig.maxJobs, - defaultParams: newConfig.defaultParams ?? oldConfig.defaultParams, - }; - await db.set(["config"], config); +export async function setConfig(newConfig: Pick): Promise { + const configEntry = await configStore.get(["config"]); + const config = { ...defaultConfig, ...configEntry.value, ...newConfig }; + await configStore.atomicSet(["config"], configEntry.versionstamp, config); } diff --git a/app/dailyStatsStore.ts b/app/dailyStatsStore.ts index 98ab91d..bd49b93 100644 --- a/app/dailyStatsStore.ts +++ b/app/dailyStatsStore.ts @@ -1,25 +1,21 @@ import { hoursToMilliseconds, isSameDay, minutesToMilliseconds } from "date-fns"; import { UTCDateMini } from "date-fns/utc"; +import { Static, t } from "elysia"; import { info } from "std/log/mod.ts"; -import { JsonSchema, jsonType } from "t_rest/server"; import { db } from "./db.ts"; import { generationStore } from "./generationStore.ts"; -import { kvMemoize } from "./kvMemoize.ts"; +import { kvMemoize } from "../utils/kvMemoize.ts"; -export const dailyStatsSchema = { - type: "object", - properties: { - userIds: { type: "array", items: { type: "number" } }, - imageCount: { type: "number" }, - stepCount: { type: "number" }, - pixelCount: { type: "number" }, - pixelStepCount: { type: "number" }, - timestamp: { type: "number" }, - }, - required: ["userIds", "imageCount", "stepCount", "pixelCount", "pixelStepCount", "timestamp"], -} as const satisfies JsonSchema; +export const dailyStatsSchema = t.Object({ + userIds: t.Array(t.Number()), + imageCount: t.Number(), + stepCount: t.Number(), + pixelCount: t.Number(), + pixelStepCount: t.Number(), + timestamp: t.Number(), +}); -export type DailyStats = jsonType; +export type DailyStats = Static; export const getDailyStats = kvMemoize( db, diff --git a/app/globalStats.ts b/app/globalStats.ts index e03ef59..603e0c7 100644 --- a/app/globalStats.ts +++ b/app/globalStats.ts @@ -1,23 +1,16 @@ import { addDays } from "date-fns"; -import { JsonSchema, jsonType } from "t_rest/server"; import { decodeTime } from "ulid"; import { getDailyStats } from "./dailyStatsStore.ts"; import { generationStore } from "./generationStore.ts"; -export const globalStatsSchema = { - type: "object", - properties: { - userIds: { type: "array", items: { type: "number" } }, - imageCount: { type: "number" }, - stepCount: { type: "number" }, - pixelCount: { type: "number" }, - pixelStepCount: { type: "number" }, - timestamp: { type: "number" }, - }, - required: ["userIds", "imageCount", "stepCount", "pixelCount", "pixelStepCount", "timestamp"], -} as const satisfies JsonSchema; - -export type GlobalStats = jsonType; +export interface GlobalStats { + userIds: number[]; + imageCount: number; + stepCount: number; + pixelCount: number; + pixelStepCount: number; + timestamp: number; +} export const globalStats: GlobalStats = await getGlobalStats(); diff --git a/app/userDailyStatsStore.ts b/app/userDailyStatsStore.ts index 64d7446..2c88eae 100644 --- a/app/userDailyStatsStore.ts +++ b/app/userDailyStatsStore.ts @@ -1,21 +1,18 @@ -import { JsonSchema, jsonType } from "t_rest/server"; -import { kvMemoize } from "./kvMemoize.ts"; +import { Static, t } from "elysia"; +import { kvMemoize } from "../utils/kvMemoize.ts"; import { db } from "./db.ts"; import { generationStore } from "./generationStore.ts"; import { hoursToMilliseconds, isSameDay, minutesToMilliseconds } from "date-fns"; import { UTCDateMini } from "date-fns/utc"; -export const userDailyStatsSchema = { - type: "object", - properties: { - imageCount: { type: "number" }, - pixelCount: { type: "number" }, - timestamp: { type: "number" }, - }, - required: ["imageCount", "pixelCount", "timestamp"], -} as const satisfies JsonSchema; +export const userDailyStatsSchema = t.Object({ + imageCount: t.Number(), + pixelCount: t.Number(), + pixelStepCount: t.Number(), + timestamp: t.Number(), +}); -export type UserDailyStats = jsonType; +export type UserDailyStats = Static; export const getUserDailyStats = kvMemoize( db, @@ -23,6 +20,7 @@ export const getUserDailyStats = kvMemoize( async (userId: number, year: number, month: number, day: number): Promise => { let imageCount = 0; let pixelCount = 0; + let pixelStepCount = 0; for await ( const generation of generationStore.listBy("fromId", { @@ -33,11 +31,15 @@ export const getUserDailyStats = kvMemoize( ) { imageCount++; pixelCount += (generation.value.info?.width ?? 0) * (generation.value.info?.height ?? 0); + pixelStepCount += (generation.value.info?.width ?? 0) * + (generation.value.info?.height ?? 0) * + (generation.value.info?.steps ?? 0); } return { imageCount, pixelCount, + pixelStepCount, timestamp: Date.now(), }; }, @@ -51,6 +53,7 @@ export const getUserDailyStats = kvMemoize( : hoursToMilliseconds(24 * 7 + Math.random() * 24 * 7); }, // should cache if the stats are non-zero - shouldCache: (result) => result.imageCount > 0 || result.pixelCount > 0, + shouldCache: (result) => + result.imageCount > 0 || result.pixelCount > 0 || result.pixelStepCount > 0, }, ); diff --git a/app/userStatsStore.ts b/app/userStatsStore.ts index e3b9aa8..0d5fb64 100644 --- a/app/userStatsStore.ts +++ b/app/userStatsStore.ts @@ -1,38 +1,23 @@ import { minutesToMilliseconds } from "date-fns"; import { Store } from "indexed_kv"; import { info } from "std/log/mod.ts"; -import { JsonSchema, jsonType } from "t_rest/server"; +import { Static, t } from "elysia"; import { db } from "./db.ts"; import { generationStore } from "./generationStore.ts"; -import { kvMemoize } from "./kvMemoize.ts"; +import { kvMemoize } from "../utils/kvMemoize.ts"; import { sortBy } from "std/collections/sort_by.ts"; -export const userStatsSchema = { - type: "object", - properties: { - userId: { type: "number" }, - imageCount: { type: "number" }, - stepCount: { type: "number" }, - pixelCount: { type: "number" }, - pixelStepCount: { type: "number" }, - tagCountMap: { - type: "object", - additionalProperties: { type: "number" }, - }, - timestamp: { type: "number" }, - }, - required: [ - "userId", - "imageCount", - "stepCount", - "pixelCount", - "pixelStepCount", - "tagCountMap", - "timestamp", - ], -} as const satisfies JsonSchema; +export const userStatsSchema = t.Object({ + userId: t.Number(), + imageCount: t.Number(), + stepCount: t.Number(), + pixelCount: t.Number(), + pixelStepCount: t.Number(), + tagCountMap: t.Record(t.String(), t.Number()), + timestamp: t.Number(), +}); -export type UserStats = jsonType; +export type UserStats = Static; type UserStatsIndices = { userId: number; diff --git a/app/workerInstanceStore.ts b/app/workerInstanceStore.ts index 06325de..144eeca 100644 --- a/app/workerInstanceStore.ts +++ b/app/workerInstanceStore.ts @@ -1,37 +1,23 @@ import { Store } from "indexed_kv"; -import { JsonSchema, jsonType } from "t_rest/server"; +import { Static, t } from "elysia"; import { db } from "./db.ts"; -export const workerInstanceSchema = { - type: "object", - properties: { - // used for counting stats - key: { type: "string" }, - // used for display - name: { type: ["string", "null"] }, - sdUrl: { type: "string" }, - sdAuth: { - type: ["object", "null"], - properties: { - user: { type: "string" }, - password: { type: "string" }, - }, - required: ["user", "password"], - }, - lastOnlineTime: { type: "number" }, - lastError: { - type: "object", - properties: { - message: { type: "string" }, - time: { type: "number" }, - }, - required: ["message", "time"], - }, - }, - required: ["key", "name", "sdUrl", "sdAuth"], -} as const satisfies JsonSchema; +export const workerInstanceSchema = t.Object({ + key: t.String(), + name: t.Nullable(t.String()), + sdUrl: t.String(), + sdAuth: t.Nullable(t.Object({ + user: t.String(), + password: t.String(), + })), + lastOnlineTime: t.Optional(t.Number()), + lastError: t.Optional(t.Object({ + message: t.String(), + time: t.Number(), + })), +}); -export type WorkerInstance = jsonType; +export type WorkerInstance = Static; export const workerInstanceStore = new Store(db, "workerInstances", { indices: {}, diff --git a/bot/broadcastCommand.ts b/bot/broadcastCommand.ts index d1d23cf..5a61182 100644 --- a/bot/broadcastCommand.ts +++ b/bot/broadcastCommand.ts @@ -12,9 +12,9 @@ export async function broadcastCommand(ctx: CommandContext) { return ctx.reply("I don't know who you are."); } - const [admin] = await adminStore.getBy("tgUserId", { value: ctx.from.id }); + const adminEntry = await adminStore.get(["admins", ctx.from.id]); - if (!admin) { + if (!adminEntry.versionstamp) { return ctx.reply("Only a bot admin can use this command."); } diff --git a/bot/txt2imgCommand.ts b/bot/txt2imgCommand.ts index b09847a..0a3087a 100644 --- a/bot/txt2imgCommand.ts +++ b/bot/txt2imgCommand.ts @@ -39,9 +39,9 @@ async function txt2img(ctx: ErisContext, match: string, includeRepliedTo: boolea return; } - const [admin] = await adminStore.getBy("tgUserId", { value: ctx.from.id }); + const adminEntry = await adminStore.get(["admins", ctx.from.id]); - if (admin) { + if (adminEntry.versionstamp) { priority = 1; } else { const jobs = await generationQueue.getAllJobs(); diff --git a/deno.json b/deno.json index 1b838a6..035bb4e 100644 --- a/deno.json +++ b/deno.json @@ -11,6 +11,9 @@ "async": "https://deno.land/x/async@v2.0.2/mod.ts", "date-fns": "https://cdn.skypack.dev/date-fns@2.30.0?dts", "date-fns/utc": "https://cdn.skypack.dev/@date-fns/utc@1.1.0?dts", + "elysia": "https://esm.sh/elysia@0.7.21?dev", + "elysia/eden": "https://esm.sh/@elysiajs/eden@0.7.4?external=elysia&dev", + "elysia/swagger": "https://esm.sh/@elysiajs/swagger@0.7.4?external=elysia&dev", "exifreader": "https://esm.sh/exifreader@4.14.1", "file_type": "https://esm.sh/file-type@18.5.0", "grammy": "https://lib.deno.dev/x/grammy@1/mod.ts", @@ -40,8 +43,6 @@ "std/path/": "https://deno.land/std@0.204.0/path/", "swr": "https://esm.sh/swr@2.2.4?external=react&dev", "swr/mutation": "https://esm.sh/swr@2.2.4/mutation?external=react&dev", - "t_rest/client": "https://esm.sh/ty-rest@0.4.1/client", - "t_rest/server": "https://esm.sh/ty-rest@0.4.1/server", "twind/core": "https://esm.sh/@twind/core@1.1.3", "twind/preset-tailwind": "https://esm.sh/@twind/preset-tailwind@1.1.4", "ulid": "https://deno.land/x/ulid@v0.3.0/mod.ts" diff --git a/deno.lock b/deno.lock index 81245d0..b82d793 100644 --- a/deno.lock +++ b/deno.lock @@ -299,10 +299,14 @@ "https://deno.land/x/swc@0.2.1/lib/deno_swc.generated.js": "a112eb5a4871bcbd5b660d3fd9d7afdd5cf2bb9e135eb9b04aceaa24d16481a0", "https://deno.land/x/swc@0.2.1/mod.ts": "9e03f5daf4c2a25208e34e19ce3e997d2e23a5a11ee8c8ed58023b0e3626073f", "https://deno.land/x/ulid@v0.3.0/mod.ts": "f7ff065b66abd485051fc68af23becef6ccc7e81f7774d7fcfd894a4b2da1984", + "https://esm.sh/@elysiajs/eden@0.7.4?external=elysia&dev": "28d477942e36cdeb3e57791a3acc1f52dd16378b1e639693897b21e98d7295e3", + "https://esm.sh/@elysiajs/swagger@0.7.4?dev": "78520881a756f6c3a69ccc1f306bc32e258c6e0e27f971f68231763fbafd303e", + "https://esm.sh/@elysiajs/swagger@0.7.4?external=elysia&dev": "9732dabca93af3dee2ed1781edb740f6e6bdbe88167cc6a03fa42bef3aadd315", "https://esm.sh/@grammyjs/types@2.0.0": "5e5d1ae9f014cb64f0af36fb91f9d35414d670049b77367818a70cd62092b2ac", "https://esm.sh/@grammyjs/types@2.12.1": "eebe448d3bf3d4fdaeacee50e31b9e3947ce965d2591f1036e5c0273cb7aec36", "https://esm.sh/@twind/core@1.1.3": "bf3e64c13de95b061a721831bf2aed322f6e7335848b06d805168c3212686c1d", "https://esm.sh/@twind/preset-tailwind@1.1.4": "29f5e4774c344ff08d2636e3d86382060a8b40d6bce1c158b803df1823c2804c", + "https://esm.sh/elysia@0.7.21?dev": "6e954350858d5f2fd6c8e61dadf8d9989186f436ccdeb850606359d5e163298c", "https://esm.sh/exifreader@4.14.1": "d0f21973393b0d1a6ed329dac8fcfb2f87ce47fe40b8172e205e7d6d85790bb6", "https://esm.sh/file-type@18.5.0": "f01f6eddb05d365925545e26bd449f934dc042bead882b2795c35c4f48d690a3", "https://esm.sh/openapi-fetch@0.7.6": "43eff6df93e773801cb6ade02b0f47c05d0bfe2fb044adbf2b94fa74ff30d35b", @@ -321,6 +325,7 @@ "https://esm.sh/use-local-storage@3.0.0?external=react&dev": "4cf7fce754a9488940daa76051389421c6341420cae5b8c7d2401158ffe79ec0", "https://esm.sh/v132/@twind/core@1.1.3/denonext/core.mjs": "c2618087a5d5cc406c7dc1079015f4d7cc874bee167f74e9945694896d907b6d", "https://esm.sh/v132/@twind/preset-tailwind@1.1.4/denonext/preset-tailwind.mjs": "3cb9f5cde89e11cd2adad54ff264f62f5000ccb1694cd88874b1856eb2d8d7f7", + "https://esm.sh/v133/@elysiajs/swagger@0.7.4/denonext/swagger.development.mjs": "63cc67eee29123918283027744da23009fc3767088c513f8bfbfef945b7170d1", "https://esm.sh/v133/@formatjs/ecma402-abstract@1.17.2/X-ZS9yZWFjdA/denonext/ecma402-abstract.development.mjs": "178a303e29d73369b4c7c1da5a18393c2baa8742a0e8a45be85f506c17f763d9", "https://esm.sh/v133/@formatjs/fast-memoize@2.2.0/X-ZS9yZWFjdA/denonext/fast-memoize.development.mjs": "4c027b3308490b65dc899f683b1ff9be8da4b7a2e1e32433ef03bcb8f0fdf821", "https://esm.sh/v133/@formatjs/icu-messageformat-parser@2.6.2/X-ZS9yZWFjdA/denonext/icu-messageformat-parser.development.mjs": "68c7a9be44aaa3e35bfe18668e17a2d4a465e11f59f3a1e025ee83fe1bd0b971", @@ -330,14 +335,25 @@ "https://esm.sh/v133/@grammyjs/types@2.0.0/denonext/types.mjs": "7ee61bd0c55a152ea1ffaf3fbe63fce6c103ae836265b23290284d6ba0e3bc5b", "https://esm.sh/v133/@grammyjs/types@2.12.1/denonext/types.mjs": "3636f7a1ca7fef89fa735d832b72193a834bc7f5250b6bf182544be53a6ab218", "https://esm.sh/v133/@remix-run/router@1.9.0/X-ZS9yZWFjdA/denonext/router.development.mjs": "97f96f031a7298b0afbbadb23177559ee9b915314d7adf0b9c6a8d7f452c70e7", + "https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/compiler.development.js": "94129b8b2f26f79bd1ec51f479776e7cdebe521f60bd2eae695634765959f7b9", + "https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/system.development.js": "e2c96ea22f15b9e79e8021b65ae62bfd1b4fd94176dd9c8fc1042fa014c64e9e", + "https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/typebox.development.mjs": "d5776065180eea03471f3f2c992a0cf8289219881c40b68270f543536f5ee029", + "https://esm.sh/v133/@sinclair/typebox@0.31.22/denonext/value.development.js": "41db30d8ba66c836ad5faf931f7ce38d15f943339847b8a7491014f8fe2be451", "https://esm.sh/v133/client-only@0.0.1/X-ZS9yZWFjdA/denonext/client-only.development.mjs": "b7efaef2653f7f628d084e24a4d5a857c9cd1a5e5060d1d9957e185ee98c8a28", + "https://esm.sh/v133/cookie@0.5.0/denonext/cookie.development.mjs": "04344caac1f8341ced6ec11d15b95a36dbda70a8ebb6a809b20b5912a5de2b8b", "https://esm.sh/v133/crc-32@0.3.0/denonext/crc-32.mjs": "92bbd96cd5a92e45267cf4b3d3928b9355d16963da1ba740637fb10f1daca590", + "https://esm.sh/v133/elysia@0.7.21/denonext/elysia.development.mjs": "4bf283f62b935737d33ab4e3e59195ba9cb0ea2fc2bac90c8e9fa00a44b5e575", + "https://esm.sh/v133/eventemitter3@5.0.1/denonext/eventemitter3.development.mjs": "e19a3e8d30d7564ce7b394636fc66ef273c6394bb6c5820b9a9bfba7ec4ac2dd", "https://esm.sh/v133/exifreader@4.14.1/denonext/exifreader.mjs": "691e1c1d1337ccaf092bf39115fdac56bf69e93259d04bb03985e918202317ab", + "https://esm.sh/v133/fast-decode-uri-component@1.0.1/denonext/fast-decode-uri-component.development.mjs": "004912e1f391fccf376cf58ff1ab06d6d4ed241cab9c7bed23756091bedbdc36", + "https://esm.sh/v133/fast-querystring@1.1.2/denonext/fast-querystring.development.mjs": "da06ef49d7e834dbac2b3b189de02c80e71cd5942b339de0011ab84aae0de0ff", "https://esm.sh/v133/file-type@18.5.0/denonext/file-type.mjs": "785cac1bc363448647871e1b310422e01c9cfb7b817a68690710b786d3598311", "https://esm.sh/v133/hoist-non-react-statics@3.3.2/X-ZS9yZWFjdA/denonext/hoist-non-react-statics.development.mjs": "fcafac9e3c33810f18ecb43dfc32ce80efc88adc63d3f49fd7ada0665d2146c6", "https://esm.sh/v133/ieee754@1.2.1/denonext/ieee754.mjs": "9ec2806065f50afcd4cf3f3f2f38d93e777a92a5954dda00d219f57d4b24832f", "https://esm.sh/v133/inherits@2.0.4/denonext/inherits.mjs": "8095f3d6aea060c904fb24ae50f2882779c0acbe5d56814514c8b5153f3b4b3b", "https://esm.sh/v133/intl-messageformat@10.5.3/X-ZS9yZWFjdA/denonext/intl-messageformat.development.mjs": "d6b701c562c51ec4b4e1deb641c6623986abe304290bbb291e6404cb6a31dc41", + "https://esm.sh/v133/lodash.clonedeep@4.5.0/denonext/lodash.clonedeep.development.mjs": "f44c863cb41bcd2714103a36d97e8eb8268f56070b5cd56dcfae26c556e6622e", + "https://esm.sh/v133/memoirist@0.1.4/denonext/memoirist.development.mjs": "e19f8379a684345ebcab9a688b0b188f29120f4bf1122588b779113cd1ec0e4e", "https://esm.sh/v133/openapi-fetch@0.7.6/denonext/openapi-fetch.mjs": "1ec8ed23c9141c7f4e58de06f84525e310fe7dda1aeaf675c8edafb3d8292cfc", "https://esm.sh/v133/peek-readable@5.0.0/denonext/peek-readable.mjs": "5e799ea86e9c501873f687eda9c891a75ed55ba666b5dd822eaa3d28a8a5f2b1", "https://esm.sh/v133/png-chunk-text@1.0.0/denonext/png-chunk-text.mjs": "e8bb89595ceab2603531693319da08a9dd90d51169437de47a73cf8bac7baa11", @@ -364,6 +380,10 @@ "https://esm.sh/v133/ty-rest@0.4.1/denonext/server.js": "00be1165ac96313b077629556a1587d4662f4e23bb0e815b945cc95cd3582370", "https://esm.sh/v133/use-local-storage@3.0.0/X-ZS9yZWFjdA/denonext/use-local-storage.development.mjs": "1fdc00893fe7dac56e95e2817e05d413b674f5cb5a1c6afd8994e25c9e2a56c8", "https://esm.sh/v133/use-sync-external-store@1.2.0/X-ZS9yZWFjdA/denonext/shim.development.js": "5388baf48494f5abe76f8a4a30810c48e828b52f1298826aa9a3f3378e2b533f", - "https://esm.sh/v133/util-deprecate@1.0.2/denonext/util-deprecate.mjs": "f69f67cf655c38428b0934e0f7c865c055834a87cc3866b629d6b2beb21005e9" + "https://esm.sh/v133/util-deprecate@1.0.2/denonext/util-deprecate.mjs": "f69f67cf655c38428b0934e0f7c865c055834a87cc3866b629d6b2beb21005e9", + "https://esm.sh/v134/@elysiajs/eden@0.7.4/X-ZS9lbHlzaWE/denonext/eden.development.mjs": "c79c9f105d2b2062b882240272faa81cab1cbdde1113290d2d56c15ed9479986", + "https://esm.sh/v134/@elysiajs/swagger@0.7.4/X-ZS9lbHlzaWE/denonext/swagger.development.mjs": "4aa74e7b8108e9bacd7830c374ef24ee1b4100d65eef045bf6802ceaa3635554", + "https://esm.sh/v134/@sinclair/typebox@0.31.26/X-ZS9lbHlzaWE/denonext/typebox.development.mjs": "5f24db9ca594ccb61f2ae58df80a1f7690d21e117772e7b00babdb290c0747df", + "https://esm.sh/v134/lodash.clonedeep@4.5.0/X-ZS9lbHlzaWE/denonext/lodash.clonedeep.development.mjs": "f44c863cb41bcd2714103a36d97e8eb8268f56070b5cd56dcfae26c556e6622e" } } diff --git a/ui/AdminsPage.tsx b/ui/AdminsPage.tsx index 4e76406..abf519a 100644 --- a/ui/AdminsPage.tsx +++ b/ui/AdminsPage.tsx @@ -11,18 +11,18 @@ export function AdminsPage(props: { sessionId: string | null }) { const addDialogRef = useRef(null); const getSession = useSWR( - sessionId ? ["sessions/{sessionId}", "GET", { params: { sessionId } }] as const : null, + sessionId ? ["/sessions/:sessionId", { params: { sessionId } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getUser = useSWR( getSession.data?.userId - ? ["users/{userId}", "GET", { params: { userId: String(getSession.data.userId) } }] as const + ? ["/users/:userId", { params: { userId: String(getSession.data.userId) } }] as const : null, (args) => fetchApi(...args).then(handleResponse), ); const getAdmins = useSWR( - ["admins", "GET", {}] as const, + ["/admins", { method: "GET" }] as const, (args) => fetchApi(...args).then(handleResponse), ); @@ -32,14 +32,10 @@ export function AdminsPage(props: { sessionId: string | null }) {