Compare commits

..

13 commits

Author SHA1 Message Date
split / May 2360c2c745
allow cors 2024-08-23 00:24:12 -07:00
split / May 06b37a5170
FUCK 2024-08-22 21:56:28 -07:00
split / May e3182b3404
bump version 2024-08-22 21:51:00 -07:00
split / May 072334443a
wait fuck 2024-08-22 21:50:47 -07:00
split / May 968a49bd46
add alt text, source
this code is fucking terrible. i'll clean it up later
2024-08-22 21:31:21 -07:00
split / May b7068ed630
Merge branch 'main' into node-port 2024-07-12 02:03:13 -07:00
split / May 5564b7b356
Convert bun calls to node calls 2024-07-12 01:55:11 -07:00
split / May a5fe1e33e7
Merge branch 'main' into node-port 2024-07-12 01:52:17 -07:00
split / May 6dbf475e32
Merge branch 'main' into node-port 2024-07-11 05:48:13 -07:00
split / May eaf6882f8a
holy fuck how did i 2024-07-11 05:45:02 -07:00
split / May 04b71ecb02
oops 2024-07-11 05:43:41 -07:00
split / May 2d853b4739
Merge branch 'main' into node-port 2024-07-11 05:41:28 -07:00
split / May 469b033e2f
port to Node 2024-07-11 05:40:07 -07:00
20 changed files with 1059 additions and 249 deletions

View file

@ -2,9 +2,6 @@
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/
@ -19,18 +16,14 @@ COPY --from=install /temp/prod/node_modules node_modules
COPY . . COPY . .
RUN npx prisma generate RUN npx prisma generate
FROM base AS prisma-dev FROM base AS build
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
FROM base2 AS build
COPY --from=prisma-dev /usr/src/app/node_modules node_modules
COPY . .
# vite build # vite build
RUN NODE_ENV=production bun --bun run build RUN NODE_ENV=production npm run build
FROM base2 AS release FROM base 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
@ -38,4 +31,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 [ "bun", "run", "./build/index.js" ] CMD [ "node", "build" ]

View file

@ -1,6 +1,6 @@
# ava # ava
ava is a simple avatar server written in TypeScript for Bun with SvelteKit and OIDC support. ava is a simple avatar server written in TypeScript for Node 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

Binary file not shown.

