Compare commits

..

No commits in common. "269db33beca4137245173aee18363401daa89040" and "2360c2c7450d97ef91c9c853ea901d7c0945640e" have entirely different histories.

33 changed files with 2028 additions and 1272 deletions

View file

@ -1,9 +1,8 @@
{ {
"singleQuote": false, "useTabs": true,
"trailingComma": "es5", "singleQuote": true,
"arrowParens": "avoid", "trailingComma": "none",
"tabWidth": 4, "printWidth": 100,
"semi": false,
"plugins": ["prettier-plugin-svelte"], "plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }] "overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
} }

1952
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": "2.0.0", "version": "1.3.2",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
@ -13,24 +13,23 @@
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-node": "^5.2.0", "@sveltejs/adapter-node": "^5.2.0",
"@sveltejs/vite-plugin-svelte": "^4.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0",
"@types/node": "^20.14.10", "@types/node": "^20.14.10",
"prettier": "^3.1.1", "prettier": "^3.1.1",
"prettier-plugin-svelte": "^3.2.6", "prettier-plugin-svelte": "^3.1.2",
"prisma": "^5.16.2", "prisma": "^5.16.2",
"svelte": "^5.0.0", "svelte": "^4.2.7",
"svelte-check": "^4.0.0", "svelte-check": "^3.6.0",
"tslib": "^2.4.1", "tslib": "^2.4.1",
"typescript": "^5.5.0", "typescript": "^5.0.0",
"vite": "^5.4.4" "vite": "^5.0.3"
}, },
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@fluentui/svg-icons": "^1.1.265",
"@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.5.27", "@sveltejs/kit": "^2.0.0",
"mime": "^4.0.4", "mime": "^4.0.4",
"sharp": "^0.33.4" "sharp": "^0.33.4"
}, },

View file

