diff --git a/.dockerignore b/.dockerignore index 15187ac..ba09860 100644 --- a/.dockerignore +++ b/.dockerignore @@ -8,4 +8,5 @@ README.md .prettierignore .gitignore .env.example +.env .svelte-kit \ No newline at end of file diff --git a/.env.example b/.env.example index 0e06d31..e7fd614 100644 --- a/.env.example +++ b/.env.example @@ -17,8 +17,14 @@ USERINFO__ROUTE= # Identifier you'd like to use to link avatars with USERINFO__IDENTIFIER=preferred_username -# Comma-separated list of allowed image types -ALLOWED_TYPES=image/jpg,image/jpeg,image/png,image/png,image/webp,image/gif +# Comma-separated list of any extra formats you'd like to be available +IMAGES__EXTRA_OUTPUT_FORMATS=jpeg +# Comma-separated list of output resolutions you'd like to be available - Default 1024,512,256,128,64,32 +IMAGES__OUTPUT_RESOLUTIONS=1024,512,256,128,64,48,32 +# Default selected resolution - Default 512 +IMAGES__DEFAULT_RESOLUTION=512 +# Comma-separated list of permitted image mimetypes +IMAGES__ALLOWED_INPUT_FORMATS=image/jpg,image/jpeg,image/png,image/webp,image/gif # Prisma database URL DATABASE_URL=file:../.data/data.db diff --git a/Dockerfile b/Dockerfile index 38fc4dc..ed528ea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ FROM base AS release COPY --from=prisma /usr/src/app/prisma prisma COPY --from=prisma /usr/src/app/node_modules node_modules COPY --from=build /usr/src/app/build build -COPY --from=build /usr/src/app/static static +COPY --from=build /usr/src/app/assets assets COPY --from=build /usr/src/app/package.json . EXPOSE 3000/tcp diff --git a/static/default/1024.png b/assets/default.png similarity index 100% rename from static/default/1024.png rename to assets/default.png diff --git a/bun.lockb b/bun.lockb deleted file mode 100755 index 1e94bea..0000000 Binary files a/bun.lockb and /dev/null differ diff --git a/package.json b/package.json index 1aad500..a3f22aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ava-node", - "version": "1.1.1", + "version": "1.2.0", "private": true, "scripts": { "dev": "vite dev", diff --git a/src/app.d.ts b/src/app.d.ts index 743f07b..a32c3e8 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -8,6 +8,9 @@ declare global { // interface PageState {} // interface Platform {} } + + declare const __APP_NAME__: string; + declare const __APP_VERSION__: string; } export {}; diff --git a/src/lib/avatars.ts b/src/lib/avatars.ts index 11d4c14..8e31ec6 100644 --- a/src/lib/avatars.ts +++ b/src/lib/avatars.ts @@ -3,56 +3,167 @@ import { existsSync } from "node:fs" import { join } from "node:path" import { prisma } from "./clientsingleton" import configuration from "./configuration" -import Sharp from "sharp" +import Sharp, { type FormatEnum } from "sharp" import mime from "mime" // todo: make customizable export const avatarDirectory = "./.data/avatars" -export const defaultAvatarDirectory = "./static/default/" -export const renderSizes = [ 1024, 512, 256, 128, 64, 32 ] +export const defaultAvatarDirectory = "./.data/defaultAvatar/" -export async function getPathToAvatarForUid(uid?: string, size: string = "512") { +await mkdir(defaultAvatarDirectory, { recursive: true }) + +export const missingAvatarQueue = new Map< + string, + Promise +>() + +/** + * @description Generate an avatar at the selected size and format + * @param path Path to the avatar directory + * @param size Avatar size + * @param fmt Avatar format + * @returns Promise that resolves to the path of the newly-generated avatar + */ +export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sharp.FormatEnum) { + let qid = JSON.stringify([path, size, fmt]) + if (missingAvatarQueue.has(qid)) + return missingAvatarQueue.get(qid)! + + let prom = new Promise(async (res, rej) => { + // locate best quality currently available + const av = await readdir(path) + // this can probably be done better but I DON'T GIVE A FUCK !!!! + const pathToBestQualityImg = + path == defaultAvatarDirectory + ? "./assets/default.png" + : join( + path, + av + .map(e => [parseInt(e.match(/(.*)\..*/)?.[1] || "", 10), e] as [number, string]) + .sort(([a],[b]) => b - a) + [0][1] + ) + + const buf = await Bun.file(pathToBestQualityImg).arrayBuffer() + res(writeAvatar(path, await renderAvatar(buf, size, fmt))) + missingAvatarQueue.delete(qid) + }) + + missingAvatarQueue.set(qid, prom) + return prom +} + +/** + * @description Get the path of an avatar for a user + * @param uid UID of the user + * @param size Avatar size + * @param fmt Avatar format + * @returns Path to the avatar of a user + */ +export async function getPathToAvatarForUid(uid?: string, size: number = configuration.images.default_resolution, fmt?: string) { if (uid?.includes("/")) throw Error("UID cannot include /") + // check if format is valid + if (![undefined, ...configuration.images.extra_output_types].includes(fmt as keyof FormatEnum)) + return + + // if no uid / no avatar folder then default to the default avatar directory let userAvatarDirectory = uid ? join(avatarDirectory, uid) : defaultAvatarDirectory if (!existsSync(userAvatarDirectory)) userAvatarDirectory = defaultAvatarDirectory - let sizes = await readdir(userAvatarDirectory) - const targetAvatar = sizes.find(s => s.match(/(.*)\..*/)?.[1] == size) + // bind a makeMissing function + const makeMissing = generateMissingAvatar.bind(null, userAvatarDirectory, size, fmt as keyof FormatEnum) + + // get directory to extract imgs from + let targetAvatarDirectory = join(userAvatarDirectory, fmt||"") + + // if there's no images for the specified fmt, generate new ones + if (!existsSync(targetAvatarDirectory)) + return makeMissing() + + let sizes = await readdir(targetAvatarDirectory, {withFileTypes: true}) + + const targetAvatar = sizes.filter(e => e.isFile()).find( + s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size + ) + if (targetAvatar) - return join(userAvatarDirectory, targetAvatar) + return join(targetAvatarDirectory, targetAvatar.name) + else if (configuration.images.output_resolutions.includes(size)) + return makeMissing() // generate image at this size for the specified format } -export async function getPathToAvatarForIdentifier(identifier: string, size: string = "512") { +export async function getPathToAvatarForIdentifier(identifier: string, size: number = configuration.images.default_resolution, fmt?: string) { let user = await prisma.user.findFirst({ where: { identifier } }) - return getPathToAvatarForUid(user?.userId, size) + return getPathToAvatarForUid(user?.userId, size, fmt) } -export async function rerenderAvatar(bin: ArrayBuffer, squareSize: number) { +/** + * @description Render an avatar at the specified size and format + * @param bin Image to rerender + * @param squareSize Target size of the new image + * @param format Image target format + * @returns Avatar buffer and other information which may be useful + */ +export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number, format?: keyof Sharp.FormatEnum) { + const opBegin = Date.now() let img = Sharp(bin); let metadata = await img.metadata(); - squareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize) + let realSquareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize) img.resize({ - width: squareSize, - height: squareSize, + width: realSquareSize, + height: realSquareSize, fit: "cover" }) - return img.toBuffer() + if (format) img.toFormat(format) + + return { + buf: await img.toBuffer(), + extension: format || metadata.format, + requestedFormat: format, + squareSize, + time: Date.now()-opBegin + } +} + +export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited>) { + const targetDir = join( + avatarDir, + ...( + renderedAvatar.requestedFormat + ? [ renderedAvatar.requestedFormat ] + : [] + ) + ) + + await mkdir(targetDir, {recursive: true}) + + const targetPath = join( + targetDir, + `${renderedAvatar.squareSize}.${renderedAvatar.extension}` + ) + + await Bun.write( + targetPath, + renderedAvatar.buf + ) + + return targetPath } export async function setNewAvatar(uid: string, avatar?: File) { if (uid?.includes("/")) throw Error("UID cannot include /") - + // Delete current avatar directory const userAvatarDirectory = join(avatarDirectory, uid) await rm(userAvatarDirectory, { recursive: true, force: true }) @@ -62,24 +173,25 @@ export async function setNewAvatar(uid: string, avatar?: File) { // make a new directory mkdir(userAvatarDirectory, { recursive: true }) - let time: Record = {} + let time: Record> = {} // render all images and write to disk let avatarData = await avatar.arrayBuffer() - let fileExtension = mime.getExtension(avatar.type) - for (let x of renderSizes) { - console.log(x) - try { - let start = Date.now() - let rerenderedAvatarData = await rerenderAvatar(avatarData, x) - await writeFile( - join(userAvatarDirectory, `${x}.${fileExtension}`), - rerenderedAvatarData - ) - time[x] = Date.now()-start - } catch (e) { // clear pfp and throw if error encountered - await rm(userAvatarDirectory, { recursive: true, force: true }) - throw e + for (let x of configuration.images.output_resolutions) { + time[x] = Object.fromEntries([ + ["input", -1], + ...configuration.images.extra_output_types + .map( e => [ e, -1 ] ) + ]) + for (let t of [undefined, ...configuration.images.extra_output_types]) { + try { + const rendered = await renderAvatar(avatarData, x, t) + await writeAvatar(userAvatarDirectory, rendered) + time[x][t || "input"] = rendered.time + } catch (e) { // clear pfp and throw if error encountered + await rm(userAvatarDirectory, { recursive: true, force: true }) + throw e + } } } diff --git a/src/lib/configuration.ts b/src/lib/configuration.ts index 5245db6..71cd8c5 100644 --- a/src/lib/configuration.ts +++ b/src/lib/configuration.ts @@ -1,3 +1,4 @@ +import { format, type FormatEnum } from "sharp" const configuration = { oauth2: { endpoints: { @@ -15,6 +16,23 @@ const configuration = { route: process.env.USERINFO__ROUTE!, identifier: process.env.USERINFO__IDENTIFIER! }, - allowed_types: process.env.ALLOWED_TYPES?.split(",") || [] + images: { + permitted_input: + ( + process.env.ALLOWED_TYPES + || process.env.IMAGES__ALLOWED_INPUT_FORMATS + )?.split(",") || [], + default_resolution: parseInt((process.env.IMAGES__DEFAULT_RESOLUTION || "").toString(), 10) || 512, + extra_output_types: + process.env.IMAGES__EXTRA_OUTPUT_FORMATS + ?.split(",") + .filter(e => e in format) as (keyof FormatEnum)[] + || [], + output_resolutions: + process.env.IMAGES__OUTPUT_RESOLUTIONS + ?.split(",") + .map(e => parseInt(e,10)) + || [ 1024, 512, 256, 128, 64, 32 ] + } } export default configuration \ No newline at end of file diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 548890f..0d35c12 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -4,6 +4,8 @@ import ava from "../assets/ava_icon.svg?raw" import type { User } from "$lib/types"; export let data: { user?: User }; + + const buildName = `${__APP_NAME__} ${__APP_VERSION__}` ava @@ -50,12 +52,16 @@ nav > * { display: flex; /* Flexbox fixes everything! */ } - a, small { + a, small, footer { color: var(--link) } code, pre { font-family: "Space Mono", monospace, monospace; } + footer { + margin: 1em 0; + text-align: center; + } @@ -69,4 +75,9 @@ + + \ No newline at end of file diff --git a/src/routes/avatar/[identifier]/[[size]]/+server.ts b/src/routes/avatar/[identifier]/[[size]]/+server.ts index 2fb2e86..61d69f2 100644 --- a/src/routes/avatar/[identifier]/[[size]]/+server.ts +++ b/src/routes/avatar/[identifier]/[[size]]/+server.ts @@ -3,11 +3,15 @@ import { error } from '@sveltejs/kit'; import { readFile } from 'fs/promises'; import mime from "mime"; -export async function GET({ params : { identifier, size } }) { - let avPath = await getPathToAvatarForIdentifier(identifier, size) +export async function GET({ params : { identifier, size }, url }) { + let sz = size ? parseInt(size, 10) : undefined + if (sz && Number.isNaN(sz)) + throw error(400, "Invalid number") + + let avPath = await getPathToAvatarForIdentifier(identifier, sz, url.searchParams.get("format") || undefined) if (!avPath) - throw error(404, "Avatar at this size not found") + throw error(404, "Avatar at this size not found, or this is an invalid format") return new Response(await readFile(avPath), { headers: { diff --git a/src/routes/set/+page.server.ts b/src/routes/set/+page.server.ts index 892bb37..92f6b14 100644 --- a/src/routes/set/+page.server.ts +++ b/src/routes/set/+page.server.ts @@ -1,7 +1,7 @@ import {getRequestUser, launchLogin} from "$lib/oidc" import configuration from "$lib/configuration.js"; import { fail } from "@sveltejs/kit"; -import { avatarDirectory, renderSizes, setNewAvatar } from "$lib/avatars.js"; +import { avatarDirectory, setNewAvatar } from "$lib/avatars.js"; import { join } from "path"; export async function load({ request, parent, url }) { const { user } = await parent(); @@ -10,8 +10,8 @@ export async function load({ request, parent, url }) { return { url: url.toString(), - allowedImageTypes: configuration.allowed_types, - renderSizes + allowedImageTypes: configuration.images.permitted_input, + renderSizes: configuration.images.output_resolutions } } @@ -27,15 +27,17 @@ export const actions = { newAvatar = submission.get("newAvatar") if (newAvatar !== undefined && !(newAvatar instanceof File)) return fail(400, {success: false, error: "incorrect entry type"}) - if (!configuration.allowed_types.includes(newAvatar.type)) + if (!configuration.images.permitted_input.includes(newAvatar.type)) return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`}) } - let time = await setNewAvatar(user.sub, newAvatar) + let timing = await setNewAvatar(user.sub, newAvatar) return { success: true, - message: Object.entries(time) - .map(([res, time]) => `${res}x${res} took ${time}ms to render`) + message: Object.entries(timing) + .map(([res, time]) => `render ${res}x${res}: ${ + Object.entries(time).map(([type, t]) => `${type}: ${t}ms`).join(", ") + }`) .join("\n") || "No timing information available" } diff --git a/static/default/128.png b/static/default/128.png deleted file mode 100644 index de2974c..0000000 Binary files a/static/default/128.png and /dev/null differ diff --git a/static/default/256.png b/static/default/256.png deleted file mode 100644 index 2c114b4..0000000 Binary files a/static/default/256.png and /dev/null differ diff --git a/static/default/32.png b/static/default/32.png deleted file mode 100644 index 6d35513..0000000 Binary files a/static/default/32.png and /dev/null differ diff --git a/static/default/512.png b/static/default/512.png deleted file mode 100644 index 2a8f681..0000000 Binary files a/static/default/512.png and /dev/null differ diff --git a/static/default/64.png b/static/default/64.png deleted file mode 100644 index 6b44c9a..0000000 Binary files a/static/default/64.png and /dev/null differ diff --git a/static/default/index.png b/static/default/index.png deleted file mode 100644 index 2a8f681..0000000 Binary files a/static/default/index.png and /dev/null differ diff --git a/vite.config.ts b/vite.config.ts index bbf8c7d..899cdbd 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,12 @@ import { sveltekit } from '@sveltejs/kit/vite'; import { defineConfig } from 'vite'; +import pkg from "./package.json" export default defineConfig({ - plugins: [sveltekit()] + plugins: [sveltekit()], + + define: { + '__APP_NAME__': JSON.stringify(pkg.name), + '__APP_VERSION__': JSON.stringify(pkg.version) + } });