Compare commits

..

No commits in common. "main" and "bun-deprecated" have entirely different histories.

20 changed files with 250 additions and 1060 deletions

View file

@ -2,6 +2,9 @@
FROM node:lts-alpine AS base FROM node:lts-alpine AS base
WORKDIR /usr/src/app WORKDIR /usr/src/app
FROM oven/bun:1-debian AS base2
WORKDIR /usr/src/app
FROM base AS install FROM base AS install
RUN mkdir -p /temp/dev RUN mkdir -p /temp/dev
COPY package.json package-lock.json /temp/dev/ COPY package.json package-lock.json /temp/dev/
@ -16,14 +19,18 @@ COPY --from=install /temp/prod/node_modules node_modules
COPY . . COPY . .
RUN npx prisma generate RUN npx prisma generate
FROM base AS build FROM base AS prisma-dev
COPY --from=install /temp/dev/node_modules node_modules COPY --from=install /temp/dev/node_modules node_modules
COPY . . COPY . .
RUN npx prisma generate RUN npx prisma generate
# vite build
RUN NODE_ENV=production npm run build
FROM base AS release FROM base2 AS build
COPY --from=prisma-dev /usr/src/app/node_modules node_modules
COPY . .
# vite build
RUN NODE_ENV=production bun --bun run build
FROM base2 AS release
COPY --from=prisma /usr/src/app/prisma prisma COPY --from=prisma /usr/src/app/prisma prisma
COPY --from=prisma /usr/src/app/node_modules node_modules COPY --from=prisma /usr/src/app/node_modules node_modules
COPY --from=build /usr/src/app/build build COPY --from=build /usr/src/app/build build
@ -31,4 +38,4 @@ COPY --from=build /usr/src/app/assets assets
COPY --from=build /usr/src/app/package.json . COPY --from=build /usr/src/app/package.json .
EXPOSE 3000/tcp EXPOSE 3000/tcp
CMD [ "node", "build" ] CMD [ "bun", "run", "./build/index.js" ]

View file

@ -1,6 +1,6 @@
# ava # ava
ava is a simple avatar server written in TypeScript for Node with SvelteKit and OIDC support. ava is a simple avatar server written in TypeScript for Bun with SvelteKit and OIDC support.
This exists for a few reasons: This exists for a few reasons:
1. I wanted to learn SvelteKit 1. I wanted to learn SvelteKit
2. I wanted to try implementing OAuth2/OIDC 2. I wanted to try implementing OAuth2/OIDC

BIN
bun.lockb Executable file

Binary file not shown.