@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE "Token" (
"id" TEXT NOT NULL PRIMARY KEY,
"token" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "Token_id_key" ON "Token"("id");

View file

@ -0,0 +1,21 @@
/*
Warnings:
- Added the required column `owner` to the `Token` table without a default value. This is not possible if the table is not empty.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Token" (
"id" TEXT NOT NULL PRIMARY KEY,
"owner" TEXT NOT NULL,
"token" TEXT NOT NULL,
"refreshToken" TEXT NOT NULL
);
INSERT INTO "new_Token" ("id", "refreshToken", "token") SELECT "id", "refreshToken", "token" FROM "Token";
DROP TABLE "Token";
ALTER TABLE "new_Token" RENAME TO "Token";
CREATE UNIQUE INDEX "Token_id_key" ON "Token"("id");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -0,0 +1,15 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Token" (
"id" TEXT NOT NULL PRIMARY KEY,
"owner" TEXT NOT NULL,
"token" TEXT NOT NULL,
"refreshToken" TEXT
);
INSERT INTO "new_Token" ("id", "owner", "refreshToken", "token") SELECT "id", "owner", "refreshToken", "token" FROM "Token";
DROP TABLE "Token";
ALTER TABLE "new_Token" RENAME TO "Token";
CREATE UNIQUE INDEX "Token_id_key" ON "Token"("id");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -0,0 +1,8 @@
-- CreateTable
CREATE TABLE "User" (
"userId" TEXT NOT NULL PRIMARY KEY,
"identifier" TEXT NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId");

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

@ -1,38 +0,0 @@
-- CreateTable
CREATE TABLE "Token" (
"id" TEXT NOT NULL PRIMARY KEY,
"owner" TEXT NOT NULL,
"token" TEXT NOT NULL,
"refreshToken" TEXT
);
-- CreateTable
CREATE TABLE "User" (
"userId" TEXT NOT NULL PRIMARY KEY,
"identifier" TEXT NOT NULL,
"name" TEXT,
"currentAvatarId" TEXT
);
-- CreateTable
CREATE TABLE "Avatar" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"usedById" TEXT,
"altText" TEXT,
"source" TEXT,
CONSTRAINT "Avatar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE,
CONSTRAINT "Avatar_usedById_fkey" FOREIGN KEY ("usedById") REFERENCES "User" ("userId") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Token_id_key" ON "Token"("id");
-- CreateIndex
CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Avatar_id_key" ON "Avatar"("id");
-- CreateIndex
CREATE UNIQUE INDEX "Avatar_usedById_key" ON "Avatar"("usedById");

View file

@ -1,20 +0,0 @@
/*
Warnings:
- You are about to drop the column `currentAvatarId` on the `User` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_User" (
"userId" TEXT NOT NULL PRIMARY KEY,
"identifier" TEXT NOT NULL,
"name" TEXT
);
INSERT INTO "new_User" ("identifier", "name", "userId") SELECT "identifier", "name", "userId" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -1,34 +0,0 @@
/*
Warnings:
- You are about to drop the column `usedById` on the `Avatar` table. All the data in the column will be lost.
*/
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Avatar" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"altText" TEXT,
"source" TEXT,
CONSTRAINT "Avatar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Avatar" ("altText", "id", "source", "userId") SELECT "altText", "id", "source", "userId" FROM "Avatar";
DROP TABLE "Avatar";
ALTER TABLE "new_Avatar" RENAME TO "Avatar";
CREATE UNIQUE INDEX "Avatar_id_key" ON "Avatar"("id");
CREATE TABLE "new_User" (
"userId" TEXT NOT NULL PRIMARY KEY,
"identifier" TEXT NOT NULL,
"name" TEXT,
"currentAvatarId" TEXT,
CONSTRAINT "User_currentAvatarId_fkey" FOREIGN KEY ("currentAvatarId") REFERENCES "Avatar" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_User" ("identifier", "name", "userId") SELECT "identifier", "name", "userId" FROM "User";
DROP TABLE "User";
ALTER TABLE "new_User" RENAME TO "User";
CREATE UNIQUE INDEX "User_userId_key" ON "User"("userId");
CREATE UNIQUE INDEX "User_currentAvatarId_key" ON "User"("currentAvatarId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -22,19 +22,13 @@ model User {
userId String @id @unique userId String @id @unique
identifier String identifier String
name String? name String?
avatars Avatar[] avatar Avatar?
currentAvatarId String? @unique
currentAvatar Avatar? @relation("CurrentAvatar", fields: [currentAvatarId], references: [id])
} }
model Avatar { model Avatar {
id String @id @unique @default(uuid()) id String @id @unique @default(uuid())
user User @relation(fields: [userId], references: [userId]) user User @relation(fields: [userId], references: [userId])
userId String userId String @unique
usedBy User? @relation("CurrentAvatar")
altText String? altText String?
source String? source String?
} }

View file

@ -5,7 +5,6 @@ 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" import type { Avatar } from "@prisma/client"
import { randomUUID } from "node:crypto"
// todo: make customizable // todo: make customizable
export const avatarDirectory = "./.data/avatars" export const avatarDirectory = "./.data/avatars"
@ -13,7 +12,10 @@ export const defaultAvatarDirectory = "./.data/defaultAvatar/"
await mkdir(defaultAvatarDirectory, { recursive: true }) await mkdir(defaultAvatarDirectory, { recursive: true })
export const missingAvatarQueue = new Map<string, Promise<string>>() export const missingAvatarQueue = new Map<
string,
Promise<string>
>()
/** /**
* @description Generate an avatar at the selected size and format * @description Generate an avatar at the selected size and format
@ -22,13 +24,10 @@ export const missingAvatarQueue = new Map<string, Promise<string>>()
* @param fmt Avatar format * @param fmt Avatar format
* @returns Promise that resolves to the path of the newly-generated avatar * @returns Promise that resolves to the path of the newly-generated avatar
*/ */
export function generateMissingAvatar( export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sharp.FormatEnum) {
path: string,
size: number,
fmt?: keyof Sharp.FormatEnum
) {
let qid = JSON.stringify([path, size, fmt]) let qid = JSON.stringify([path, size, fmt])
if (missingAvatarQueue.has(qid)) return missingAvatarQueue.get(qid)! if (missingAvatarQueue.has(qid))
return missingAvatarQueue.get(qid)!
let prom = new Promise<string>(async (res, rej) => { let prom = new Promise<string>(async (res, rej) => {
// locate best quality currently available // locate best quality currently available
@ -40,17 +39,9 @@ export function generateMissingAvatar(
: join( : join(
path, path,
av av
.map( .map(e => [parseInt(e.match(/(.*)\..*/)?.[1] || "", 10), e] as [number, string])
e => .sort(([a],[b]) => b - a)
[ [0][1]
parseInt(
e.match(/(.*)\..*/)?.[1] || "",
10
),
e,
] as [number, string]
)
.sort(([a], [b]) => b - a)[0][1]
) )
const buf = await readFile(pathToBestQualityImg) const buf = await readFile(pathToBestQualityImg)
@ -64,86 +55,54 @@ export function generateMissingAvatar(
/** /**
* @description Get the path of an avatar for a user * @description Get the path of an avatar for a user
* @param avatarId Avatar ID * @param uid UID of the user
* @param size Avatar size * @param size Avatar size
* @param fmt Avatar format * @param fmt Avatar format
* @returns Path to the avatar of a user * @returns Path to the avatar of a user
*/ */
export async function getPathToAvatar( export async function getPathToAvatarForUid(uid?: string, size: number = configuration.images.default_resolution, fmt?: string) {
avatarId?: string, if (uid?.includes("/"))
size: number = configuration.images.default_resolution, throw Error("UID cannot include /")
fmt?: string
) {
if (avatarId?.includes("/")) throw Error("AvatarID cannot include /")
// check if format is valid // check if format is valid
if ( if (![undefined, ...configuration.images.extra_output_types].includes(fmt as keyof FormatEnum))
![undefined, ...configuration.images.extra_output_types].includes(
fmt as keyof FormatEnum
)
)
return return
// if no uid / no avatar folder then default to the default avatar directory // if no uid / no avatar folder then default to the default avatar directory
let avDir = avatarId let userAvatarDirectory = uid ? join(avatarDirectory, uid) : defaultAvatarDirectory
? join(avatarDirectory, avatarId) if (!existsSync(userAvatarDirectory))
: defaultAvatarDirectory userAvatarDirectory = defaultAvatarDirectory
if (!existsSync(avDir)) avDir = defaultAvatarDirectory
// bind a makeMissing function // bind a makeMissing function
const makeMissing = generateMissingAvatar.bind( const makeMissing = generateMissingAvatar.bind(null, userAvatarDirectory, size, fmt as keyof FormatEnum)
null,
avDir,
size,
fmt as keyof FormatEnum
)
// get directory to extract imgs from // get directory to extract imgs from
let targetAvatarDirectory = join(avDir, fmt || "") let targetAvatarDirectory = join(userAvatarDirectory, fmt||"")
// if there's no images for the specified fmt, generate new ones // if there's no images for the specified fmt, generate new ones
if (!existsSync(targetAvatarDirectory)) return makeMissing() if (!existsSync(targetAvatarDirectory))
return makeMissing()
let sizes = await readdir(targetAvatarDirectory, {withFileTypes: true}) let sizes = await readdir(targetAvatarDirectory, {withFileTypes: true})
const targetAvatar = sizes const targetAvatar = sizes.filter(e => e.isFile()).find(
.filter(e => e.isFile()) s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size
.find(s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size) )
if (targetAvatar) return join(targetAvatarDirectory, targetAvatar.name) if (targetAvatar)
return join(targetAvatarDirectory, targetAvatar.name)
else if (configuration.images.output_resolutions.includes(size)) else if (configuration.images.output_resolutions.includes(size))
return makeMissing() // generate image at this size for the specified format return makeMissing() // generate image at this size for the specified format
} }
export async function getPathToAvatarForIdentifier( export async function getPathToAvatarForIdentifier(identifier: string, size: number = configuration.images.default_resolution, fmt?: string) {
identifier: string,
size: number = configuration.images.default_resolution,
fmt?: string
) {
let avatar = await prisma.avatar.findFirst({
where: {
usedBy: {
identifier,
},
},
})
return getPathToAvatar(avatar?.id, size, fmt)
}
export async function getPathToAvatarForUid(
uid: string,
size: number = configuration.images.default_resolution,
fmt?: string
) {
let user = await prisma.user.findFirst({ let user = await prisma.user.findFirst({
where: { where: {
userId: uid, identifier
}, }
}) })
return getPathToAvatar(user?.currentAvatarId || undefined, size, fmt) return getPathToAvatarForUid(user?.userId, size, fmt)
} }
function sanitizeAvatar(avatar: Avatar | null) { function sanitizeAvatar(avatar: Avatar | null) {
@ -151,35 +110,33 @@ function sanitizeAvatar(avatar: Avatar | null) {
? { ? {
altText: avatar.altText || "", altText: avatar.altText || "",
source: avatar.source || "", source: avatar.source || "",
default: false, default: false
} }
: { : {
altText: "Default profile picture", altText: "Default profile picture",
source: "https://git.sucks.win/split/ava", source: "https://git.sucks.win/split/ava",
default: true, default: true
} }
} }
export async function getMetadataForIdentifier(identifier: string) { export async function getMetadataForIdentifier(identifier: string) {
let avatar = await prisma.user let avatar = await prisma.avatar.findFirst({
.findFirst({
where: { where: {
identifier, user: {
}, identifier
}
}
}) })
.currentAvatar()
return sanitizeAvatar(avatar) return sanitizeAvatar(avatar)
} }
export async function getMetadataForUserId(userId: string) { export async function getMetadataForUserId(userId: string) {
let avatar = await prisma.user let avatar = await prisma.avatar.findFirst({
.findFirst({
where: { where: {
userId, userId
}, }
}) })
.currentAvatar()
return sanitizeAvatar(avatar) return sanitizeAvatar(avatar)
} }
@ -191,23 +148,16 @@ export async function getMetadataForUserId(userId: string) {
* @param format Image target format * @param format Image target format
* @returns Avatar buffer and other information which may be useful * @returns Avatar buffer and other information which may be useful
*/ */
export async function renderAvatar( export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number, format?: keyof Sharp.FormatEnum) {
bin: ArrayBuffer | Buffer,
squareSize: number,
format?: keyof Sharp.FormatEnum
) {
const opBegin = Date.now() const opBegin = Date.now()
let img = Sharp(bin) let img = Sharp(bin);
let metadata = await img.metadata() let metadata = await img.metadata();
let realSquareSize = Math.min( let realSquareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize)
...([metadata.width, metadata.height].filter(e => e) as number[]),
squareSize
)
img.resize({ img.resize({
width: realSquareSize, width: realSquareSize,
height: realSquareSize, height: realSquareSize,
fit: "cover", fit: "cover"
}) })
if (format) img.toFormat(format) if (format) img.toFormat(format)
@ -217,19 +167,18 @@ export async function renderAvatar(
extension: format || metadata.format, extension: format || metadata.format,
requestedFormat: format, requestedFormat: format,
squareSize, squareSize,
time: Date.now() - opBegin, time: Date.now()-opBegin
} }
} }
export async function writeAvatar( export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<ReturnType<typeof renderAvatar>>) {
avatarDir: string,
renderedAvatar: Awaited<ReturnType<typeof renderAvatar>>
) {
const targetDir = join( const targetDir = join(
avatarDir, avatarDir,
...(renderedAvatar.requestedFormat ...(
renderedAvatar.requestedFormat
? [ renderedAvatar.requestedFormat ] ? [ renderedAvatar.requestedFormat ]
: []) : []
)
) )
await mkdir(targetDir, {recursive: true}) await mkdir(targetDir, {recursive: true})
@ -239,62 +188,60 @@ export async function writeAvatar(
`${renderedAvatar.squareSize}.${renderedAvatar.extension}` `${renderedAvatar.squareSize}.${renderedAvatar.extension}`
) )
await writeFile(targetPath, renderedAvatar.img) await writeFile(
targetPath,
renderedAvatar.img
)
return targetPath return targetPath
} }
export async function createNewAvatar( export async function setNewAvatar(uid: string, avatar?: File) {
uid: string, if (uid?.includes("/"))
avatar: File, throw Error("UID cannot include /")
metadata: { altText?: string; source?: string } = {}
) { // Delete current avatar directory and avatar database entry
const avatarId = randomUUID() const userAvatarDirectory = join(avatarDirectory, uid)
const newAvatarDirectory = join(avatarDirectory, avatarId) 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
// make a new directory // make a new directory
mkdir(newAvatarDirectory, { recursive: true }) mkdir(userAvatarDirectory, { recursive: true })
let time: Record< let time: Record<number, Record<"input" | keyof Sharp.FormatEnum, number>> = {}
number,
Record<"input" | keyof Sharp.FormatEnum, number>
> = {}
// render all images and write to disk // render all images and write to disk
let avatarData = await avatar.arrayBuffer() let avatarData = await avatar.arrayBuffer()
for (let x of configuration.images.output_resolutions) { for (let x of configuration.images.output_resolutions) {
time[x] = Object.fromEntries([ time[x] = Object.fromEntries([
["input", -1], ["input", -1],
...configuration.images.extra_output_types.map(e => [e, -1]), ...configuration.images.extra_output_types
.map( e => [ e, -1 ] )
]) ])
for (let t of [undefined, ...configuration.images.extra_output_types]) { for (let t of [undefined, ...configuration.images.extra_output_types]) {
try { try {
const rendered = await renderAvatar(avatarData, x, t) const rendered = await renderAvatar(avatarData, x, t)
await writeAvatar(newAvatarDirectory, rendered) await writeAvatar(userAvatarDirectory, rendered)
time[x][t || "input"] = rendered.time time[x][t || "input"] = rendered.time
} catch (e) { } catch (e) { // clear pfp and throw if error encountered
// clear pfp and throw if error encountered await rm(userAvatarDirectory, { recursive: true, force: true })
await rm(newAvatarDirectory, { recursive: true, force: true })
throw e throw e
} }
} }
} }
// create new Avatar database entry // create new Avatar database entry
await prisma.avatar await prisma.avatar.create({
.create({
data: { data: {
id: avatarId, userId: uid
userId: uid, }
...metadata,
},
}) })
.then(() =>
prisma.user.update({
where: { userId: uid },
data: { currentAvatarId: avatarId },
})
)
return time return time
} }

View file

@ -1,38 +0,0 @@
<script lang="ts">
import back from "@fluentui/svg-icons/icons/arrow_left_32_regular.svg?raw"
import type { Snippet } from "svelte"
let {to, children, subheading}:{to: string, children: Snippet, subheading?: Snippet} = $props()
</script>
<style>
a {
text-decoration: none;
fill: var(--link);
display: flex;
}
div {
display: flex;
align-items: center;
gap: 1em;
}
.subheading {
font-size: 0.5em;
color: var(--link);
display: block;
}
</style>
<div>
<a href={to}>
{@html back.replace("svg", "svg style=\"width: 2em; height: 2em; vertical-align:middle;\"").replace(">", "><title>Go back</title>")}
</a>
<h1>
{@render children()}
{#if subheading}
<span class="subheading">
{@render subheading()}
</span>
{/if}
</h1>
</div>

View file

@ -1,42 +0,0 @@
<script lang="ts">
import type { Snippet } from "svelte";
const { status, children }: { status: "success" | "warn" | "error", children: Snippet } = $props()
</script>
<style>
div {
border-bottom: 2px solid var(--color);
background-color: color-mix(in srgb, var(--color) 20%, var(--background) 80%);
text-align: center;
padding: 10px;
}
.error {
--color: #d20f39;
}
.warn {
--color: #df8e1d;
}
.success {
--color: #40a02b;
}
@media (prefers-color-scheme: dark) {
.error {
--color: #f38ba8;
}
.warn {
--color: #f9e2af;
}
.success {
--color: #a6e3a1;
}
}
</style>
<div class={status}>
{@render children()}
</div>

View file

@ -3,13 +3,7 @@
import "@fontsource-variable/noto-sans-mono" import "@fontsource-variable/noto-sans-mono"
import ava from "../assets/ava_icon.svg?raw" import ava from "../assets/ava_icon.svg?raw"
import type { User } from "$lib/types"; import type { User } from "$lib/types";
import type { Snippet } from "svelte"; export let data: { user?: User };
interface Props {
data: { user?: User };
children?: Snippet;
}
let { data, children }: Props = $props();
const buildName = `${__APP_NAME__} ${__APP_VERSION__}` const buildName = `${__APP_NAME__} ${__APP_VERSION__}`
</script> </script>
@ -70,6 +64,8 @@
} }
</style> </style>
</svelte:head> </svelte:head>
<body>
<nav> <nav>
<a href="/">{@html ava}</a> <a href="/">{@html ava}</a>
<a href="/set">Set avatar</a> <a href="/set">Set avatar</a>
@ -78,9 +74,10 @@
{/if} {/if}
</nav> </nav>
{@render children?.()} <slot />
<footer> <footer>
{import.meta.env.DEV ? "[DEV]" : ""} {import.meta.env.DEV ? "[DEV]" : ""}
{buildName} {buildName}
</footer> </footer>
</body>

View file

@ -1,42 +0,0 @@
import { getRequestUser, launchLogin } from "$lib/oidc"
import configuration from "$lib/configuration.js"
import { fail, redirect } from "@sveltejs/kit"
import {
avatarDirectory,
createNewAvatar,
getMetadataForUserId,
} from "$lib/avatars.js"
import { join } from "path"
import { prisma } from "$lib/clientsingleton"
export async function load({ request, parent, url, params: { id } }) {
const { user } = await parent()
if (!user) return launchLogin(url.toString())
return {
url: url.toString(),
avatar: await prisma.avatar.findUnique({ where: { id } }),
}
}
export const actions = {
default: async ({ request, cookies, params: { id } }) => {
let user = await getRequestUser(request, cookies)
if (!user) return fail(401, { error: "unauthenticated" })
let { altText, source } = Object.fromEntries(
(await request.formData()).entries()
)
await prisma.avatar.update({
where: {
id,
},
data: {
altText: altText instanceof File ? undefined : altText,
source: source instanceof File ? undefined : source,
},
})
return redirect(302, "/set")
},
}

View file

@ -1,87 +0,0 @@
<script lang="ts">
import StatusBanner from "$lib/components/StatusBanner.svelte";
import type { User } from "$lib/types";
import FilePreviewSet from "$lib/components/FilePreviewSet.svelte";
import ReversibleHeading from "$lib/components/ReversibleHeading.svelte"
export interface Props {
data: {
user: User,
url: string,
avatar: {
id: string,
altText?: string,
source?: string
}
};
form: { success: true, message: string } | { success: false, error: string } | undefined;
}
let { data = $bindable(), form }: Props = $props();
let files: FileList | undefined = $state();
let fileSrc = $derived(files && files.length >= 0 ? URL.createObjectURL(files.item(0)!) : "")
</script>
<style>
form {
flex-direction: column;
}
form, form > .buttons, form > .metadata {
display: flex;
gap: 10px;
}
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;
}
form input[type="submit"], form textarea {
padding: 0.5em 1em;
border-radius: 8px;
border: 1px solid var(--link);
color: var(--text);
background-color: var(--crust);
}
form input[type="submit"]{
cursor: pointer;
}
form textarea:disabled {
color: var(--link);
}
</style>
<ReversibleHeading to="/set">
Edit avatar
{#snippet subheading()}
<code>{data.avatar.id}</code>
{/snippet}
</ReversibleHeading>
<FilePreviewSet avatarUrl="{data.url}/image" style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl="{data.url}/image" style="border-radius:100%;" />
<br>
{#if form}
{#if !form.success}
<StatusBanner status="error">{form.error}</StatusBanner>
<br>
{/if}
{/if}
<form method="post" enctype="multipart/form-data">
<div class="metadata">
<textarea name="altText" placeholder="Describe your image">{data.avatar.altText}</textarea>
<textarea name="source" placeholder="Provide a source for your image">{data.avatar.source}</textarea>
</div>
<div class="buttons">
<input type="submit" name="action" value="Save">
</div>
</form>

View file

@ -1,29 +0,0 @@
import { getPathToAvatar, getPathToAvatarForIdentifier } from "$lib/avatars.js"
import { prisma } from "$lib/clientsingleton.js"
import { getRequestUser } from "$lib/oidc.js"
import { error } from "@sveltejs/kit"
import { readFile } from "fs/promises"
import mime from "mime"
export async function GET({ params: { id, size }, url }) {
let sz = size ? parseInt(size, 10) : undefined
if (sz && Number.isNaN(sz)) throw error(400, "Invalid number")
let avPath = await getPathToAvatar(
id,
sz,
url.searchParams.get("format") || undefined
)
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) || "",
},
})
}

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

@ -1,51 +0,0 @@
import { getRequestUser, launchLogin } from "$lib/oidc"
import configuration from "$lib/configuration.js"
import { fail, redirect } from "@sveltejs/kit"
import {
avatarDirectory,
createNewAvatar,
getMetadataForUserId,
} 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())
return {
url: url.toString(),
allowedImageTypes: configuration.images.permitted_input,
renderSizes: configuration.images.output_resolutions,
}
}
export const actions = {
default: async ({ request, cookies }) => {
let user = await getRequestUser(request, cookies)
if (!user) return fail(401, { error: "unauthenticated" })
let { newAvatar, altText, source } = Object.fromEntries(
(await request.formData()).entries()
)
if (
newAvatar === undefined ||
!(newAvatar instanceof File) ||
newAvatar.size == 0
)
return fail(400, { error: "no file was attached" })
if (!configuration.images.permitted_input.includes(newAvatar.type))
return fail(400, {
success: false,
error: `allowed types does not include ${newAvatar.type}`,
})
let timing = await createNewAvatar(user.sub, newAvatar, {
altText: altText instanceof File ? undefined : altText,
source: source instanceof File ? undefined : source,
})
return redirect(302, "/set")
},
}

