Compare commits

..

12 commits

Author SHA1 Message Date
May 3dbf5b2395
refactor: ♻️ use $lib 2024-11-20 20:10:13 -08:00
May 7442cb464b
Merge branch 'v2' 2024-11-20 20:09:13 -08:00
May 3bd599a51d
feat: hyperlink links in the source of an avatar 2024-11-20 20:02:18 -08:00
May 060454533c
fix: 🐛 don't send webhook when editing an avatar that isn't your current one 2024-11-20 19:46:06 -08:00
May aa67b08239
feat: add libravatar API 2024-11-20 19:36:24 -08:00
May c70db40ffa
feat: 🚧 add libravatar options 2024-11-20 16:02:42 -08:00
May 092c70da5b
feat: webhooks 2024-11-20 13:03:46 -08:00
May 17814b3478
fix: 🐛 redirecting to 404 2024-11-20 07:40:49 -08:00
May 88ec069c55
feat: add delete button 2024-11-20 07:38:36 -08:00
May 269db33bec
feat: PFP switching 2024-11-20 02:17:45 -08:00
May a1294b145f
Svelte 5 port 2024-11-19 18:40:03 -08:00
May eb5b86556d
feat: 🚧 2024-11-19 17:34:49 -08:00
45 changed files with 2000 additions and 2131 deletions

View file

@ -26,6 +26,19 @@ IMAGES__DEFAULT_RESOLUTION=512
# Comma-separated list of permitted image mimetypes
IMAGES__ALLOWED_INPUT_FORMATS=image/jpg,image/jpeg,image/png,image/webp,image/gif
# Whether or not to enable the libravatar API. Default false.
LIBRAVATAR__ENABLED=false
# Either jpeg or png. The type of image the libravatar API serves. Default png.
LIBRAVATAR__FORMAT=png
# What to do when libravatar requests a resolution that is not a valid output resolution. Default nearest.
# Modes:
# nearest - Use the nearest image size.
# nocache - Render the image at the requested size without saving it to disk.
# cache - Render the image at the requested size and save it to disk.
LIBRAVATAR__GENERATION_MODE=nearest
# Prisma database URL
DATABASE_URL=file:../.data/data.db

View file

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

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

View file

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

