Compare commits
12 commits
2360c2c745
...
3dbf5b2395
Author | SHA1 | Date | |
---|---|---|---|
May | 3dbf5b2395 | ||
May | 7442cb464b | ||
May | 3bd599a51d | ||
May | 060454533c | ||
May | aa67b08239 | ||
May | c70db40ffa | ||
May | 092c70da5b | ||
May | 17814b3478 | ||
May | 88ec069c55 | ||
May | 269db33bec | ||
May | a1294b145f | ||
May | eb5b86556d |
13
.env.example
13
.env.example
|
@ -26,6 +26,19 @@ IMAGES__DEFAULT_RESOLUTION=512
|
||||||
# Comma-separated list of permitted image mimetypes
|
# Comma-separated list of permitted image mimetypes
|
||||||
IMAGES__ALLOWED_INPUT_FORMATS=image/jpg,image/jpeg,image/png,image/webp,image/gif
|
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
|
# Prisma database URL
|
||||||
DATABASE_URL=file:../.data/data.db
|
DATABASE_URL=file:../.data/data.db
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
{
|
{
|
||||||
"useTabs": true,
|
"singleQuote": false,
|
||||||
"singleQuote": true,
|
"trailingComma": "es5",
|
||||||
"trailingComma": "none",
|
"arrowParens": "avoid",
|
||||||
"printWidth": 100,
|
"tabWidth": 4,
|
||||||
|
"semi": false,
|
||||||
"plugins": ["prettier-plugin-svelte"],
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
}
|
}
|
||||||
|
|
1940
package-lock.json
generated
1940
package-lock.json
generated
File diff suppressed because it is too large
Load diff
19
package.json
19
package.json
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "ava-node",
|
"name": "ava",
|
||||||
"version": "1.3.2",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
@ -13,23 +13,24 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/adapter-node": "^5.2.0",
|
"@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",
|
"@types/node": "^20.14.10",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.1.1",
|
||||||
"prettier-plugin-svelte": "^3.1.2",
|
"prettier-plugin-svelte": "^3.2.6",
|
||||||
"prisma": "^5.16.2",
|
"prisma": "^5.16.2",
|
||||||
"svelte": "^4.2.7",
|
"svelte": "^5.0.0",
|
||||||
"svelte-check": "^3.6.0",
|
"svelte-check": "^4.0.0",
|
||||||
"tslib": "^2.4.1",
|
"tslib": "^2.4.1",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.5.0",
|
||||||
"vite": "^5.0.3"
|
"vite": "^5.4.4"
|
||||||
},
|
},
|
||||||
"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.0.0",
|
"@sveltejs/kit": "^2.5.27",
|
||||||
"mime": "^4.0.4",
|
"mime": "^4.0.4",
|
||||||
"sharp": "^0.33.4"
|
"sharp": "^0.33.4"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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");
|
|
|
@ -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;
|
|
|
@ -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;
|
|
|
@ -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");
|
|
|
@ -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");
|
|
|
@ -1,3 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "Avatar" ADD COLUMN "altText" TEXT;
|
|
||||||
ALTER TABLE "Avatar" ADD COLUMN "source" TEXT;
|
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "User" ADD COLUMN "name" TEXT;
|
|
38
prisma/migrations/20241120054008_2_0init/migration.sql
Normal file
38
prisma/migrations/20241120054008_2_0init/migration.sql
Normal 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");
|
20
prisma/migrations/20241120075128_oops/migration.sql
Normal file
20
prisma/migrations/20241120075128_oops/migration.sql
Normal 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;
|
34
prisma/migrations/20241120075821_flip_relation/migration.sql
Normal file
34
prisma/migrations/20241120075821_flip_relation/migration.sql
Normal 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;
|
10
prisma/migrations/20241120195003_add_webhooks/migration.sql
Normal file
10
prisma/migrations/20241120195003_add_webhooks/migration.sql
Normal 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");
|
|
@ -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
|
||||||
|
);
|
20
prisma/migrations/20241121005623_add_md5/migration.sql
Normal file
20
prisma/migrations/20241121005623_add_md5/migration.sql
Normal 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;
|
|
@ -22,13 +22,37 @@ model User {
|
||||||
userId String @id @unique
|
userId String @id @unique
|
||||||
identifier String
|
identifier String
|
||||||
name String?
|
name String?
|
||||||
avatar Avatar?
|
avatars Avatar[]
|
||||||
|
webhooks Webhook[]
|
||||||
|
emailHashes EmailHashes?
|
||||||
|
|
||||||
|
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 @unique
|
userId String
|
||||||
|
|
||||||
|
usedBy User? @relation("CurrentAvatar")
|
||||||
|
|
||||||
altText String?
|
altText String?
|
||||||
source 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
|
||||||
|
}
|
||||||
|
|
|
@ -5,6 +5,7 @@ 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"
|
||||||
|
@ -12,10 +13,17 @@ export const defaultAvatarDirectory = "./.data/defaultAvatar/"
|
||||||
|
|
||||||
await mkdir(defaultAvatarDirectory, { recursive: true })
|
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,
|
string,
|
||||||
Promise<string>
|
]
|
||||||
>()
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Generate an avatar at the selected size and format
|
* @description Generate an avatar at the selected size and format
|
||||||
|
@ -24,24 +32,24 @@ export const missingAvatarQueue = new Map<
|
||||||
* @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(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])
|
let qid = JSON.stringify([path, size, fmt])
|
||||||
if (missingAvatarQueue.has(qid))
|
if (missingAvatarQueue.has(qid)) return missingAvatarQueue.get(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
|
||||||
const av = await readdir(path)
|
|
||||||
// this can probably be done better but I DON'T GIVE A FUCK !!!!
|
|
||||||
const pathToBestQualityImg =
|
const pathToBestQualityImg =
|
||||||
path == defaultAvatarDirectory
|
path == defaultAvatarDirectory
|
||||||
? "./assets/default.png"
|
? "./assets/default.png"
|
||||||
: join(
|
: join(
|
||||||
path,
|
path,
|
||||||
av
|
(await getAvailableSizesInPath(path)).sort(
|
||||||
.map(e => [parseInt(e.match(/(.*)\..*/)?.[1] || "", 10), e] as [number, string])
|
([a], [b]) => b - a
|
||||||
.sort(([a],[b]) => b - a)
|
)[0][1]
|
||||||
[0][1]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const buf = await readFile(pathToBestQualityImg)
|
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
|
* @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 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 getPathToAvatarForUid(uid?: string, size: number = configuration.images.default_resolution, fmt?: string) {
|
export async function getPathToAvatar(
|
||||||
if (uid?.includes("/"))
|
avatarId?: string,
|
||||||
throw Error("UID cannot include /")
|
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
|
// 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
|
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 userAvatarDirectory = uid ? join(avatarDirectory, uid) : defaultAvatarDirectory
|
let avDir = avatarId
|
||||||
if (!existsSync(userAvatarDirectory))
|
? join(avatarDirectory, avatarId)
|
||||||
userAvatarDirectory = defaultAvatarDirectory
|
: defaultAvatarDirectory
|
||||||
|
|
||||||
|
if (!existsSync(avDir)) avDir = defaultAvatarDirectory
|
||||||
|
|
||||||
// bind a makeMissing function
|
// bind a makeMissing function
|
||||||
const makeMissing = generateMissingAvatar.bind(null, userAvatarDirectory, size, fmt as keyof FormatEnum)
|
const makeMissing = generateMissingAvatar.bind(
|
||||||
|
null,
|
||||||
// get directory to extract imgs from
|
avDir,
|
||||||
let targetAvatarDirectory = join(userAvatarDirectory, fmt||"")
|
size,
|
||||||
|
fmt as keyof FormatEnum
|
||||||
// if there's no images for the specified fmt, generate new ones
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (targetAvatar)
|
// get directory to extract imgs from
|
||||||
return join(targetAvatarDirectory, targetAvatar.name)
|
let targetAvatarDirectory = join(avDir, fmt || "")
|
||||||
else if (configuration.images.output_resolutions.includes(size))
|
|
||||||
|
// if there's no images for the specified fmt, generate new ones
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
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) {
|
export async function getPathToAvatarForIdentifier(
|
||||||
let user = await prisma.user.findFirst({
|
identifier: string,
|
||||||
|
size: number = configuration.images.default_resolution,
|
||||||
|
fmt?: string
|
||||||
|
) {
|
||||||
|
let avatar = await prisma.avatar.findFirst({
|
||||||
where: {
|
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
|
return avatar
|
||||||
? {
|
? {
|
||||||
|
id: avatar.id,
|
||||||
altText: avatar.altText || "",
|
altText: avatar.altText || "",
|
||||||
source: avatar.source || "",
|
source: avatar.source || "",
|
||||||
default: false
|
default: false,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
id: "default",
|
||||||
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.avatar.findFirst({
|
let avatar = await prisma.user
|
||||||
|
.findFirst({
|
||||||
where: {
|
where: {
|
||||||
user: {
|
identifier,
|
||||||
identifier
|
},
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
.currentAvatar()
|
||||||
|
|
||||||
return sanitizeAvatar(avatar)
|
return sanitizeAvatar(avatar)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMetadataForUserId(userId: string) {
|
export async function getMetadataForUserId(userId: string) {
|
||||||
let avatar = await prisma.avatar.findFirst({
|
let avatar = await prisma.user
|
||||||
|
.findFirst({
|
||||||
where: {
|
where: {
|
||||||
userId
|
userId,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
.currentAvatar()
|
||||||
|
|
||||||
return sanitizeAvatar(avatar)
|
return sanitizeAvatar(avatar)
|
||||||
}
|
}
|
||||||
|
@ -148,16 +200,23 @@ 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(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()
|
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(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize)
|
let realSquareSize = Math.min(
|
||||||
|
...([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)
|
||||||
|
@ -167,81 +226,124 @@ export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number,
|
||||||
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(avatarDir: string, renderedAvatar: Awaited<ReturnType<typeof renderAvatar>>) {
|
export async function writeAvatar(
|
||||||
|
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 })
|
||||||
|
|
||||||
const targetPath = join(
|
const targetPath = join(
|
||||||
targetDir,
|
targetDir,
|
||||||
`${renderedAvatar.squareSize}.${renderedAvatar.extension}`
|
`${renderedAvatar.squareSize}.${renderedAvatar.extension}`
|
||||||
)
|
)
|
||||||
|
|
||||||
await writeFile(
|
await writeFile(targetPath, renderedAvatar.img)
|
||||||
targetPath,
|
|
||||||
renderedAvatar.img
|
|
||||||
)
|
|
||||||
|
|
||||||
return targetPath
|
return targetPath
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setNewAvatar(uid: string, avatar?: File) {
|
export async function createNewAvatar(
|
||||||
if (uid?.includes("/"))
|
uid: string,
|
||||||
throw Error("UID cannot include /")
|
avatar: File,
|
||||||
|
metadata: { altText?: string; source?: string } = {}
|
||||||
// Delete current avatar directory and avatar database entry
|
) {
|
||||||
const userAvatarDirectory = join(avatarDirectory, uid)
|
const avatarId = randomUUID()
|
||||||
await rm(userAvatarDirectory, { recursive: true, force: true })
|
const newAvatarDirectory = join(avatarDirectory, avatarId)
|
||||||
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(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
|
// 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
|
...configuration.images.extra_output_types.map(e => [e, -1]),
|
||||||
.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(userAvatarDirectory, rendered)
|
await writeAvatar(newAvatarDirectory, rendered)
|
||||||
time[x][t || "input"] = rendered.time
|
time[x][t || "input"] = rendered.time
|
||||||
} catch (e) { // clear pfp and throw if error encountered
|
} catch (e) {
|
||||||
await rm(userAvatarDirectory, { recursive: true, force: true })
|
// clear pfp and throw if error encountered
|
||||||
|
await rm(newAvatarDirectory, { recursive: true, force: true })
|
||||||
throw e
|
throw e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// create new Avatar database entry
|
// create new Avatar database entry
|
||||||
await prisma.avatar.create({
|
await prisma.avatar
|
||||||
|
.create({
|
||||||
data: {
|
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
|
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
3
src/lib/common.ts
Normal 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
|
|
@ -1,6 +1,10 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
export let style: string = "";
|
export interface Props {
|
||||||
export let avatarUrl: string;
|
style?: string;
|
||||||
|
avatarUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { style = "", avatarUrl }: Props = $props();
|
||||||
</script>
|
</script>
|
||||||
<style>
|
<style>
|
||||||
div {
|
div {
|
38
src/lib/components/ReversibleHeading.svelte
Normal file
38
src/lib/components/ReversibleHeading.svelte
Normal 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>
|
28
src/lib/components/StatusBanner.svelte
Normal file
28
src/lib/components/StatusBanner.svelte
Normal 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>
|
|
@ -1,32 +1,83 @@
|
||||||
import Sharp, { type FormatEnum } from 'sharp';
|
import Sharp, { type FormatEnum } from "sharp"
|
||||||
import { env } from '$env/dynamic/private';
|
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 = {
|
const configuration = {
|
||||||
oauth2: {
|
oauth2: {
|
||||||
endpoints: {
|
endpoints: {
|
||||||
authenticate: env.OAUTH2__AUTHENTICATE!,
|
authenticate: env.OAUTH2__AUTHENTICATE!,
|
||||||
logout: env.OAUTH2__LOGOUT,
|
logout: env.OAUTH2__LOGOUT,
|
||||||
token: env.OAUTH2__GET_TOKEN!
|
token: env.OAUTH2__GET_TOKEN!,
|
||||||
},
|
},
|
||||||
client: {
|
client: {
|
||||||
id: env.OAUTH2__CLIENT_ID!,
|
id: env.OAUTH2__CLIENT_ID!,
|
||||||
secret: env.OAUTH2__CLIENT_SECRET!,
|
secret: env.OAUTH2__CLIENT_SECRET!,
|
||||||
scopes: env.OAUTH2__SCOPES!
|
scopes: env.OAUTH2__SCOPES!,
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
userinfo: {
|
userinfo: {
|
||||||
route: env.USERINFO__ROUTE!,
|
route: env.USERINFO__ROUTE!,
|
||||||
identifier: env.USERINFO__IDENTIFIER!
|
identifier: env.USERINFO__IDENTIFIER!,
|
||||||
},
|
},
|
||||||
images: {
|
images: {
|
||||||
permitted_input: (env.ALLOWED_TYPES || env.IMAGES__ALLOWED_INPUT_FORMATS)?.split(',') || [],
|
permitted_input:
|
||||||
default_resolution: parseInt((env.IMAGES__DEFAULT_RESOLUTION || '').toString(), 10) || 512,
|
(env.ALLOWED_TYPES || env.IMAGES__ALLOWED_INPUT_FORMATS)?.split(
|
||||||
|
","
|
||||||
|
) || [],
|
||||||
|
default_resolution:
|
||||||
|
parseInt((env.IMAGES__DEFAULT_RESOLUTION || "").toString(), 10) ||
|
||||||
|
512,
|
||||||
extra_output_types:
|
extra_output_types:
|
||||||
(env.IMAGES__EXTRA_OUTPUT_FORMATS?.split(',').filter(
|
(env.IMAGES__EXTRA_OUTPUT_FORMATS?.split(",").filter(
|
||||||
(e) => e in Sharp.format
|
e => e in Sharp.format
|
||||||
) as (keyof FormatEnum)[]) || [],
|
) as (keyof FormatEnum)[]) || [],
|
||||||
output_resolutions: env.IMAGES__OUTPUT_RESOLUTIONS?.split(',').map((e) => parseInt(e, 10)) || [
|
output_resolutions: env.IMAGES__OUTPUT_RESOLUTIONS?.split(",").map(e =>
|
||||||
1024, 512, 256, 128, 64, 32
|
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
|
||||||
|
|
120
src/lib/oidc.ts
120
src/lib/oidc.ts
|
@ -2,9 +2,14 @@ import { error, redirect, type Cookies } from "@sveltejs/kit"
|
||||||
import configuration from "./configuration"
|
import configuration from "./configuration"
|
||||||
import type { User } from "./types"
|
import type { User } from "./types"
|
||||||
import { prisma } from "./clientsingleton"
|
import { prisma } from "./clientsingleton"
|
||||||
|
import type { EmailHashes } from "@prisma/client"
|
||||||
|
import crypto from "node:crypto"
|
||||||
|
|
||||||
// Map of OAuth2 states
|
// 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
|
// Cache of userinfo
|
||||||
const userInfoCache = new Map<string, User>()
|
const userInfoCache = new Map<string, User>()
|
||||||
|
|
||||||
|
@ -22,7 +27,7 @@ export function launchLogin(url: string) {
|
||||||
client_id: configuration.oauth2.client.id,
|
client_id: configuration.oauth2.client.id,
|
||||||
redirect_uri: url,
|
redirect_uri: url,
|
||||||
scope: configuration.oauth2.client.scopes,
|
scope: configuration.oauth2.client.scopes,
|
||||||
state
|
state,
|
||||||
})
|
})
|
||||||
// Did not think this would work lmao
|
// Did not think this would work lmao
|
||||||
const target = new URL(
|
const target = new URL(
|
||||||
|
@ -33,17 +38,10 @@ export function launchLogin(url: string) {
|
||||||
// cache state
|
// cache state
|
||||||
// NO IDEA IF THIS WORKS IN SERVERLESS LOL
|
// NO IDEA IF THIS WORKS IN SERVERLESS LOL
|
||||||
// not like this is going to be running serverless anyway
|
// not like this is going to be running serverless anyway
|
||||||
states
|
states.set(state, {
|
||||||
.set(
|
timeout: setTimeout(() => states.delete(state), 2 * 60 * 1000),
|
||||||
state,
|
redirect_uri: url,
|
||||||
{
|
})
|
||||||
timeout: setTimeout(
|
|
||||||
() => states.delete(state),
|
|
||||||
2*60*1000
|
|
||||||
),
|
|
||||||
redirect_uri: url
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
throw redirect(302, target.toString())
|
throw redirect(302, target.toString())
|
||||||
}
|
}
|
||||||
|
@ -55,38 +53,46 @@ export function launchLogin(url: string) {
|
||||||
*/
|
*/
|
||||||
export async function getNewToken(
|
export async function getNewToken(
|
||||||
params:
|
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
|
// Generate a query string for the request
|
||||||
const searchParams = new URLSearchParams({
|
const searchParams = new URLSearchParams({
|
||||||
...params,
|
...params,
|
||||||
client_id: configuration.oauth2.client.id,
|
client_id: configuration.oauth2.client.id,
|
||||||
client_secret: configuration.oauth2.client.secret
|
client_secret: configuration.oauth2.client.secret,
|
||||||
})
|
})
|
||||||
|
|
||||||
// send request to retrieve tokens
|
// send request to retrieve tokens
|
||||||
let res = await fetch(configuration.oauth2.endpoints.token, {
|
let res = await fetch(configuration.oauth2.endpoints.token, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: searchParams // this standard sucks, actually
|
body: searchParams, // this standard sucks, actually
|
||||||
})
|
})
|
||||||
if (res.ok)
|
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) {
|
export function fetchUserInfo(token: string) {
|
||||||
// try fetching new userinfo
|
// try fetching new userinfo
|
||||||
return fetch(configuration.userinfo.route, {
|
return fetch(configuration.userinfo.route, {
|
||||||
headers: {
|
headers: {
|
||||||
"Authorization": `Bearer ${token}`
|
Authorization: `Bearer ${token}`,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getUserInfo(id: string) {
|
export async function getUserInfo(id: string) {
|
||||||
// fetch token information
|
// fetch token information
|
||||||
const tokenInfo = await prisma.token.findUnique({
|
const tokenInfo = await prisma.token.findUnique({
|
||||||
where: { id }
|
where: { id },
|
||||||
})
|
})
|
||||||
if (!tokenInfo) return
|
if (!tokenInfo) return
|
||||||
|
|
||||||
|
@ -103,15 +109,15 @@ export async function getUserInfo(id: string) {
|
||||||
if (!tokenInfo.refreshToken) return // no refresh token. back out
|
if (!tokenInfo.refreshToken) return // no refresh token. back out
|
||||||
let token = await getNewToken({
|
let token = await getNewToken({
|
||||||
grant_type: "refresh_token",
|
grant_type: "refresh_token",
|
||||||
refresh_token: tokenInfo.refreshToken
|
refresh_token: tokenInfo.refreshToken,
|
||||||
})
|
})
|
||||||
if (!token) return // refresh failed. back out
|
if (!token) return // refresh failed. back out
|
||||||
await prisma.token.update({
|
await prisma.token.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
token: token.access_token,
|
token: token.access_token,
|
||||||
refreshToken: token.refresh_token
|
refreshToken: token.refresh_token,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
userInfoRequest = await fetchUserInfo(token.access_token)
|
userInfoRequest = await fetchUserInfo(token.access_token)
|
||||||
|
@ -120,6 +126,20 @@ export async function getUserInfo(id: string) {
|
||||||
|
|
||||||
userInfo = await userInfoRequest.json()
|
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
|
// update user
|
||||||
await prisma.user.upsert({
|
await prisma.user.upsert({
|
||||||
where: {
|
where: {
|
||||||
|
@ -127,37 +147,58 @@ export async function getUserInfo(id: string) {
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
identifier: userInfo[configuration.userinfo.identifier],
|
identifier: userInfo[configuration.userinfo.identifier],
|
||||||
name: userInfo.name
|
name: userInfo.name,
|
||||||
|
...(emailHashes
|
||||||
|
? {
|
||||||
|
emailHashes: {
|
||||||
|
upsert: {
|
||||||
|
create: emailHashes,
|
||||||
|
update: emailHashes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
create: {
|
create: {
|
||||||
userId: userInfo.sub,
|
userId: userInfo.sub,
|
||||||
identifier: userInfo[configuration.userinfo.identifier],
|
identifier: userInfo[configuration.userinfo.identifier],
|
||||||
name: userInfo.name
|
name: userInfo.name,
|
||||||
|
...(emailHashes
|
||||||
|
? {
|
||||||
|
emailHashes: {
|
||||||
|
create: emailHashes,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// cache userinfo
|
// cache userinfo
|
||||||
userInfoCache.set(tokenInfo.owner, userInfo)
|
userInfoCache.set(tokenInfo.owner, userInfo)
|
||||||
setTimeout(() => userInfoCache.delete(tokenInfo.owner), 15*60*1000)
|
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) {
|
export function deleteToken(id: string) {
|
||||||
prisma.token.delete({
|
prisma.token.delete({
|
||||||
where: {id}
|
where: { id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRequestUser(request: Request, cookies: Cookies) {
|
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")
|
let token = cookies.get("token")
|
||||||
// log user in
|
// log user in
|
||||||
if (!token && params.has("code") && params.has("state")) {
|
if (!token && params.has("code") && params.has("state")) {
|
||||||
// check if state is real
|
// check if state is real
|
||||||
if (!states.has(params.get("state")!))
|
if (!states.has(params.get("state")!)) throw error(401, "bad state")
|
||||||
throw error(401, "bad state")
|
|
||||||
|
|
||||||
// get state
|
// get state
|
||||||
let state = states.get(params.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({
|
let tokens = await getNewToken({
|
||||||
grant_type: "authorization_code",
|
grant_type: "authorization_code",
|
||||||
redirect_uri: state.redirect_uri,
|
redirect_uri: state.redirect_uri,
|
||||||
code: params.get("code")!
|
code: params.get("code")!,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!tokens)
|
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
|
// fetch userdata
|
||||||
// could cache this, but lazy
|
// 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
|
// create a new token
|
||||||
let newToken = await prisma.token.create({
|
let newToken = await prisma.token.create({
|
||||||
data: {
|
data: {
|
||||||
token: tokens.access_token,
|
token: tokens.access_token,
|
||||||
refreshToken: tokens.refresh_token,
|
refreshToken: tokens.refresh_token,
|
||||||
owner: userInfo.sub
|
owner: userInfo.sub,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
token = newToken.id
|
token = newToken.id
|
||||||
|
|
|
@ -2,4 +2,5 @@ export interface User {
|
||||||
name: string
|
name: string
|
||||||
sub: string
|
sub: string
|
||||||
identifier: string
|
identifier: string
|
||||||
|
email?: string
|
||||||
}
|
}
|
|
@ -3,7 +3,13 @@
|
||||||
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";
|
||||||
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__}`
|
const buildName = `${__APP_NAME__} ${__APP_VERSION__}`
|
||||||
</script>
|
</script>
|
||||||
|
@ -15,15 +21,23 @@
|
||||||
--link: #333;
|
--link: #333;
|
||||||
--background: white;
|
--background: white;
|
||||||
--crust: #eee;
|
--crust: #eee;
|
||||||
|
--red: #d20f39;
|
||||||
|
--yellow: #df8e1d;
|
||||||
|
--green: #40a02b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme:dark) {
|
@media (prefers-color-scheme:dark) {
|
||||||
:root {
|
:root {
|
||||||
--text: white;
|
--text: white;
|
||||||
--link: #aaa;
|
--link: #aaa;
|
||||||
--background: #111;
|
--background: #111;
|
||||||
--crust: #333;
|
--crust: #333;
|
||||||
|
--red: #f38ba8;
|
||||||
|
--yellow: #f9e2af;
|
||||||
|
--green: #a6e3a1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
html {
|
||||||
background: var(--background);
|
background: var(--background);
|
||||||
}
|
}
|
||||||
|
@ -64,20 +78,17 @@
|
||||||
}
|
}
|
||||||
</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>
|
||||||
{#if data.user}
|
{#if data.user}
|
||||||
<a href="/logout">Log out</a>
|
<a href="/logout">Log out</a>
|
||||||
{/if}
|
{/if}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<slot />
|
{@render children?.()}
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
{import.meta.env.DEV ? "[DEV]" : ""}
|
{import.meta.env.DEV ? "[DEV]" : ""}
|
||||||
{buildName}
|
{buildName}
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
|
139
src/routes/avatar/[hash]/+server.ts
Normal file
139
src/routes/avatar/[hash]/+server.ts
Normal 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) || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
80
src/routes/avatars/[id]/+page.server.ts
Normal file
80
src/routes/avatars/[id]/+page.server.ts
Normal 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")
|
||||||
|
},
|
||||||
|
}
|
94
src/routes/avatars/[id]/+page.svelte
Normal file
94
src/routes/avatars/[id]/+page.svelte
Normal 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>
|
29
src/routes/avatars/[id]/image/[[size]]/+server.ts
Normal file
29
src/routes/avatars/[id]/image/[[size]]/+server.ts
Normal 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) || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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>
|
|
|
@ -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)
|
|
||||||
}
|
|
51
src/routes/new/+page.server.ts
Normal file
51
src/routes/new/+page.server.ts
Normal 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")
|
||||||
|
},
|
||||||
|
}
|
89
src/routes/new/+page.svelte
Normal file
89
src/routes/new/+page.svelte
Normal 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>
|
|
@ -1,64 +1,90 @@
|
||||||
import {getRequestUser, launchLogin} from "$lib/oidc"
|
import { getRequestUser, launchLogin } from "$lib/oidc"
|
||||||
import configuration from "$lib/configuration.js";
|
import configuration from "$lib/configuration.js"
|
||||||
import { fail } from "@sveltejs/kit";
|
import { fail } from "@sveltejs/kit"
|
||||||
import { avatarDirectory, getMetadataForUserId, setNewAvatar } from "$lib/avatars.js";
|
import {
|
||||||
import { join } from "path";
|
avatarDirectory,
|
||||||
import { prisma } from "$lib/clientsingleton";
|
executeHooksForUser,
|
||||||
|
getMetadataForUserId,
|
||||||
|
sanitizeAvatar,
|
||||||
|
} from "$lib/avatars.js"
|
||||||
|
import { join } from "path"
|
||||||
|
import { prisma } from "$lib/clientsingleton"
|
||||||
export async function load({ request, parent, url }) {
|
export async function load({ request, parent, url }) {
|
||||||
const { user } = await parent();
|
const { user } = await parent()
|
||||||
if (!user)
|
if (!user) return launchLogin(url.toString())
|
||||||
return launchLogin(url.toString())
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: url.toString(),
|
url: url.toString(),
|
||||||
avatar: await getMetadataForUserId(user.sub),
|
avatars: (
|
||||||
allowedImageTypes: configuration.images.permitted_input,
|
await prisma.avatar.findMany({
|
||||||
renderSizes: configuration.images.output_resolutions
|
where: { userId: user.sub },
|
||||||
|
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)
|
if (!user) return fail(401, { error: "unauthenticated" })
|
||||||
return fail(401, {error: "unauthenticated"})
|
|
||||||
|
|
||||||
let submission = Object.fromEntries((await request.formData()).entries());
|
let submission = Object.fromEntries(
|
||||||
let newAvatar = submission.newAvatar
|
(await request.formData()).entries()
|
||||||
let timing: Awaited<ReturnType<typeof setNewAvatar>> = {}
|
)
|
||||||
let isUploadingNewFile = submission.action == "Clear"
|
|
||||||
|
|
||||||
if (
|
if (typeof submission.action != "string")
|
||||||
!isUploadingNewFile // if action isn't already clear
|
return fail(400, { error: "bad action" })
|
||||||
&& 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 (isUploadingNewFile)
|
if (submission.action == "Clear") {
|
||||||
timing = await setNewAvatar(user.sub, newAvatar as File|null || undefined)
|
await prisma.user.update({
|
||||||
if (await prisma.avatar.findFirst({ where: { userId: user.sub } }))
|
|
||||||
await prisma.avatar.update({
|
|
||||||
where: {
|
where: {
|
||||||
userId: user.sub
|
userId: user.sub,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
altText: typeof submission.altText == "string" ? submission.altText : null,
|
currentAvatarId: null,
|
||||||
source: typeof submission.source == "string" ? submission.source : null,
|
},
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
|
executeHooksForUser(user.sub, sanitizeAvatar(null))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: Object.entries(timing)
|
message: "Avatar cleared successfully",
|
||||||
.map(([res, time]) => `render ${res}x${res}: ${
|
}
|
||||||
Object.entries(time).map(([type, t]) => `${type}: ${t}ms`).join(", ")
|
} else if (submission.action.startsWith("Set:")) {
|
||||||
}`)
|
let avatarId = submission.action.match(/Set\:(.*)/)![1]
|
||||||
.join("\n")
|
|
||||||
|| "No timing information available"
|
// 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",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
}
|
}
|
|
@ -1,131 +1,98 @@
|
||||||
<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 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,
|
user: User,
|
||||||
url: string,
|
url: string,
|
||||||
avatar: {
|
avatars: (Avatar & {inUse: boolean})[]
|
||||||
altText: string,
|
|
||||||
source: string,
|
|
||||||
default: boolean
|
|
||||||
}
|
|
||||||
allowedImageTypes: string[],
|
|
||||||
renderSizes: number[]
|
|
||||||
};
|
};
|
||||||
export let form: { success: true, message: string } | { success: false, error: string } | undefined;
|
form: { success: true, message: string } | { success: false, error: string } | undefined;
|
||||||
let files: FileList;
|
}
|
||||||
let fileSrc = `/avatar/${data.user.identifier}/`
|
import FilePreviewSet from "$lib/components/FilePreviewSet.svelte";
|
||||||
|
|
||||||
$: if (files && files.length >= 0) {
|
let { data = $bindable(), form }: Props = $props();
|
||||||
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;
|
||||||
gap: 10px;
|
justify-content: center;
|
||||||
}
|
gap: 1em;
|
||||||
form > .metadata {
|
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
form > .metadata > textarea {
|
input[type="submit"] {
|
||||||
height: 3em;
|
width: 7em;
|
||||||
flex-grow: 1;
|
height: 7em;
|
||||||
min-width: 15em;
|
aspect-ratio: 1 / 1;
|
||||||
}
|
|
||||||
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: 1px solid var(--link);
|
border: none;
|
||||||
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: 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>
|
</style>
|
||||||
|
|
||||||
<h1>Hi, {data.user.name}</h1>
|
<h1>Hi, {data.user.name}</h1>
|
||||||
<p>
|
<br>
|
||||||
<details>
|
<FilePreviewSet avatarUrl="/user/{data.user.identifier}/avatar" style="border-radius:8px;" />
|
||||||
<summary>View user information...</summary>
|
<br>
|
||||||
<div>
|
<FilePreviewSet avatarUrl="/user/{data.user.identifier}/avatar" style="border-radius:100%;" />
|
||||||
<pre>{JSON.stringify(data.user, null, 4)}</pre>
|
<br>
|
||||||
</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>
|
|
||||||
{#if form}
|
{#if form}
|
||||||
{#if form.success}
|
<StatusBanner status={form.success ? "success" : "error"}>{form.success ? form.message : form.error}</StatusBanner>
|
||||||
<details>
|
|
||||||
<summary><small>Avatar updated successfully</small></summary>
|
|
||||||
<div>
|
|
||||||
<pre>{form.message}</pre>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{:else}
|
|
||||||
<small>An error occurred: {form.error}</small>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
{/if}
|
||||||
{#key fileSrc}
|
<h2>Your avatars</h2>
|
||||||
<br>
|
<form method="post">
|
||||||
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:8px;" />
|
{#each data.avatars as avatar}
|
||||||
<br>
|
<div class="idiv">
|
||||||
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:100%;" />
|
<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} />
|
||||||
{/key}
|
<a href="/avatars/{avatar.id}" aria-label="Edit" class="editButton">
|
||||||
|
{@html editIcon.replace("svg", "svg style=\"width: 16px; height: 16px;\"")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{/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>
|
||||||
|
•
|
||||||
|
<a href="/webhooks">Webhooks</a>
|
||||||
|
•
|
||||||
|
<a href="/user/{data.user.identifier}" target="_blank">See user page</a>
|
||||||
|
</footer>
|
14
src/routes/user/[identifier]/+page.server.ts
Normal file
14
src/routes/user/[identifier]/+page.server.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
59
src/routes/user/[identifier]/+page.svelte
Normal file
59
src/routes/user/[identifier]/+page.svelte
Normal 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,"<")
|
||||||
|
.replace(/\>/g,">")
|
||||||
|
.replace(URL_REGEX, (match) => `<a style="color:var(--text)" target="_blank" href=\"${match.replace(/\&/g,"<uamp>") /*lol*/}\">${match}</a>`)
|
||||||
|
.replace(/\&/g,"&")
|
||||||
|
.replace(/\<uamp\>/g,"&")
|
||||||
|
}
|
||||||
|
{:else}
|
||||||
|
<em>No source available</em>
|
||||||
|
{/if}
|
||||||
|
</p>
|
15
src/routes/user/[identifier]/source/+server.ts
Normal file
15
src/routes/user/[identifier]/source/+server.ts
Normal 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)
|
||||||
|
}
|
115
src/routes/webhooks/+page.server.ts
Normal file
115
src/routes/webhooks/+page.server.ts
Normal 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
98
src/routes/webhooks/+page.svelte
Normal file
98
src/routes/webhooks/+page.svelte
Normal 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>
|
Loading…
Reference in a new issue