View file

@ -1,89 +0,0 @@
<script lang="ts">
import StatusBanner from "$lib/components/StatusBanner.svelte";
import type { User } from "$lib/types";
import FilePreviewSet from "$lib/components/FilePreviewSet.svelte";
import ReversibleHeading from "$lib/components/ReversibleHeading.svelte"
export interface Props {
data: {
user: User,
allowedImageTypes: string[],
renderSizes: number[]
};
form: { success: true, message: string } | { success: false, error: string } | undefined;
}
let { data = $bindable(), form }: Props = $props();
let files: FileList | undefined = $state();
let fileSrc = $derived(files && files.length >= 0 ? URL.createObjectURL(files.item(0)!) : "")
</script>
<style>
form {
flex-direction: column;
}
form, form > .buttons, form > .metadata {
display: flex;
gap: 10px;
}
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;
}
form > input[type="file"] {
flex-basis: 100%;
min-height: 1em;
}
form input[type="submit"], form input[type="file"] {
cursor: pointer;
}
form input[type="submit"], form input[type="file"], form textarea {
padding: 0.5em 1em;
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;
}
</style>
<ReversibleHeading to="/set">Add a new avatar</ReversibleHeading>
<br>
{#if form}
{#if !form.success}
<StatusBanner status="error">{form.error}</StatusBanner>
<br>
{/if}
{/if}
<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"></textarea>
<textarea name="source" placeholder="Provide a source for your image"></textarea>
</div>
{#if fileSrc}
<br>
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:100%;" />
{/if}
<div class="buttons">
<input type="submit" name="action" value="Create">
</div>
</form>

View file

@ -1,81 +1,64 @@
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 } 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" 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) return launchLogin(url.toString()) if (!user)
return launchLogin(url.toString())
return { return {
url: url.toString(), url: url.toString(),
avatars: ( avatar: await getMetadataForUserId(user.sub),
await prisma.avatar.findMany({ allowedImageTypes: configuration.images.permitted_input,
where: { userId: user.sub }, renderSizes: configuration.images.output_resolutions
include: { usedBy: true },
})
)
.reverse()
.map(e => ({ ...e, inUse: Boolean(e.usedBy) })),
} }
} }
export const actions = { export const actions = {
default: async ({request, cookies}) => { default: async ({request, cookies}) => {
let user = await getRequestUser(request, cookies) let user = await getRequestUser(request, cookies);
if (!user) return fail(401, { error: "unauthenticated" }) if (!user)
return fail(401, {error: "unauthenticated"})
let submission = Object.fromEntries( let submission = Object.fromEntries((await request.formData()).entries());
(await request.formData()).entries() let newAvatar = submission.newAvatar
) let timing: Awaited<ReturnType<typeof setNewAvatar>> = {}
let isUploadingNewFile = submission.action == "Clear"
if (typeof submission.action != "string") if (
return fail(400, { error: "bad action" }) !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))
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
isUploadingNewFile = true
}
if (submission.action == "Clear") { if (isUploadingNewFile)
await prisma.user.update({ timing = await setNewAvatar(user.sub, newAvatar as File|null || undefined)
if (await prisma.avatar.findFirst({ where: { userId: user.sub } }))
await prisma.avatar.update({
where: { where: {
userId: user.sub, userId: user.sub
}, },
data: { data: {
currentAvatarId: null, altText: typeof submission.altText == "string" ? submission.altText : null,
}, source: typeof submission.source == "string" ? submission.source : null,
}
}) })
return { return {
success: true, success: true,
message: "Avatar cleared successfully", message: Object.entries(timing)
} .map(([res, time]) => `render ${res}x${res}: ${
} else if (submission.action.startsWith("Set:")) { Object.entries(time).map(([type, t]) => `${type}: ${t}ms`).join(", ")
let avatarId = submission.action.match(/Set\:(.*)/)![1] }`)
.join("\n")
// make sure the avatar exists and is owned by the user || "No timing information available"
let avatar = await prisma.avatar.findUnique({
where: {
id: avatarId,
userId: user.sub,
},
})
if (!avatar)
return fail(400, {
error: "This avatar does not exist or you do not own it",
})
await prisma.user.update({
where: {
userId: user.sub,
},
data: {
currentAvatarId: avatar.id,
},
})
return {
success: true,
message: "New avatar set",
} }
} }
},
} }

