Compare commits

..

No commits in common. "b7068ed6306a6fdb06268cbe6aee1a62d1c10fcd" and "6dbf475e329143af8a5b766720ddc9c898bc66d4" have entirely different histories.

19 changed files with 49 additions and 211 deletions

View file

@ -8,5 +8,4 @@ README.md
.prettierignore
.gitignore
.env.example
.env
.svelte-kit

View file

@ -17,14 +17,8 @@ USERINFO__ROUTE=
# Identifier you'd like to use to link avatars with
USERINFO__IDENTIFIER=preferred_username
# 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
# Comma-separated list of allowed image types
ALLOWED_TYPES=image/jpg,image/jpeg,image/png,image/png,image/webp,image/gif
# Prisma database URL
DATABASE_URL=file:../.data/data.db

View file

@ -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/assets assets
COPY --from=build /usr/src/app/static static
COPY --from=build /usr/src/app/package.json .
EXPOSE 3000/tcp

BIN
bun.lockb Executable file

Binary file not shown.

View file

@ -1,6 +1,6 @@
{
"name": "ava-node",
"version": "1.2.1",
"version": "1.1.1",
"private": true,
"scripts": {
"dev": "vite dev",

3
src/app.d.ts vendored
View file

@ -8,9 +8,6 @@ declare global {
// interface PageState {}
// interface Platform {}
}
declare const __APP_NAME__: string;
declare const __APP_VERSION__: string;
}
export {};

View file

@ -1,168 +1,58 @@
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
import { mkdir, readdir, rm, writeFile } from "node:fs/promises"
import { existsSync } from "node:fs"
import { join } from "node:path"
import { prisma } from "./clientsingleton"
import configuration from "./configuration"
import Sharp, { type FormatEnum } from "sharp"
import Sharp from "sharp"
import mime from "mime"
// todo: make customizable
export const avatarDirectory = "./.data/avatars"
export const defaultAvatarDirectory = "./.data/defaultAvatar/"
export const defaultAvatarDirectory = "./static/default/"
export const renderSizes = [ 1024, 512, 256, 128, 64, 32 ]
await mkdir(defaultAvatarDirectory, { recursive: true })
export const missingAvatarQueue = new Map<
string,
Promise<string>
>()
/**
* @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<string>(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 readFile(pathToBestQualityImg)
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) {
export async function getPathToAvatarForUid(uid?: string, size: string = "512") {
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
// 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
)
let sizes = await readdir(userAvatarDirectory)
const targetAvatar = sizes.find(s => s.match(/(.*)\..*/)?.[1] == size)
if (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
return join(userAvatarDirectory, targetAvatar)
}
export async function getPathToAvatarForIdentifier(identifier: string, size: number = configuration.images.default_resolution, fmt?: string) {
export async function getPathToAvatarForIdentifier(identifier: string, size: string = "512") {
let user = await prisma.user.findFirst({
where: {
identifier
}
})
return getPathToAvatarForUid(user?.userId, size, fmt)
return getPathToAvatarForUid(user?.userId, size)
}
/**
* @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()
export async function rerenderAvatar(bin: ArrayBuffer, squareSize: number) {
let img = Sharp(bin);
let metadata = await img.metadata();
let realSquareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize)
squareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize)
img.resize({
width: realSquareSize,
height: realSquareSize,
width: squareSize,
height: squareSize,
fit: "cover"
})
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<ReturnType<typeof renderAvatar>>) {
const targetDir = join(
avatarDir,
...(
renderedAvatar.requestedFormat
? [ renderedAvatar.requestedFormat ]
: []
)
)
await mkdir(targetDir, {recursive: true})
const targetPath = join(
targetDir,
`${renderedAvatar.squareSize}.${renderedAvatar.extension}`
)
await writeFile(
targetPath,
renderedAvatar.buf
)
return targetPath
return img.toBuffer()
}
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 })
@ -172,25 +62,24 @@ export async function setNewAvatar(uid: string, avatar?: File) {
// make a new directory
mkdir(userAvatarDirectory, { recursive: true })
let time: Record<number, Record<"input" | keyof Sharp.FormatEnum, number>> = {}
let time: Record<number, number> = {}
// render all images and write to disk
let avatarData = await avatar.arrayBuffer()
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
}
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
}
}

View file

@ -1,4 +1,3 @@
import Sharp, { type FormatEnum } from "sharp"
const configuration = {
oauth2: {
endpoints: {
@ -16,23 +15,6 @@ const configuration = {
route: process.env.USERINFO__ROUTE!,
identifier: process.env.USERINFO__IDENTIFIER!
},
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 Sharp.format) as (keyof FormatEnum)[]
|| [],
output_resolutions:
process.env.IMAGES__OUTPUT_RESOLUTIONS
?.split(",")
.map(e => parseInt(e,10))
|| [ 1024, 512, 256, 128, 64, 32 ]
}
allowed_types: process.env.ALLOWED_TYPES?.split(",") || []
}
export default configuration

View file

@ -4,8 +4,6 @@
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__}`
</script>
<svelte:head>
<title>ava</title>
@ -52,16 +50,12 @@
nav > * {
display: flex; /* Flexbox fixes everything! */
}
a, small, footer {
a, small {
color: var(--link)
}
code, pre {
font-family: "Space Mono", monospace, monospace;
}
footer {
margin: 1em 0;
text-align: center;
}
</style>
</svelte:head>
<body>
@ -75,9 +69,4 @@
</nav>
<slot />
<footer>
{import.meta.env.DEV ? "[DEV]" : ""}
{buildName}
</footer>
</body>

View file

@ -3,15 +3,11 @@ import { error } from '@sveltejs/kit';
import { readFile } from 'fs/promises';
import mime from "mime";
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)
export async function GET({ params : { identifier, size } }) {
let avPath = await getPathToAvatarForIdentifier(identifier, size)
if (!avPath)
throw error(404, "Avatar at this size not found, or this is an invalid format")
throw error(404, "Avatar at this size not found")
return new Response(await readFile(avPath), {
headers: {

View file

@ -1,7 +1,7 @@
import {getRequestUser, launchLogin} from "$lib/oidc"
import configuration from "$lib/configuration.js";
import { fail } from "@sveltejs/kit";
import { avatarDirectory, setNewAvatar } from "$lib/avatars.js";
import { avatarDirectory, renderSizes, 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.images.permitted_input,
renderSizes: configuration.images.output_resolutions
allowedImageTypes: configuration.allowed_types,
renderSizes
}
}
@ -27,17 +27,15 @@ export const actions = {
newAvatar = submission.get("newAvatar")
if (newAvatar !== undefined && !(newAvatar instanceof File))
return fail(400, {success: false, error: "incorrect entry type"})
if (!configuration.images.permitted_input.includes(newAvatar.type))
if (!configuration.allowed_types.includes(newAvatar.type))
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
}
let timing = await setNewAvatar(user.sub, newAvatar)
let time = await setNewAvatar(user.sub, newAvatar)
return {
success: true,
message: Object.entries(timing)
.map(([res, time]) => `render ${res}x${res}: ${
Object.entries(time).map(([type, t]) => `${type}: ${t}ms`).join(", ")
}`)
message: Object.entries(time)
.map(([res, time]) => `${res}x${res} took ${time}ms to render`)
.join("\n")
|| "No timing information available"
}

View file

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 31 KiB

BIN
static/default/128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

BIN
static/default/256.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8 KiB

BIN
static/default/32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
static/default/512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
static/default/64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2 KiB

BIN
static/default/index.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -1,12 +1,6 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vite';
import pkg from "./package.json"
export default defineConfig({
plugins: [sveltekit()],
define: {
'__APP_NAME__': JSON.stringify(pkg.name),
'__APP_VERSION__': JSON.stringify(pkg.version)
}
plugins: [sveltekit()]
});