924
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "ava-node", "name": "ava",
"version": "1.3.2", "version": "1.2.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@ -12,13 +12,13 @@
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/node": "^20.14.10", "@types/bun": "^1.1.6",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2", "prettier-plugin-svelte": "^3.1.2",
"prisma": "^5.16.2", "prisma": "^5.16.2",
"svelte": "^4.2.7", "svelte": "^4.2.7",
"svelte-adapter-bun": "^0.5.2",
"svelte-check": "^3.6.0", "svelte-check": "^3.6.0",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.0.0", "typescript": "^5.0.0",
@ -29,9 +29,9 @@
"@fontsource-variable/inter": "^5.0.18", "@fontsource-variable/inter": "^5.0.18",
"@fontsource-variable/noto-sans-mono": "^5.0.20", "@fontsource-variable/noto-sans-mono": "^5.0.20",
"@prisma/client": "5.16.2", "@prisma/client": "5.16.2",
"@sveltejs/kit": "^2.0.0",
"mime": "^4.0.4", "mime": "^4.0.4",
"sharp": "^0.33.4" "sharp": "^0.33.4",
"@sveltejs/kit": "^2.0.0"
}, },
"trustedDependencies": [ "trustedDependencies": [
"@prisma/client", "@prisma/client",

View file

@ -1,12 +0,0 @@
-- CreateTable
CREATE TABLE "Avatar" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
CONSTRAINT "Avatar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Avatar_id_key" ON "Avatar"("id");
-- CreateIndex
CREATE UNIQUE INDEX "Avatar_userId_key" ON "Avatar"("userId");

View file

@ -1,3 +0,0 @@
-- AlterTable
ALTER TABLE "Avatar" ADD COLUMN "altText" TEXT;
ALTER TABLE "Avatar" ADD COLUMN "source" TEXT;

View file

@ -1,2 +0,0 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "name" TEXT;

View file

@ -21,14 +21,4 @@ model Token {
model User { model User {
userId String @id @unique userId String @id @unique
identifier String identifier String
name String?
avatar Avatar?
}
model Avatar {
id String @id @unique @default(uuid())
user User @relation(fields: [userId], references: [userId])
userId String @unique
altText String?
source String?
} }

View file

@ -1,10 +1,9 @@
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises" import { mkdir, readdir, rm } from "node:fs/promises"
import { existsSync } from "node:fs" import { existsSync } from "node:fs"
import { join } from "node:path" import { join } from "node:path"
import { prisma } from "./clientsingleton" import { prisma } from "./clientsingleton"
import configuration from "./configuration" import configuration from "./configuration"
import Sharp, { type FormatEnum } from "sharp" import Sharp, { type FormatEnum } from "sharp"
import type { Avatar } from "@prisma/client"
// todo: make customizable // todo: make customizable
export const avatarDirectory = "./.data/avatars" export const avatarDirectory = "./.data/avatars"
@ -44,7 +43,7 @@ export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sh
[0][1] [0][1]
) )
const buf = await readFile(pathToBestQualityImg) const buf = await Bun.file(pathToBestQualityImg).arrayBuffer()
res(writeAvatar(path, await renderAvatar(buf, size, fmt))) res(writeAvatar(path, await renderAvatar(buf, size, fmt)))
missingAvatarQueue.delete(qid) missingAvatarQueue.delete(qid)
}) })
@ -105,42 +104,6 @@ export async function getPathToAvatarForIdentifier(identifier: string, size: num
return getPathToAvatarForUid(user?.userId, size, fmt) return getPathToAvatarForUid(user?.userId, size, fmt)
} }
function sanitizeAvatar(avatar: Avatar | null) {
return avatar
? {
altText: avatar.altText || "",
source: avatar.source || "",
default: false
}
: {
altText: "Default profile picture",
source: "https://git.sucks.win/split/ava",
default: true
}
}
export async function getMetadataForIdentifier(identifier: string) {
let avatar = await prisma.avatar.findFirst({
where: {
user: {
identifier
}
}
})
return sanitizeAvatar(avatar)
}
export async function getMetadataForUserId(userId: string) {
let avatar = await prisma.avatar.findFirst({
where: {
userId
}
})
return sanitizeAvatar(avatar)
}
/** /**
* @description Render an avatar at the specified size and format * @description Render an avatar at the specified size and format
* @param bin Image to rerender * @param bin Image to rerender
@ -163,7 +126,7 @@ export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number,
if (format) img.toFormat(format) if (format) img.toFormat(format)
return { return {
img, buf: await img.toBuffer(),
extension: format || metadata.format, extension: format || metadata.format,
requestedFormat: format, requestedFormat: format,
squareSize, squareSize,
@ -188,9 +151,9 @@ export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<Ret
`${renderedAvatar.squareSize}.${renderedAvatar.extension}` `${renderedAvatar.squareSize}.${renderedAvatar.extension}`
) )
await writeFile( await Bun.write(
targetPath, targetPath,
renderedAvatar.img renderedAvatar.buf
) )
return targetPath return targetPath
@ -200,14 +163,9 @@ export async function setNewAvatar(uid: string, avatar?: File) {
if (uid?.includes("/")) if (uid?.includes("/"))
throw Error("UID cannot include /") throw Error("UID cannot include /")
// Delete current avatar directory and avatar database entry // Delete current avatar directory
const userAvatarDirectory = join(avatarDirectory, uid) const userAvatarDirectory = join(avatarDirectory, uid)
await rm(userAvatarDirectory, { recursive: true, force: true }) await rm(userAvatarDirectory, { recursive: true, force: true })
await prisma.avatar.deleteMany({
where: {
userId: uid
}
})
if (!avatar) return {} // we don't need to set a new one if (!avatar) return {} // we don't need to set a new one
@ -236,12 +194,5 @@ export async function setNewAvatar(uid: string, avatar?: File) {
} }
} }
// create new Avatar database entry
await prisma.avatar.create({
data: {
userId: uid
}
})
return time return time
} }

View file

@ -1,32 +1,38 @@
import Sharp, { type FormatEnum } from 'sharp'; import Sharp, { type FormatEnum } from "sharp"
import { env } from '$env/dynamic/private';
const configuration = { const configuration = {
oauth2: { oauth2: {
endpoints: { endpoints: {
authenticate: env.OAUTH2__AUTHENTICATE!, authenticate: process.env.OAUTH2__AUTHENTICATE!,
logout: env.OAUTH2__LOGOUT, logout: process.env.OAUTH2__LOGOUT,
token: env.OAUTH2__GET_TOKEN! token: process.env.OAUTH2__GET_TOKEN!
}, },
client: { client: {
id: env.OAUTH2__CLIENT_ID!, id: process.env.OAUTH2__CLIENT_ID!,
secret: env.OAUTH2__CLIENT_SECRET!, secret: process.env.OAUTH2__CLIENT_SECRET!,
scopes: env.OAUTH2__SCOPES! scopes: process.env.OAUTH2__SCOPES!
} }
}, },
userinfo: { userinfo: {
route: env.USERINFO__ROUTE!, route: process.env.USERINFO__ROUTE!,
identifier: env.USERINFO__IDENTIFIER! identifier: process.env.USERINFO__IDENTIFIER!
}, },
images: { images: {
permitted_input: (env.ALLOWED_TYPES || env.IMAGES__ALLOWED_INPUT_FORMATS)?.split(',') || [], permitted_input:
default_resolution: parseInt((env.IMAGES__DEFAULT_RESOLUTION || '').toString(), 10) || 512, (
extra_output_types: process.env.ALLOWED_TYPES
(env.IMAGES__EXTRA_OUTPUT_FORMATS?.split(',').filter( || process.env.IMAGES__ALLOWED_INPUT_FORMATS
(e) => e in Sharp.format )?.split(",") || [],
) as (keyof FormatEnum)[]) || [], default_resolution: parseInt((process.env.IMAGES__DEFAULT_RESOLUTION || "").toString(), 10) || 512,
output_resolutions: env.IMAGES__OUTPUT_RESOLUTIONS?.split(',').map((e) => parseInt(e, 10)) || [ extra_output_types:
1024, 512, 256, 128, 64, 32 process.env.IMAGES__EXTRA_OUTPUT_FORMATS
] ?.split(",")
} .filter(e => e in Sharp.format) as (keyof FormatEnum)[]
}; || [],
export default configuration; output_resolutions:
process.env.IMAGES__OUTPUT_RESOLUTIONS
?.split(",")
.map(e => parseInt(e,10))
|| [ 1024, 512, 256, 128, 64, 32 ]
}
}
export default configuration

View file

@ -126,13 +126,11 @@ export async function getUserInfo(id: string) {
userId: userInfo.sub, userId: userInfo.sub,
}, },
update: { update: {
identifier: userInfo[configuration.userinfo.identifier], identifier: userInfo[configuration.userinfo.identifier]
name: userInfo.name
}, },
create: { create: {
userId: userInfo.sub, userId: userInfo.sub,
identifier: userInfo[configuration.userinfo.identifier], identifier: userInfo[configuration.userinfo.identifier]
name: userInfo.name
} }
}) })

View file

@ -1,7 +1,5 @@
import { getPathToAvatarForIdentifier } from '$lib/avatars.js'; import { getPathToAvatarForIdentifier } from '$lib/avatars.js';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import { readFile } from 'fs/promises';
import mime from "mime";
export async function GET({ params : { identifier, size }, url }) { export async function GET({ params : { identifier, size }, url }) {
let sz = size ? parseInt(size, 10) : undefined let sz = size ? parseInt(size, 10) : undefined
@ -13,9 +11,5 @@ export async function GET({ params : { identifier, size }, url }) {
if (!avPath) 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, or this is an invalid format")
return new Response(await readFile(avPath), { return new Response(Bun.file(avPath))
headers: {
"Content-Type": mime.getType(avPath) || ""
}
})
} }

View file

@ -1,15 +0,0 @@
import { getMetadataForIdentifier } from '$lib/avatars.js';
import { prisma } from '$lib/clientsingleton.js';
import { error, redirect } from '@sveltejs/kit';
export async function load({ params : { identifier } }) {
let userInfo = await prisma.user.findFirst({ where: { identifier } })
if (!userInfo)
throw error(404, "User not found")
let metadata = await getMetadataForIdentifier(identifier)
return {
name: userInfo.name || identifier,
identifier,
...metadata
}
}

View file

@ -1,46 +0,0 @@
<script lang="ts">
import FilePreviewSet from "../../set/FilePreviewSet.svelte";
export let data: {
identifier: string,
name: string,
altText: string,
source: string
};
</script>
<style>
h5, p {
margin: 0;
}
h5 {
font-weight: normal;
color: var(--link)
}
</style>
<h1>{data.name}'s avatar</h1>
<FilePreviewSet avatarUrl="/avatar/{data.identifier}" style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl="/avatar/{data.identifier}" style="border-radius:100%;" />
<br>
<h5>Alt text</h5>
<p>
{#if data.altText}
{data.altText}
{:else}
<em>No alt text available</em>
{/if}
</p>
<br>
<h5>Source</h5>
<p>
{#if data.source}
{data.source}
{:else}
<em>No source available</em>
{/if}
</p>

View file

@ -1,15 +0,0 @@
import { getMetadataForIdentifier } from '$lib/avatars.js';
import { error, redirect } from '@sveltejs/kit';
export async function GET({ params : { identifier } }) {
return new Response(
JSON.stringify(
await getMetadataForIdentifier(identifier)
),
{
headers: {
"Access-Control-Allow-Origin": "*"
}
}
)
}

View file

@ -1,15 +0,0 @@
import { getMetadataForIdentifier } from '$lib/avatars.js';
import { error, redirect } from '@sveltejs/kit';
export async function GET({ params : { identifier } }) {
let { source } = await getMetadataForIdentifier(identifier)
let finalUrl: URL | string = `/info/${identifier}`
if (source) {
try {
finalUrl = new URL(source)
} catch {}
}
throw redirect(302, finalUrl)
}

View file

@ -1,17 +1,15 @@
import {getRequestUser, launchLogin} from "$lib/oidc" import {getRequestUser, launchLogin} from "$lib/oidc"
import configuration from "$lib/configuration.js"; import configuration from "$lib/configuration.js";
import { fail } from "@sveltejs/kit"; import { fail } from "@sveltejs/kit";
import { avatarDirectory, getMetadataForUserId, setNewAvatar } from "$lib/avatars.js"; import { avatarDirectory, setNewAvatar } from "$lib/avatars.js";
import { join } from "path"; import { join } from "path";
import { prisma } from "$lib/clientsingleton";
export async function load({ request, parent, url }) { export async function load({ request, parent, url }) {
const { user } = await parent(); const { user } = await parent();
if (!user) if (!user)
return launchLogin(url.toString()) launchLogin(url.toString())
return { return {
url: url.toString(), url: url.toString(),
avatar: await getMetadataForUserId(user.sub),
allowedImageTypes: configuration.images.permitted_input, allowedImageTypes: configuration.images.permitted_input,
renderSizes: configuration.images.output_resolutions renderSizes: configuration.images.output_resolutions
} }
@ -23,33 +21,16 @@ export const actions = {
if (!user) if (!user)
return fail(401, {error: "unauthenticated"}) return fail(401, {error: "unauthenticated"})
let submission = Object.fromEntries((await request.formData()).entries()); let submission = await request.formData();
let newAvatar = submission.newAvatar let newAvatar = undefined
let timing: Awaited<ReturnType<typeof setNewAvatar>> = {} if (submission.get("action") != "Clear") {
let isUploadingNewFile = submission.action == "Clear" newAvatar = submission.get("newAvatar")
if (newAvatar !== undefined && !(newAvatar instanceof File))
if ( return fail(400, {success: false, error: "incorrect entry type"})
!isUploadingNewFile // if action isn't already clear
&& newAvatar !== undefined // and avatar is defined
&& (newAvatar instanceof File && newAvatar.size > 0)
) {
if (!configuration.images.permitted_input.includes(newAvatar.type)) if (!configuration.images.permitted_input.includes(newAvatar.type))
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`}) return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
isUploadingNewFile = true
} }
let timing = await setNewAvatar(user.sub, newAvatar)
if (isUploadingNewFile)
timing = await setNewAvatar(user.sub, newAvatar as File|null || undefined)
if (await prisma.avatar.findFirst({ where: { userId: user.sub } }))
await prisma.avatar.update({
where: {
userId: user.sub
},
data: {
altText: typeof submission.altText == "string" ? submission.altText : null,
source: typeof submission.source == "string" ? submission.source : null,
}
})
return { return {
success: true, success: true,

View file

@ -2,66 +2,37 @@
import type { User } from "$lib/types"; import type { User } from "$lib/types";
import FilePreviewSet from "./FilePreviewSet.svelte"; import FilePreviewSet from "./FilePreviewSet.svelte";
export let data: { export let data: {user: User, url: string, allowedImageTypes: string[], renderSizes: number[]};
user: User,
url: string,
avatar: {
altText: string,
source: string,
default: boolean
}
allowedImageTypes: string[],
renderSizes: number[]
};
export let form: { success: true, message: string } | { success: false, error: string } | undefined; export let form: { success: true, message: string } | { success: false, error: string } | undefined;
let files: FileList; let files: FileList;
let fileSrc = `/avatar/${data.user.identifier}/` let fileSrc = `/avatar/${data.user.identifier}/`
$: if (files && files.length >= 0) { $: if (files && files.length >= 0) {
data.avatar.altText = "", data.avatar.source = "", data.avatar.default = false console.log(files.length)
fileSrc = URL.createObjectURL(files.item(0)!) fileSrc = URL.createObjectURL(files.item(0)!)
} else fileSrc = `/avatar/${data.user.identifier}/` } else fileSrc = `/avatar/${data.user.identifier}/`
</script> </script>
<style> <style>
form { form {
flex-direction: column;
}
form, form > .buttons, form > .metadata {
display: flex; display: flex;
gap: 10px; gap: 10px;
align-items: center;
} }
form > .metadata { form > * {
flex-wrap: wrap;
}
form > .metadata > textarea {
height: 3em;
flex-grow: 1;
min-width: 15em;
}
form > .buttons {
justify-content: flex-end;
}
form input {
font-family: "Inter Variable", "Inter", sans-serif; font-family: "Inter Variable", "Inter", sans-serif;
} }
form > input[type="file"] { form > input[type="file"] {
flex-basis: 100%; width: 100%;
min-height: 1em;
} }
form input[type="submit"], form input[type="file"] { form > input[type="submit"], form > input[type="file"] {
cursor: pointer;
}
form input[type="submit"], form input[type="file"], form textarea {
padding: 0.5em 1em; padding: 0.5em 1em;
cursor: pointer;
border-radius: 8px; border-radius: 8px;
border: 1px solid var(--link); border: 1px solid var(--link);
color: var(--text); color: var(--text);
background-color: var(--crust); background-color: var(--crust);
} }
form textarea:disabled {
color: var(--link);
}
form > input[type="file"]::file-selector-button { form > input[type="file"]::file-selector-button {
display: none; display: none;
} }
@ -102,19 +73,13 @@
</p> </p>
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<input type="file" bind:files={files} accept={data.allowedImageTypes.join(",")} name="newAvatar"> <input type="file" bind:files={files} accept={data.allowedImageTypes.join(",")} name="newAvatar">
<div class="metadata"> <input type="submit" name="action" value="Upload">
<textarea name="altText" placeholder="Describe your image" disabled={data.avatar.default}>{data.avatar.altText}</textarea> <input type="submit" name="action" value="Clear">
<textarea name="source" placeholder="Provide a source for your image" disabled={data.avatar.default}>{data.avatar.source}</textarea>
</div>
<div class="buttons">
<input type="submit" name="action" value="Save">
<input type="submit" name="action" value="Clear">
</div>
</form> </form>
{#if form} {#if form}
{#if form.success} {#if form.success}
<details> <details>
<summary><small>Avatar updated successfully</small></summary> <summary><small>Avatar set successfully</small></summary>
<div> <div>
<pre>{form.message}</pre> <pre>{form.message}</pre>
</div> </div>

View file

@ -1,4 +1,4 @@
import adapter from '@sveltejs/adapter-node'; import adapter from 'svelte-adapter-bun';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/** @type {import('@sveltejs/kit').Config} */ /** @type {import('@sveltejs/kit').Config} */