View file

@ -1,98 +1,131 @@
<script lang="ts"> <script lang="ts">
import StatusBanner from "$lib/components/StatusBanner.svelte";
import type { User } from "$lib/types"; import type { User } from "$lib/types";
import type { Avatar } from "@prisma/client"; import FilePreviewSet from "./FilePreviewSet.svelte";
import editIcon from "@fluentui/svg-icons/icons/pen_16_regular.svg?raw"
export interface Props { export let data: {
data: {
user: User, user: User,
url: string, url: string,
avatars: (Avatar & {inUse: boolean})[] avatar: {
}; altText: string,
form: { success: true, message: string } | { success: false, error: string } | undefined; source: string,
default: boolean
} }
import FilePreviewSet from "$lib/components/FilePreviewSet.svelte"; 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}/`
let { data = $bindable(), form }: Props = $props(); $: if (files && files.length >= 0) {
data.avatar.altText = "", data.avatar.source = "", data.avatar.default = false
fileSrc = URL.createObjectURL(files.item(0)!)
} else fileSrc = `/avatar/${data.user.identifier}/`
</script> </script>
<style> <style>
form { form {
flex-direction: column;
}
form, form > .buttons, form > .metadata {
display: flex; display: flex;
justify-content: center; gap: 10px;
gap: 1em; }
form > .metadata {
flex-wrap: wrap; flex-wrap: wrap;
} }
input[type="submit"] { form > .metadata > textarea {
width: 7em; height: 3em;
height: 7em; flex-grow: 1;
aspect-ratio: 1 / 1; min-width: 15em;
}
form > .buttons {
justify-content: flex-end;
}
form input {
font-family: "Inter Variable", "Inter", sans-serif;
}
form > input[type="file"] {
flex-basis: 100%;
min-height: 1em;
}
form input[type="submit"], form input[type="file"] {
cursor: pointer;
}
form input[type="submit"], form input[type="file"], form textarea {
padding: 0.5em 1em;
border-radius: 8px; border-radius: 8px;
border: none; 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;
}
summary::marker {
content: ""
}
summary {
color: var(--link);
cursor:pointer; cursor:pointer;
color: transparent;
}
.editButton {
border: 1px solid var(--crust);
padding: 5px;
aspect-ratio: 1 / 1;
border-radius: 100%;
cursor: pointer;
background-color: var(--background);
fill: var(--text);
width: 16px;
height: 16px;
display:flex; /* flex fixes everything! */
opacity: 0;
transition-duration: 150ms;
position: absolute;
left: 100%;
top: 5px;
transform: translateX(calc( -100% - 5px ))
}
.editButton:hover {
border: 1px solid var(--link)
}
.idiv:hover .editButton {
opacity: 1;
}
.idiv {
position: relative
}
/* keep editbutton visible on mobile */
@media (hover: none) {
.editButton {
opacity: 1
} }
details > div {
border-left: 0.25em solid var(--link);
padding: 0 1em;
overflow-x: auto;
background-color: var(--crust);
width: calc( 100% - 2em );
} }
</style> </style>
<h1>Hi, {data.user.name}</h1> <h1>Hi, {data.user.name}</h1>
<br> <p>
<FilePreviewSet avatarUrl="/user/{data.user.identifier}/avatar" style="border-radius:8px;" /> <details>
<br> <summary>View user information...</summary>
<FilePreviewSet avatarUrl="/user/{data.user.identifier}/avatar" style="border-radius:100%;" /> <div>
<br> <pre>{JSON.stringify(data.user, null, 4)}</pre>
{#if form}
<StatusBanner status={form.success ? "success" : "error"}>{form.success ? form.message : form.error}</StatusBanner>
{/if}
<h2>Your avatars</h2>
<form method="post">
{#each data.avatars as avatar}
<div class="idiv">
<input type="submit" name="action" value={"Set:"+avatar.id} aria-label={avatar.altText || "No alt text set"} style:background="center / cover no-repeat url('/avatars/{avatar.id}/image')" data-in-use={avatar.inUse} />
<a href="/avatars/{avatar.id}" aria-label="Edit" class="editButton">
{@html editIcon.replace("svg", "svg style=\"width: 16px; height: 16px;\"")}
</a>
</div> </div>
</details>
<details>
<summary>Avatar URLs...</summary>
<div>
<ul>
{#each ["", ...data.renderSizes] as variant}
<li>{new URL(`/avatar/${data.user.identifier}/${variant}`, data.url)}</li>
{/each} {/each}
<input type="submit" name="action" value="Clear" aria-label="Default avatar" style:background="center / cover no-repeat url('/avatars/default/image')" /> </ul>
</div>
</details>
</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>
</form> </form>
<footer> {#if form}
<a href="/new">Add new avatar</a> {#if form.success}
&bullet; <details>
<a href="/webhooks">Webhooks</a> <summary><small>Avatar updated successfully</small></summary>
&bullet; <div>
<a href="/user/{data.user.identifier}" target="_blank">See user page</a> <pre>{form.message}</pre>
</footer> </div>
</details>
{:else}
<small>An error occurred: {form.error}</small>
{/if}
{/if}
{#key fileSrc}
<br>
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:100%;" />
{/key}

View file

@ -1,10 +1,6 @@
<script lang="ts"> <script lang="ts">
export interface Props { export let style: string = "";
style?: string; export let avatarUrl: string;
avatarUrl: string;
}
let { style = "", avatarUrl }: Props = $props();
</script> </script>
<style> <style>
div { div {

View file

@ -1,14 +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,51 +0,0 @@
<script lang="ts">
import FilePreviewSet from "../../../lib/components/FilePreviewSet.svelte";
interface Props {
data: {
identifier: string,
name: string,
altText: string,
source: string
};
}
let { data }: Props = $props();
</script>
<style>
h2, p {
margin: 0;
}
h2 {
font-weight: normal;
color: var(--link);
font-size: 0.82em;
}
</style>
<h1>{data.name}'s avatar</h1>
<FilePreviewSet avatarUrl="/user/{data.identifier}/avatar" style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl="/user/{data.identifier}/avatar" style="border-radius:100%;" />
<br>
<h2>Alt text</h2>
<p>
{#if data.altText}
{data.altText}
{:else}
<em>No alt text available</em>
{/if}
</p>
<br>
<h2>Source</h2>
<p>
{#if data.source}
{data.source}
{:else}
<em>No source available</em>
{/if}
</p>