922
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,6 +1,6 @@
{ {
"name": "ava", "name": "ava-node",
"version": "1.2.1", "version": "1.3.2",
"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/bun": "^1.1.6", "@types/node": "^20.14.10",
"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

@ -0,0 +1,12 @@
-- 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

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

View file

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

View file

@ -21,4 +21,14 @@ 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,9 +1,10 @@
import { mkdir, readdir, rm } from "node:fs/promises" import { mkdir, readdir, readFile, rm, writeFile } 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"
@ -43,7 +44,7 @@ export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sh
[0][1] [0][1]
) )
const buf = await Bun.file(pathToBestQualityImg).arrayBuffer() const buf = await readFile(pathToBestQualityImg)
res(writeAvatar(path, await renderAvatar(buf, size, fmt))) res(writeAvatar(path, await renderAvatar(buf, size, fmt)))
missingAvatarQueue.delete(qid) missingAvatarQueue.delete(qid)
}) })
@ -104,6 +105,42 @@ 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
@ -126,7 +163,7 @@ export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number,
if (format) img.toFormat(format) if (format) img.toFormat(format)
return { return {
buf: await img.toBuffer(), img,
extension: format || metadata.format, extension: format || metadata.format,
requestedFormat: format, requestedFormat: format,
squareSize, squareSize,
@ -151,9 +188,9 @@ export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<Ret
`${renderedAvatar.squareSize}.${renderedAvatar.extension}` `${renderedAvatar.squareSize}.${renderedAvatar.extension}`
) )
await Bun.write( await writeFile(
targetPath, targetPath,
renderedAvatar.buf renderedAvatar.img
) )
return targetPath return targetPath
@ -163,9 +200,14 @@ 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 // Delete current avatar directory and avatar database entry
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
@ -194,5 +236,12 @@ 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,38 +1,32 @@
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: process.env.OAUTH2__AUTHENTICATE!, authenticate: env.OAUTH2__AUTHENTICATE!,
logout: process.env.OAUTH2__LOGOUT, logout: env.OAUTH2__LOGOUT,
token: process.env.OAUTH2__GET_TOKEN! token: env.OAUTH2__GET_TOKEN!
}, },
client: { client: {
id: process.env.OAUTH2__CLIENT_ID!, id: env.OAUTH2__CLIENT_ID!,
secret: process.env.OAUTH2__CLIENT_SECRET!, secret: env.OAUTH2__CLIENT_SECRET!,
scopes: process.env.OAUTH2__SCOPES! scopes: env.OAUTH2__SCOPES!
} }
}, },
userinfo: { userinfo: {
route: process.env.USERINFO__ROUTE!, route: env.USERINFO__ROUTE!,
identifier: process.env.USERINFO__IDENTIFIER! identifier: env.USERINFO__IDENTIFIER!
}, },
images: { images: {
permitted_input: permitted_input: (env.ALLOWED_TYPES || env.IMAGES__ALLOWED_INPUT_FORMATS)?.split(',') || [],
( default_resolution: parseInt((env.IMAGES__DEFAULT_RESOLUTION || '').toString(), 10) || 512,
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: extra_output_types:
process.env.IMAGES__EXTRA_OUTPUT_FORMATS (env.IMAGES__EXTRA_OUTPUT_FORMATS?.split(',').filter(
?.split(",") (e) => e in Sharp.format
.filter(e => e in Sharp.format) as (keyof FormatEnum)[] ) as (keyof FormatEnum)[]) || [],
|| [], output_resolutions: env.IMAGES__OUTPUT_RESOLUTIONS?.split(',').map((e) => parseInt(e, 10)) || [
output_resolutions: 1024, 512, 256, 128, 64, 32
process.env.IMAGES__OUTPUT_RESOLUTIONS ]
?.split(",")
.map(e => parseInt(e,10))
|| [ 1024, 512, 256, 128, 64, 32 ]
} }
} };
export default configuration export default configuration;

View file

@ -126,11 +126,13 @@ 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,5 +1,7 @@
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
@ -11,5 +13,9 @@ 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(Bun.file(avPath)) return new Response(await readFile(avPath), {
headers: {
"Content-Type": mime.getType(avPath) || ""
}
})
} }

View file

@ -0,0 +1,15 @@
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

@ -0,0 +1,46 @@
<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

@ -0,0 +1,15 @@
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

@ -0,0 +1,15 @@
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,15 +1,17 @@
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, setNewAvatar } from "$lib/avatars.js"; import { avatarDirectory, getMetadataForUserId, 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)
launchLogin(url.toString()) return 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
} }
@ -21,16 +23,33 @@ export const actions = {
if (!user) if (!user)
return fail(401, {error: "unauthenticated"}) return fail(401, {error: "unauthenticated"})
let submission = await request.formData(); let submission = Object.fromEntries((await request.formData()).entries());
let newAvatar = undefined let newAvatar = submission.newAvatar
if (submission.get("action") != "Clear") { let timing: Awaited<ReturnType<typeof setNewAvatar>> = {}
newAvatar = submission.get("newAvatar") let isUploadingNewFile = submission.action == "Clear"
if (newAvatar !== undefined && !(newAvatar instanceof File))
return fail(400, {success: false, error: "incorrect entry type"}) if (
!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,37 +2,66 @@
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: {user: User, url: string, allowedImageTypes: string[], renderSizes: number[]}; export let data: {
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) {
console.log(files.length) data.avatar.altText = "", data.avatar.source = "", data.avatar.default = false
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 > * { form > .metadata {
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"] {
width: 100%; flex-basis: 100%;
min-height: 1em;
} }
form > input[type="submit"], form > input[type="file"] { form input[type="submit"], form input[type="file"] {
padding: 0.5em 1em;
cursor: pointer; cursor: pointer;
}
form input[type="submit"], form input[type="file"], form textarea {
padding: 0.5em 1em;
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;
} }
@ -73,13 +102,19 @@
</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">
<input type="submit" name="action" value="Upload"> <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"> <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 set successfully</small></summary> <summary><small>Avatar updated successfully</small></summary>
<div> <div>
<pre>{form.message}</pre> <pre>{form.message}</pre>
</div> </div>

View file

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