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
WORKDIR /usr/src/app
FROM oven/bun:1-debian AS base2
WORKDIR /usr/src/app
FROM base AS install
RUN mkdir -p /temp/dev
COPY package.json package-lock.json /temp/dev/
@ -16,14 +19,18 @@ COPY --from=install /temp/prod/node_modules node_modules
COPY . .
RUN npx prisma generate
FROM base AS build
FROM base AS prisma-dev
COPY --from=install /temp/dev/node_modules node_modules
COPY . .
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/node_modules node_modules
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 .
EXPOSE 3000/tcp
CMD [ "node", "build" ]
CMD [ "bun", "run", "./build/index.js" ]

View file

@ -1,6 +1,6 @@
# 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:
1. I wanted to learn SvelteKit
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",
"version": "1.3.2",
"name": "ava",
"version": "1.2.1",
"private": true,
"scripts": {
"dev": "vite dev",
@ -12,13 +12,13 @@
"format": "prettier --write ."
},
"devDependencies": {
"@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/node": "^20.14.10",
"@types/bun": "^1.1.6",
"prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.1.2",
"prisma": "^5.16.2",
"svelte": "^4.2.7",
"svelte-adapter-bun": "^0.5.2",
"svelte-check": "^3.6.0",
"tslib": "^2.4.1",
"typescript": "^5.0.0",
@ -29,9 +29,9 @@
"@fontsource-variable/inter": "^5.0.18",
"@fontsource-variable/noto-sans-mono": "^5.0.20",
"@prisma/client": "5.16.2",
"@sveltejs/kit": "^2.0.0",
"mime": "^4.0.4",
"sharp": "^0.33.4"
"sharp": "^0.33.4",
"@sveltejs/kit": "^2.0.0"
},
"trustedDependencies": [
"@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 {
userId String @id @unique
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 { join } from "node:path"
import { prisma } from "./clientsingleton"
import configuration from "./configuration"
import Sharp, { type FormatEnum } from "sharp"
import type { Avatar } from "@prisma/client"
// todo: make customizable
export const avatarDirectory = "./.data/avatars"
@ -44,7 +43,7 @@ export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sh
[0][1]
)
const buf = await readFile(pathToBestQualityImg)
const buf = await Bun.file(pathToBestQualityImg).arrayBuffer()
res(writeAvatar(path, await renderAvatar(buf, size, fmt)))
missingAvatarQueue.delete(qid)
})
@ -105,42 +104,6 @@ export async function getPathToAvatarForIdentifier(identifier: string, size: num
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
* @param bin Image to rerender
@ -163,7 +126,7 @@ export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number,
if (format) img.toFormat(format)
return {
img,
buf: await img.toBuffer(),
extension: format || metadata.format,
requestedFormat: format,
squareSize,
@ -188,9 +151,9 @@ export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<Ret
`${renderedAvatar.squareSize}.${renderedAvatar.extension}`
)
await writeFile(
await Bun.write(
targetPath,
renderedAvatar.img
renderedAvatar.buf
)
return targetPath
@ -200,14 +163,9 @@ export async function setNewAvatar(uid: string, avatar?: File) {
if (uid?.includes("/"))
throw Error("UID cannot include /")
// Delete current avatar directory and avatar database entry
// Delete current avatar directory
const userAvatarDirectory = join(avatarDirectory, uid)
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
@ -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
}

View file

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

View file

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

View file

@ -1,7 +1,5 @@
import { getPathToAvatarForIdentifier } from '$lib/avatars.js';
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
@ -13,9 +11,5 @@ export async function GET({ params : { identifier, size }, url }) {
if (!avPath)
throw error(404, "Avatar at this size not found, or this is an invalid format")
return new Response(await readFile(avPath), {
headers: {
"Content-Type": mime.getType(avPath) || ""
}
})
return new Response(Bun.file(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 configuration from "$lib/configuration.js";
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 { prisma } from "$lib/clientsingleton";
export async function load({ request, parent, url }) {
const { user } = await parent();
if (!user)
return launchLogin(url.toString())
launchLogin(url.toString())
return {
url: url.toString(),
avatar: await getMetadataForUserId(user.sub),
allowedImageTypes: configuration.images.permitted_input,
renderSizes: configuration.images.output_resolutions
}
@ -23,33 +21,16 @@ export const actions = {
if (!user)
return fail(401, {error: "unauthenticated"})
let submission = Object.fromEntries((await request.formData()).entries());
let newAvatar = submission.newAvatar
let timing: Awaited<ReturnType<typeof setNewAvatar>> = {}
let isUploadingNewFile = submission.action == "Clear"
if (
!isUploadingNewFile // if action isn't already clear
&& newAvatar !== undefined // and avatar is defined
&& (newAvatar instanceof File && newAvatar.size > 0)
) {
let submission = await request.formData();
let newAvatar = undefined
if (submission.get("action") != "Clear") {
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))
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
isUploadingNewFile = true
}
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,
}
})
let timing = await setNewAvatar(user.sub, newAvatar)
return {
success: true,

View file

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