@ -1,21 +0,0 @@
/*
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

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

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

@ -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

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

@ -0,0 +1,20 @@
/*
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

@ -0,0 +1,34 @@
/*
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

@ -0,0 +1,10 @@
-- CreateTable
CREATE TABLE "Webhook" (
"userId" TEXT NOT NULL,
"url" TEXT NOT NULL,
"enabled" BOOLEAN NOT NULL DEFAULT true,
CONSTRAINT "Webhook_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Webhook_url_userId_key" ON "Webhook"("url", "userId");

View file

@ -0,0 +1,6 @@
-- CreateTable
CREATE TABLE "EmailHashes" (
"forUserId" TEXT NOT NULL PRIMARY KEY,
"sha256" BLOB NOT NULL,
CONSTRAINT "EmailHashes_forUserId_fkey" FOREIGN KEY ("forUserId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
);

View file

@ -0,0 +1,20 @@
/*
Warnings:
- Added the required column `md5` to the `EmailHashes` 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_EmailHashes" (
"forUserId" TEXT NOT NULL PRIMARY KEY,
"sha256" BLOB NOT NULL,
"md5" BLOB NOT NULL,
CONSTRAINT "EmailHashes_forUserId_fkey" FOREIGN KEY ("forUserId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_EmailHashes" ("forUserId", "sha256") SELECT "forUserId", "sha256" FROM "EmailHashes";
DROP TABLE "EmailHashes";
ALTER TABLE "new_EmailHashes" RENAME TO "EmailHashes";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -22,13 +22,37 @@ model User {
userId String @id @unique
identifier String
name String?
avatar Avatar?
avatars Avatar[]
webhooks Webhook[]
emailHashes EmailHashes?
currentAvatarId String? @unique
currentAvatar Avatar? @relation("CurrentAvatar", fields: [currentAvatarId], references: [id])
}
model Avatar {
id String @id @unique @default(uuid())
user User @relation(fields: [userId], references: [userId])
userId String @unique
userId String
usedBy User? @relation("CurrentAvatar")
altText String?
source String?
}
model Webhook {
userId String
user User @relation(fields: [userId], references: [userId])
url String
enabled Boolean @default(true)
@@unique([url, userId])
}
model EmailHashes {
forUserId String @id
user User @relation(fields: [forUserId], references: [userId])
sha256 Bytes
md5 Bytes
}

View file

@ -5,6 +5,7 @@ import { prisma } from "./clientsingleton"
import configuration from "./configuration"
import Sharp, { type FormatEnum } from "sharp"
import type { Avatar } from "@prisma/client"
import { randomUUID } from "node:crypto"
// todo: make customizable
export const avatarDirectory = "./.data/avatars"
@ -12,10 +13,17 @@ export const defaultAvatarDirectory = "./.data/defaultAvatar/"
await mkdir(defaultAvatarDirectory, { recursive: true })
export const missingAvatarQueue = new Map<
export const missingAvatarQueue = new Map<string, Promise<string>>()
export async function getAvailableSizesInPath(path: string) {
return (await readdir(path)).map(
e =>
[parseInt(e.match(/(.*)\..*/)?.[1] || "", 10), e] as [
number,
string,
Promise<string>
>()
]
)
}
/**
* @description Generate an avatar at the selected size and format
@ -24,24 +32,24 @@ export const missingAvatarQueue = new Map<
* @param fmt Avatar format
* @returns Promise that resolves to the path of the newly-generated avatar
*/
export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sharp.FormatEnum) {
export function generateMissingAvatar(
path: string,
size: number,
fmt?: keyof Sharp.FormatEnum
) {
let qid = JSON.stringify([path, size, fmt])
if (missingAvatarQueue.has(qid))
return missingAvatarQueue.get(qid)!
if (missingAvatarQueue.has(qid)) return missingAvatarQueue.get(qid)!
let prom = new Promise<string>(async (res, rej) => {
// locate best quality currently available
const av = await readdir(path)
// this can probably be done better but I DON'T GIVE A FUCK !!!!
const pathToBestQualityImg =
path == defaultAvatarDirectory
? "./assets/default.png"
: join(
path,
av
.map(e => [parseInt(e.match(/(.*)\..*/)?.[1] || "", 10), e] as [number, string])
.sort(([a],[b]) => b - a)
[0][1]
(await getAvailableSizesInPath(path)).sort(
([a], [b]) => b - a
)[0][1]
)
const buf = await readFile(pathToBestQualityImg)
@ -55,88 +63,132 @@ export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sh
/**
* @description Get the path of an avatar for a user
* @param uid UID of the user
* @param avatarId Avatar ID
* @param size Avatar size
* @param fmt Avatar format
* @returns Path to the avatar of a user
*/
export async function getPathToAvatarForUid(uid?: string, size: number = configuration.images.default_resolution, fmt?: string) {
if (uid?.includes("/"))
throw Error("UID cannot include /")
export async function getPathToAvatar(
avatarId?: string,
size: number = configuration.images.default_resolution,
fmt?: string,
bypass_size_limits: boolean = false
) {
if (avatarId?.includes("/")) throw Error("AvatarID cannot include /")
// check if format is valid
if (![undefined, ...configuration.images.extra_output_types].includes(fmt as keyof FormatEnum))
if (
![undefined, ...configuration.images.extra_output_types].includes(
fmt as keyof FormatEnum
)
)
return
// if no uid / no avatar folder then default to the default avatar directory
let userAvatarDirectory = uid ? join(avatarDirectory, uid) : defaultAvatarDirectory
if (!existsSync(userAvatarDirectory))
userAvatarDirectory = defaultAvatarDirectory
let avDir = avatarId
? join(avatarDirectory, avatarId)
: defaultAvatarDirectory
if (!existsSync(avDir)) avDir = defaultAvatarDirectory
// bind a makeMissing function
const makeMissing = generateMissingAvatar.bind(null, userAvatarDirectory, size, fmt as keyof FormatEnum)
const makeMissing = generateMissingAvatar.bind(
null,
avDir,
size,
fmt as keyof FormatEnum
)
// get directory to extract imgs from
let targetAvatarDirectory = join(userAvatarDirectory, fmt||"")
let targetAvatarDirectory = join(avDir, fmt || "")
// 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 })
const targetAvatar = sizes.filter(e => e.isFile()).find(
s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size
)
const targetAvatar = sizes
.filter(e => e.isFile())
.find(s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size)
if (targetAvatar)
return join(targetAvatarDirectory, targetAvatar.name)
else if (configuration.images.output_resolutions.includes(size))
if (targetAvatar) return join(targetAvatarDirectory, targetAvatar.name)
else if (
configuration.images.output_resolutions.includes(size) ||
bypass_size_limits
)
return makeMissing() // generate image at this size for the specified format
}
export async function getPathToAvatarForIdentifier(identifier: string, size: number = configuration.images.default_resolution, fmt?: string) {
let user = await prisma.user.findFirst({
export async function getPathToAvatarForIdentifier(
identifier: string,
size: number = configuration.images.default_resolution,
fmt?: string
) {
let avatar = await prisma.avatar.findFirst({
where: {
identifier
}
usedBy: {
identifier,
},
},
})
return getPathToAvatarForUid(user?.userId, size, fmt)
return getPathToAvatar(avatar?.id, size, fmt)
}
function sanitizeAvatar(avatar: Avatar | null) {
export async function getPathToAvatarForUid(
uid: string,
size: number = configuration.images.default_resolution,
fmt?: string
) {
let user = await prisma.user.findFirst({
where: {
userId: uid,
},
})
return getPathToAvatar(user?.currentAvatarId || undefined, size, fmt)
}
export function sanitizeAvatar(
avatar:
| (Pick<Avatar, "id"> & Partial<Pick<Avatar, "altText" | "source">>)
| null
) {
return avatar
? {
id: avatar.id,
altText: avatar.altText || "",
source: avatar.source || "",
default: false
default: false,
}
: {
id: "default",
altText: "Default profile picture",
source: "https://git.sucks.win/split/ava",
default: true
default: true,
}
}
export async function getMetadataForIdentifier(identifier: string) {
let avatar = await prisma.avatar.findFirst({
let avatar = await prisma.user
.findFirst({
where: {
user: {
identifier
}
}
identifier,
},
})
.currentAvatar()
return sanitizeAvatar(avatar)
}
export async function getMetadataForUserId(userId: string) {
let avatar = await prisma.avatar.findFirst({
let avatar = await prisma.user
.findFirst({
where: {
userId
}
userId,
},
})
.currentAvatar()
return sanitizeAvatar(avatar)
}
@ -148,16 +200,23 @@ export async function getMetadataForUserId(userId: string) {
* @param format Image target format
* @returns Avatar buffer and other information which may be useful
*/
export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number, format?: keyof Sharp.FormatEnum) {
export async function renderAvatar(
bin: ArrayBuffer | Buffer,
squareSize: number,
format?: keyof Sharp.FormatEnum
) {
const opBegin = Date.now()
let img = Sharp(bin);
let metadata = await img.metadata();
let realSquareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize)
let img = Sharp(bin)
let metadata = await img.metadata()
let realSquareSize = Math.min(
...([metadata.width, metadata.height].filter(e => e) as number[]),
squareSize
)
img.resize({
width: realSquareSize,
height: realSquareSize,
fit: "cover"
fit: "cover",
})
if (format) img.toFormat(format)
@ -167,18 +226,19 @@ export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number,
extension: format || metadata.format,
requestedFormat: format,
squareSize,
time: Date.now()-opBegin
time: Date.now() - opBegin,
}
}
export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<ReturnType<typeof renderAvatar>>) {
export async function writeAvatar(
avatarDir: string,
renderedAvatar: Awaited<ReturnType<typeof renderAvatar>>
) {
const targetDir = join(
avatarDir,
...(
renderedAvatar.requestedFormat
...(renderedAvatar.requestedFormat
? [renderedAvatar.requestedFormat]
: []
)
: [])
)
await mkdir(targetDir, { recursive: true })
@ -188,60 +248,102 @@ export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<Ret
`${renderedAvatar.squareSize}.${renderedAvatar.extension}`
)
await writeFile(
targetPath,
renderedAvatar.img
)
await writeFile(targetPath, renderedAvatar.img)
return targetPath
}
export async function setNewAvatar(uid: string, avatar?: File) {
if (uid?.includes("/"))
throw Error("UID cannot include /")
// Delete current avatar directory and avatar database entry
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
export async function createNewAvatar(
uid: string,
avatar: File,
metadata: { altText?: string; source?: string } = {}
) {
const avatarId = randomUUID()
const newAvatarDirectory = join(avatarDirectory, avatarId)
// make a new directory
mkdir(userAvatarDirectory, { recursive: true })
mkdir(newAvatarDirectory, { recursive: true })
let time: Record<number, Record<"input" | keyof Sharp.FormatEnum, number>> = {}
let time: Record<
number,
Record<"input" | keyof Sharp.FormatEnum, number>
> = {}
// render all images and write to disk
let avatarData = await avatar.arrayBuffer()
for (let x of configuration.images.output_resolutions) {
time[x] = Object.fromEntries([
["input", -1],
...configuration.images.extra_output_types
.map( e => [ e, -1 ] )
...configuration.images.extra_output_types.map(e => [e, -1]),
])
for (let t of [undefined, ...configuration.images.extra_output_types]) {
try {
const rendered = await renderAvatar(avatarData, x, t)
await writeAvatar(userAvatarDirectory, rendered)
await writeAvatar(newAvatarDirectory, rendered)
time[x][t || "input"] = rendered.time
} catch (e) { // clear pfp and throw if error encountered
await rm(userAvatarDirectory, { recursive: true, force: true })
} catch (e) {
// clear pfp and throw if error encountered
await rm(newAvatarDirectory, { recursive: true, force: true })
throw e
}
}
}
// create new Avatar database entry
await prisma.avatar.create({
await prisma.avatar
.create({
data: {
userId: uid
}
id: avatarId,
userId: uid,
...metadata,
},
})
.then(() =>
// set the user's avatar
prisma.user.update({
where: { userId: uid },
data: { currentAvatarId: avatarId },
})
)
// execute webhooks
executeHooksForUser(uid, {
id: avatarId,
default: false,
...metadata,
})
return time
}
export function deleteAvatar(id: string) {
const targetAvatarDirectory = join(avatarDirectory, id)
return prisma.avatar
.delete({ where: { id } })
.then(_ => rm(targetAvatarDirectory, { recursive: true, force: true }))
}
export async function executeHooksForUser(
userId: string,
payload: { id: string; altText?: string; source?: string; default: boolean }
) {
let hooks = await prisma.webhook.findMany({
where: {
enabled: true,
userId,
},
})
hooks.forEach(async hook =>
fetch(hook.url, {
method: "POST",
body: JSON.stringify(payload),
}).catch(e =>
console.error(
`error executing webhook ${hook.url} for userid ${userId}:`,
e
)
)
)
}

3
src/lib/common.ts Normal file
View file

@ -0,0 +1,3 @@
// https://stackoverflow.com/questions/3809401/what-is-a-good-regular-expression-to-match-a-url
export const URL_REGEX =
/https?:\/\/(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)/g

View file

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

View file

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

@ -0,0 +1,28 @@
<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: var(--red);
}
.warn {
--color: var(--yellow);
}
.success {
--color: var(--green);
}
</style>
<div class={status}>
{@render children()}
</div>

View file

@ -1,32 +1,83 @@
import Sharp, { type FormatEnum } from 'sharp';
import { env } from '$env/dynamic/private';
import Sharp, { type FormatEnum } from "sharp"
import { env } from "$env/dynamic/private"
const stbool = (s: string) => {
switch (s.toLowerCase()) {
case "1":
case "y":
case "yes":
case "true":
return true
case "0":
case "n":
case "no":
case "false":
return false
default:
return undefined
}
}
const configuration = {
oauth2: {
endpoints: {
authenticate: env.OAUTH2__AUTHENTICATE!,
logout: env.OAUTH2__LOGOUT,
token: env.OAUTH2__GET_TOKEN!
token: env.OAUTH2__GET_TOKEN!,
},
client: {
id: env.OAUTH2__CLIENT_ID!,
secret: env.OAUTH2__CLIENT_SECRET!,
scopes: env.OAUTH2__SCOPES!
}
scopes: env.OAUTH2__SCOPES!,
},
},
userinfo: {
route: env.USERINFO__ROUTE!,
identifier: env.USERINFO__IDENTIFIER!
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,
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
(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
]
output_resolutions: env.IMAGES__OUTPUT_RESOLUTIONS?.split(",").map(e =>
parseInt(e, 10)
) || [1024, 512, 256, 128, 64, 32],
},
...(stbool(env.LIBRAVATAR__ENABLED) ?? false
? {
libravatar: {
output_format: (["png", "jpeg"].includes(
env.LIBRAVATAR__FORMAT
)
? env.LIBRAVATAR__FORMAT
: "jpeg") as keyof FormatEnum,
resize_mode: (["nearest", "nocache", "cache"].includes(
env.LIBRAVATAR__GENERATION_MODE
)
? env.LIBRAVATAR__GENERATION_MODE
: "nearest") as "nearest" | "nocache" | "cache",
},
}
};
export default configuration;
: {}),
}
// add extra output format if required
if (
configuration.libravatar &&
!configuration.images.extra_output_types.includes(
configuration.libravatar.output_format
)
)
configuration.images.extra_output_types.push(
configuration.libravatar.output_format
)
export default configuration

View file

@ -2,9 +2,14 @@ import { error, redirect, type Cookies } from "@sveltejs/kit"
import configuration from "./configuration"
import type { User } from "./types"
import { prisma } from "./clientsingleton"
import type { EmailHashes } from "@prisma/client"
import crypto from "node:crypto"
// Map of OAuth2 states
const states = new Map<string, { redirect_uri: string, timeout: ReturnType<typeof setTimeout> }>()
const states = new Map<
string,
{ redirect_uri: string; timeout: ReturnType<typeof setTimeout> }
>()
// Cache of userinfo
const userInfoCache = new Map<string, User>()
@ -22,7 +27,7 @@ export function launchLogin(url: string) {
client_id: configuration.oauth2.client.id,
redirect_uri: url,
scope: configuration.oauth2.client.scopes,
state
state,
})
// Did not think this would work lmao
const target = new URL(
@ -33,17 +38,10 @@ export function launchLogin(url: string) {
// cache state
// NO IDEA IF THIS WORKS IN SERVERLESS LOL
// not like this is going to be running serverless anyway
states
.set(
state,
{
timeout: setTimeout(
() => states.delete(state),
2*60*1000
),
redirect_uri: url
}
)
states.set(state, {
timeout: setTimeout(() => states.delete(state), 2 * 60 * 1000),
redirect_uri: url,
})
throw redirect(302, target.toString())
}
@ -55,38 +53,46 @@ export function launchLogin(url: string) {
*/
export async function getNewToken(
params:
{grant_type: "authorization_code", redirect_uri: string, code: string}
| {grant_type: "refresh_token", refresh_token: string}
| {
grant_type: "authorization_code"
redirect_uri: string
code: string
}
| { grant_type: "refresh_token"; refresh_token: string }
) {
// Generate a query string for the request
const searchParams = new URLSearchParams({
...params,
client_id: configuration.oauth2.client.id,
client_secret: configuration.oauth2.client.secret
client_secret: configuration.oauth2.client.secret,
})
// send request to retrieve tokens
let res = await fetch(configuration.oauth2.endpoints.token, {
method: "POST",
body: searchParams // this standard sucks, actually
body: searchParams, // this standard sucks, actually
})
if (res.ok)
return (await res.json()) as { access_token: string, expires_in: number, refresh_token?: string }
return (await res.json()) as {
access_token: string
expires_in: number
refresh_token?: string
}
}
export function fetchUserInfo(token: string) {
// try fetching new userinfo
return fetch(configuration.userinfo.route, {
headers: {
"Authorization": `Bearer ${token}`
}
Authorization: `Bearer ${token}`,
},
})
}
export async function getUserInfo(id: string) {
// fetch token information
const tokenInfo = await prisma.token.findUnique({
where: { id }
where: { id },
})
if (!tokenInfo) return
@ -103,15 +109,15 @@ export async function getUserInfo(id: string) {
if (!tokenInfo.refreshToken) return // no refresh token. back out
let token = await getNewToken({
grant_type: "refresh_token",
refresh_token: tokenInfo.refreshToken
refresh_token: tokenInfo.refreshToken,
})
if (!token) return // refresh failed. back out
await prisma.token.update({
where: { id },
data: {
token: token.access_token,
refreshToken: token.refresh_token
}
refreshToken: token.refresh_token,
},
})
userInfoRequest = await fetchUserInfo(token.access_token)
@ -120,6 +126,20 @@ export async function getUserInfo(id: string) {
userInfo = await userInfoRequest.json()
// get emailHashes
let emailHashes: Omit<EmailHashes, "forUserId"> | undefined = undefined
if (userInfo.email) {
emailHashes = {
sha256: crypto
.createHash("sha256")
.update(userInfo.email)
.digest(),
md5: crypto.createHash("md5").update(userInfo.email).digest(),
}
}
// update user
await prisma.user.upsert({
where: {
@ -127,13 +147,30 @@ export async function getUserInfo(id: string) {
},
update: {
identifier: userInfo[configuration.userinfo.identifier],
name: userInfo.name
name: userInfo.name,
...(emailHashes
? {
emailHashes: {
upsert: {
create: emailHashes,
update: emailHashes,
},
},
}
: {}),
},
create: {
userId: userInfo.sub,
identifier: userInfo[configuration.userinfo.identifier],
name: userInfo.name
name: userInfo.name,
...(emailHashes
? {
emailHashes: {
create: emailHashes,
},
}
: {}),
},
})
// cache userinfo
@ -141,23 +178,27 @@ export async function getUserInfo(id: string) {
setTimeout(() => userInfoCache.delete(tokenInfo.owner), 15 * 60 * 1000)
}
return { ...userInfo, identifier: userInfo[configuration.userinfo.identifier] } as User
return {
...userInfo,
identifier: userInfo[configuration.userinfo.identifier],
} as User
}
export function deleteToken(id: string) {
prisma.token.delete({
where: {id}
where: { id },
})
}
export async function getRequestUser(request: Request, cookies: Cookies) {
const params = new URLSearchParams(request.url.split("?").slice(1).join("?"))
const params = new URLSearchParams(
request.url.split("?").slice(1).join("?")
)
let token = cookies.get("token")
// log user in
if (!token && params.has("code") && params.has("state")) {
// check if state is real
if (!states.has(params.get("state")!))
throw error(401, "bad state")
if (!states.has(params.get("state")!)) throw error(401, "bad state")
// get state
let state = states.get(params.get("state")!)!
@ -168,23 +209,28 @@ export async function getRequestUser(request: Request, cookies: Cookies) {
let tokens = await getNewToken({
grant_type: "authorization_code",
redirect_uri: state.redirect_uri,
code: params.get("code")!
code: params.get("code")!,
})
if (!tokens)
throw error(401, "Couldn't get initial token, code may be incorrect")
throw error(
401,
"Couldn't get initial token, code may be incorrect"
)
// fetch userdata
// could cache this, but lazy
let userInfo = await (await fetchUserInfo(tokens.access_token)).json() as User
let userInfo = (await (
await fetchUserInfo(tokens.access_token)
).json()) as User
// create a new token
let newToken = await prisma.token.create({
data: {
token: tokens.access_token,
refreshToken: tokens.refresh_token,
owner: userInfo.sub
}
owner: userInfo.sub,
},
})
token = newToken.id

View file

@ -2,4 +2,5 @@ export interface User {
name: string
sub: string
identifier: string
email?: string
}

View file

@ -3,7 +3,13 @@
import "@fontsource-variable/noto-sans-mono"
import ava from "../assets/ava_icon.svg?raw"
import type { User } from "$lib/types";
export let data: { user?: User };
import type { Snippet } from "svelte";
interface Props {
data: { user?: User };
children?: Snippet;
}
let { data, children }: Props = $props();
const buildName = `${__APP_NAME__} ${__APP_VERSION__}`
</script>
@ -15,15 +21,23 @@
--link: #333;
--background: white;
--crust: #eee;
--red: #d20f39;
--yellow: #df8e1d;
--green: #40a02b;
}
@media (prefers-color-scheme:dark) {
:root {
--text: white;
--link: #aaa;
--background: #111;
--crust: #333;
--red: #f38ba8;
--yellow: #f9e2af;
--green: #a6e3a1
}
}
html {
background: var(--background);
}
@ -64,8 +78,6 @@
}
</style>
</svelte:head>
<body>
<nav>
<a href="/">{@html ava}</a>
<a href="/set">Set avatar</a>
@ -74,10 +86,9 @@
{/if}
</nav>
<slot />
{@render children?.()}
<footer>
{import.meta.env.DEV ? "[DEV]" : ""}
{buildName}
</footer>
</body>

View file

@ -0,0 +1,139 @@
import {
avatarDirectory,
getAvailableSizesInPath,
getPathToAvatar,
getPathToAvatarForIdentifier,
renderAvatar,
} from "$lib/avatars.js"
import { prisma } from "$lib/clientsingleton.js"
import configuration from "$lib/configuration.js"
import { getRequestUser } from "$lib/oidc.js"
import { error, redirect } from "@sveltejs/kit"
import { readFile } from "fs/promises"
import mime from "mime"
import { join } from "path"
export async function GET({ params: { hash }, url }) {
if (!configuration.libravatar)
throw error(501, "The libravatar API is disabled on this server")
const requestedSize = parseInt(url.searchParams.get("s") || "0", 10) || 80
const fallback = url.searchParams.get("d") || "mm"
const forceDefault = url.searchParams.get("f") === "y"
const hashBinary = Buffer.from(hash, "hex")
let size = requestedSize > 512 || requestedSize < 1 ? 80 : requestedSize
// try to find the user from the hashBinary
const avatarId = (
await prisma.emailHashes.findFirst({
where: {
OR: [
{
sha256: hashBinary,
},
{
md5: hashBinary,
},
],
},
select: {
user: {
select: {
currentAvatarId: true,
},
},
},
})
)?.user.currentAvatarId
let avPath: string | undefined
if (!forceDefault && avatarId)
switch (configuration.libravatar.resize_mode) {
case "nearest":
// find nearest size available
size = configuration.images.output_resolutions.includes(size)
? size
: configuration.images.output_resolutions
.slice()
.sort(
(a, b) => Math.abs(size - a) - Math.abs(size - b)
)[0]
// don't break here so it goes into the cache case
case "cache":
// get path to avatar
avPath = await getPathToAvatar(
avatarId,
size,
configuration.libravatar.output_format,
// bypass size limits if cache
// nearest shouldn't trigger this anyway but just in case
configuration.libravatar.resize_mode == "cache"
)
break
case "nocache":
const avatarPath = join(avatarDirectory, avatarId),
avImgs = await getAvailableSizesInPath(avatarPath),
avImageSizes = avImgs.map(e => e[0])
if (!avImageSizes.includes(size)) {
// we need to scale down
// find the next largest image resolution
let sortedSizes = [...avImageSizes, size].sort(
(a, b) => a - b
)
// try to get higher res if exists, otherwise get lower
let scaleDownFrom = join(
avatarPath,
avImgs[
avImageSizes.indexOf(
sortedSizes[sortedSizes.indexOf(size) + 1] ||
sortedSizes[sortedSizes.indexOf(size) - 1]
)
][1]
)
// render an avatar
let avatar = await renderAvatar(
await readFile(scaleDownFrom),
size,
configuration.libravatar.output_format
)
// serve image
return new Response(await avatar.img.toBuffer(), {
headers: {
"Content-Type":
mime.getType(avatar.extension!) || "",
},
})
} else {
// we don't need to scale down. serve this image
avPath = join(
avatarPath,
avImgs[avImageSizes.indexOf(size)][1]
)
}
}
if (!avPath) {
switch (fallback) {
case "404":
throw error(404, "Avatar not found")
case "mm":
case "mp":
// TODO: serve a default image at the correct size
throw redirect(302, "/avatars/default/image")
default:
throw redirect(302, fallback)
}
}
return new Response(await readFile(avPath), {
headers: {
"Content-Type": mime.getType(avPath) || "",
},
})
}

View file

@ -0,0 +1,80 @@
import { getRequestUser, launchLogin } from "$lib/oidc"
import configuration from "$lib/configuration.js"
import { error, fail, redirect } from "@sveltejs/kit"
import {
avatarDirectory,
createNewAvatar,
deleteAvatar,
executeHooksForUser,
getMetadataForUserId,
sanitizeAvatar,
} 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())
let avatar = await prisma.avatar.findUnique({
where: { id, userId: user.sub },
})
if (!avatar)
throw error(404, "Avatar is not owned by you or does not exist")
return {
url: url.toString(),
avatar,
}
}
export const actions = {
default: async ({ request, cookies, params: { id } }) => {
let user = await getRequestUser(request, cookies)
if (!user) return fail(401, { error: "unauthenticated" })
// check if user can edit
if (
!(await prisma.avatar.findUnique({
where: { id, userId: user.sub },
}))
)
return fail(404, {
error: "Avatar is not owned by you or does not exist",
})
let { action, altText, source } = Object.fromEntries(
(await request.formData()).entries()
)
const editingCurrentAvatar = await prisma.user.findUnique({
where: { userId: user.sub, currentAvatarId: id },
})
let data = {
altText: altText instanceof File ? undefined : altText,
source: source instanceof File ? undefined : source,
}
if (action == "Save") {
await prisma.avatar.update({
where: {
id,
},
data,
})
} else if (action == "Delete") {
await deleteAvatar(id)
}
// make sure they're editing the current avatar
if (editingCurrentAvatar)
// execute webhooks
executeHooksForUser(
user.sub,
sanitizeAvatar(action == "Save" ? { id, ...data } : null)
)
return redirect(302, "/set")
},
}

View file

@ -0,0 +1,94 @@
<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);
}
form input[type="submit"][value="Delete"] {
border: 1px solid var(--red);
background-color: color-mix(in srgb, var(--red) 20%, var(--background) 80%);
color: var(--red);
margin-right: auto;
}
</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="Delete">
<input type="submit" name="action" value="Save">
</div>
</form>

View file

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

@ -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 } }) {
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

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

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

View file

@ -1,131 +1,98 @@
<script lang="ts">
import StatusBanner from "$lib/components/StatusBanner.svelte";
import type { User } from "$lib/types";
import FilePreviewSet from "./FilePreviewSet.svelte";
import type { Avatar } from "@prisma/client";
import editIcon from "@fluentui/svg-icons/icons/pen_16_regular.svg?raw"
export let data: {
export interface Props {
data: {
user: User,
url: string,
avatar: {
altText: string,
source: string,
default: boolean
}
allowedImageTypes: string[],
renderSizes: number[]
avatars: (Avatar & {inUse: boolean})[]
};
export let form: { success: true, message: string } | { success: false, error: string } | undefined;
let files: FileList;
let fileSrc = `/avatar/${data.user.identifier}/`
form: { success: true, message: string } | { success: false, error: string } | undefined;
}
import FilePreviewSet from "$lib/components/FilePreviewSet.svelte";
$: 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}/`
let { data = $bindable(), form }: Props = $props();
</script>
<style>
form {
flex-direction: column;
}
form, form > .buttons, form > .metadata {
display: flex;
gap: 10px;
}
form > .metadata {
justify-content: center;
gap: 1em;
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;
input[type="submit"] {
width: 7em;
height: 7em;
aspect-ratio: 1 / 1;
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;
}
summary::marker {
content: ""
}
summary {
color: var(--link);
border: none;
cursor:pointer;
color: transparent;
}
.editButton {
border: 1px solid var(--crust);
padding: 5px;
aspect-ratio: 1 / 1;
border-radius: 8px;
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>
<h1>Hi, {data.user.name}</h1>
<p>
<details>
<summary>View user information...</summary>
<div>
<pre>{JSON.stringify(data.user, null, 4)}</pre>
</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}
</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>
<br>
<FilePreviewSet avatarUrl="/user/{data.user.identifier}/avatar" style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl="/user/{data.user.identifier}/avatar" style="border-radius:100%;" />
<br>
{#if form}
{#if form.success}
<details>
<summary><small>Avatar updated successfully</small></summary>
<div>
<pre>{form.message}</pre>
<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>
</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}
{/each}
<input type="submit" name="action" value="Clear" aria-label="Default avatar" style:background="center / cover no-repeat url('/avatars/default/image')" />
</form>
<footer>
<a href="/new">Add new avatar</a>
&bullet;
<a href="/webhooks">Webhooks</a>
&bullet;
<a href="/user/{data.user.identifier}" target="_blank">See user page</a>
</footer>

View file

@ -0,0 +1,14 @@
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,59 @@
<script lang="ts">
import { URL_REGEX } from "$lib/common"
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}
{@html
data.source
.replace(/\</g,"&lt;")
.replace(/\>/g,"&gt;")
.replace(URL_REGEX, (match) => `<a style="color:var(--text)" target="_blank" href=\"${match.replace(/\&/g,"<uamp>") /*lol*/}\">${match}</a>`)
.replace(/\&/g,"&amp;")
.replace(/\<uamp\>/g,"&")
}
{: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 } }) {
let { source } = await getMetadataForIdentifier(identifier)
let finalUrl: URL | string = `/user/${identifier}`
if (source) {
try {
finalUrl = new URL(source)
} catch {}
}
throw redirect(302, finalUrl)
}

View file

@ -0,0 +1,115 @@
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"
import { URL_REGEX } from "$lib/common.js"
export async function load({ request, parent, url }) {
const { user } = await parent()
if (!user) return launchLogin(url.toString())
return {
url: url.toString(),
webhooks: await prisma.webhook.findMany({
where: { userId: user.sub },
}),
}
}
export const actions = {
create: async ({ request, cookies }) => {
let user = await getRequestUser(request, cookies)
if (!user) return fail(401, { error: "unauthenticated" })
let { url } = Object.fromEntries((await request.formData()).entries())
if (!url || url instanceof File)
return fail(400, { error: "no url supplied" })
if (url.match(URL_REGEX)?.[0] !== url)
return fail(400, { error: "bad url" })
url = new URL(url).toString()
if (
await prisma.webhook.findFirst({
where: {
userId: user.sub,
url,
},
})
)
return fail(409, { error: "Webhook already exists" })
await prisma.webhook.create({
data: {
url,
userId: user.sub,
},
})
return {
success: true,
message: "New webhook created",
}
},
manage: async ({ request, cookies }) => {
let user = await getRequestUser(request, cookies)
if (!user) return fail(401, { error: "unauthenticated" })
let { action, toggle, url } = Object.fromEntries(
(await request.formData()).entries()
)
if (!url || url instanceof File)
return fail(400, { error: "no url supplied" })
let whk = await prisma.webhook.findUnique({
where: {
url_userId: {
url,
userId: user.sub,
},
},
})
if (!whk) return fail(404, { error: "webhook doesn't exist" })
if (action == "Delete") {
await prisma.webhook.delete({
where: {
url_userId: {
url,
userId: user.sub,
},
},
})
return {
success: true,
message: "Webhook deleted",
}
} else if (toggle) {
await prisma.webhook.update({
where: {
url_userId: {
url,
userId: user.sub,
},
},
data: {
enabled: !whk.enabled,
},
})
return {
success: true,
message: "Webhook updated",
}
}
},
}

View file

@ -0,0 +1,98 @@
<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,
webhooks: {id: string, url: string, enabled: boolean}[]
};
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 {
display: flex;
gap: 10px;
}
form > input[type="url"] {
flex-basis: 100%;
min-height: 1em;
}
form input[type="submit"], form {
cursor: pointer;
}
/*form input[name="id"] {
display: none;
}*/
form input {
font-family: "Inter Variable", "Inter", sans-serif;
padding: 0.5em 1em;
border-radius: 8px;
border: 1px solid var(--link);
color: var(--text);
background-color: var(--crust);
}
form input[type="submit"].enabled {
border: 1px solid var(--green);
background-color: color-mix(in srgb, var(--green) 20%, var(--background) 80%);
color: var(--green);
}
form input[type="submit"].disabled {
border: 1px solid var(--text);
background-color: var(--background);
opacity: 0.25;
}
form input[type="submit"].disabled, form input[type="submit"].enabled {
font-size: 1;
padding: 0.25em .5em;
flex-shrink: 0;
aspect-ratio: 1 / 1;
}
div {
display: flex;
flex-direction: column;
gap: .5em;
}
</style>
<ReversibleHeading to="/set">Manage webhooks</ReversibleHeading>
{#if form}
<br>
<StatusBanner status={form.success ? "success" : "error"}>{form.success ? form.message : form.error}</StatusBanner>
<br>
{/if}
<div>
{#each data.webhooks as webhook}
<form method="post" enctype="multipart/form-data" action="?/manage">
<input type="submit" name="toggle" class="{webhook.enabled ? "enabled" : "disabled"}" value="⏻" aria-label={webhook.enabled ? "Enabled" : "Disabled"}>
<input type="url" name="url" readonly value={webhook.url}>
<input type="submit" name="action" value="Delete">
</form>
{/each}
</div>
<br>
<form method="post" enctype="multipart/form-data" action="?/create">
<input type="url" name="url" placeholder="URL">
<input type="submit" name="action" value="Add">
</form>
<br>
<hr>
<br>
URLs added to this page will be sent a POST request with the following payload when you change or edit your current profile picture:
<pre>
{`{
"id": string
"altText": string
"source": string
"default": boolean
}`}
</pre>