add alt text, source

this code is fucking terrible. i'll clean it up later
This commit is contained in:
split / May 2024-08-22 21:31:21 -07:00
parent b7068ed630
commit 968a49bd46
Signed by: split
GPG key ID: C325C61F0BF517C0
14 changed files with 272 additions and 62 deletions

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{ {
"name": "ava-node", "name": "ava-node",
"version": "1.1.1", "version": "1.2.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "ava-node", "name": "ava-node",
"version": "1.1.1", "version": "1.2.1",
"dependencies": { "dependencies": {
"@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",

View file

@ -1,6 +1,6 @@
{ {
"name": "ava-node", "name": "ava-node",
"version": "1.2.1", "version": "1.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

View file

@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "Avatar" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
CONSTRAINT "Avatar_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("userId") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Avatar_id_key" ON "Avatar"("id");
-- CreateIndex
CREATE UNIQUE INDEX "Avatar_userId_key" ON "Avatar"("userId");

View file

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

View file

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

View file

@ -21,4 +21,14 @@ model Token {
model User { model User {
userId String @id @unique userId String @id @unique
identifier String identifier String
name String?
avatar Avatar?
}
model Avatar {
id String @id @unique @default(uuid())
user User @relation(fields: [userId], references: [userId])
userId String @unique
altText String?
source String?
} }

View file

@ -4,6 +4,7 @@ import { join } from "node:path"
import { prisma } from "./clientsingleton" import { prisma } from "./clientsingleton"
import configuration from "./configuration" import configuration from "./configuration"
import Sharp, { type FormatEnum } from "sharp" import Sharp, { type FormatEnum } from "sharp"
import type { Avatar } from "@prisma/client"
// todo: make customizable // todo: make customizable
export const avatarDirectory = "./.data/avatars" export const avatarDirectory = "./.data/avatars"
@ -104,6 +105,42 @@ export async function getPathToAvatarForIdentifier(identifier: string, size: num
return getPathToAvatarForUid(user?.userId, size, fmt) return getPathToAvatarForUid(user?.userId, size, fmt)
} }
function sanitizeAvatar(avatar: Avatar | null) {
return avatar
? {
altText: avatar.altText || "",
source: avatar.source || "",
default: false
}
: {
altText: "Default profile picture",
source: "https://git.sucks.win/split/ava",
default: true
}
}
export async function getMetadataForIdentifier(identifier: string) {
let avatar = await prisma.avatar.findFirst({
where: {
user: {
identifier
}
}
})
return sanitizeAvatar(avatar)
}
export async function getMetadataForUserId(userId: string) {
let avatar = await prisma.avatar.findFirst({
where: {
userId
}
})
return sanitizeAvatar(avatar)
}
/** /**
* @description Render an avatar at the specified size and format * @description Render an avatar at the specified size and format
* @param bin Image to rerender * @param bin Image to rerender
@ -126,7 +163,7 @@ export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number,
if (format) img.toFormat(format) if (format) img.toFormat(format)
return { return {
buf: await img.toBuffer(), img,
extension: format || metadata.format, extension: format || metadata.format,
requestedFormat: format, requestedFormat: format,
squareSize, squareSize,
@ -153,7 +190,7 @@ export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<Ret
await writeFile( await writeFile(
targetPath, targetPath,
renderedAvatar.buf renderedAvatar.img
) )
return targetPath return targetPath
@ -163,9 +200,14 @@ export async function setNewAvatar(uid: string, avatar?: File) {
if (uid?.includes("/")) if (uid?.includes("/"))
throw Error("UID cannot include /") throw Error("UID cannot include /")
// Delete current avatar directory // Delete current avatar directory and avatar database entry
const userAvatarDirectory = join(avatarDirectory, uid) const userAvatarDirectory = join(avatarDirectory, uid)
await rm(userAvatarDirectory, { recursive: true, force: true }) await rm(userAvatarDirectory, { recursive: true, force: true })
await prisma.avatar.deleteMany({
where: {
userId: uid
}
})
if (!avatar) return {} // we don't need to set a new one if (!avatar) return {} // we don't need to set a new one
@ -194,5 +236,12 @@ export async function setNewAvatar(uid: string, avatar?: File) {
} }
} }
// create new Avatar database entry
await prisma.avatar.create({
data: {
userId: uid
}
})
return time return time
} }

View file

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

View file

@ -0,0 +1,15 @@
import { getMetadataForIdentifier } from '$lib/avatars.js';
import { prisma } from '$lib/clientsingleton.js';
import { error, redirect } from '@sveltejs/kit';
export async function load({ params : { identifier } }) {
let userInfo = await prisma.user.findFirst({ where: { identifier } })
if (!userInfo)
throw error(404, "User not found")
let metadata = await getMetadataForIdentifier(identifier)
return {
name: userInfo.name || identifier,
identifier,
...metadata
}
}

View file

@ -0,0 +1,46 @@
<script lang="ts">
import FilePreviewSet from "../../set/FilePreviewSet.svelte";
export let data: {
identifier: string,
name: string,
altText: string,
source: string
};
</script>
<style>
h5, p {
margin: 0;
}
h5 {
font-weight: normal;
color: var(--link)
}
</style>
<h1>{data.name}'s avatar</h1>
<FilePreviewSet avatarUrl="/avatar/{data.identifier}" style="border-radius:8px;" />
<br>
<FilePreviewSet avatarUrl="/avatar/{data.identifier}" style="border-radius:100%;" />
<br>
<h5>Alt text</h5>
<p>
{#if data.altText}
{data.altText}
{:else}
<em>No alt text available</em>
{/if}
</p>
<br>
<h5>Source</h5>
<p>
{#if data.source}
{data.source}
{:else}
<em>No source available</em>
{/if}
</p>

View file

@ -0,0 +1,10 @@
import { getMetadataForIdentifier } from '$lib/avatars.js';
import { error, redirect } from '@sveltejs/kit';
export async function GET({ params : { identifier } }) {
return new Response(
JSON.stringify(
await getMetadataForIdentifier(identifier)
)
)
}

View file

@ -0,0 +1,15 @@
import { getMetadataForIdentifier } from '$lib/avatars.js';
import { error, redirect } from '@sveltejs/kit';
export async function GET({ params : { identifier } }) {
let { source } = await getMetadataForIdentifier(identifier)
let finalUrl: URL | string = `/info/${identifier}`
if (source) {
try {
finalUrl = new URL(source)
} catch {}
}
throw redirect(302, finalUrl)
}

View file

@ -1,15 +1,17 @@
import {getRequestUser, launchLogin} from "$lib/oidc" import {getRequestUser, launchLogin} from "$lib/oidc"
import configuration from "$lib/configuration.js"; import configuration from "$lib/configuration.js";
import { fail } from "@sveltejs/kit"; import { fail } from "@sveltejs/kit";
import { avatarDirectory, setNewAvatar } from "$lib/avatars.js"; import { avatarDirectory, getMetadataForUserId, setNewAvatar } from "$lib/avatars.js";
import { join } from "path"; import { join } from "path";
import { prisma } from "$lib/clientsingleton";
export async function load({ request, parent, url }) { export async function load({ request, parent, url }) {
const { user } = await parent(); const { user } = await parent();
if (!user) if (!user)
launchLogin(url.toString()) return launchLogin(url.toString())
return { return {
url: url.toString(), url: url.toString(),
avatar: await getMetadataForUserId(user.sub),
allowedImageTypes: configuration.images.permitted_input, allowedImageTypes: configuration.images.permitted_input,
renderSizes: configuration.images.output_resolutions renderSizes: configuration.images.output_resolutions
} }
@ -21,16 +23,33 @@ export const actions = {
if (!user) if (!user)
return fail(401, {error: "unauthenticated"}) return fail(401, {error: "unauthenticated"})
let submission = await request.formData(); let submission = Object.fromEntries((await request.formData()).entries());
let newAvatar = undefined let newAvatar = submission.newAvatar
if (submission.get("action") != "Clear") { let timing: Awaited<ReturnType<typeof setNewAvatar>> = {}
newAvatar = submission.get("newAvatar") let isUploadingNewFile = submission.action == "Clear"
if (newAvatar !== undefined && !(newAvatar instanceof File))
return fail(400, {success: false, error: "incorrect entry type"}) if (
!isUploadingNewFile // if action isn't already clear
&& newAvatar !== undefined // and avatar is defined
&& (newAvatar instanceof File && newAvatar.size > 0)
) {
if (!configuration.images.permitted_input.includes(newAvatar.type)) if (!configuration.images.permitted_input.includes(newAvatar.type))
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`}) return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
isUploadingNewFile = true
} }
let timing = await setNewAvatar(user.sub, newAvatar)
if (isUploadingNewFile)
timing = await setNewAvatar(user.sub, newAvatar as File|null || undefined)
if (await prisma.avatar.findFirst({ where: { userId: user.sub } }))
await prisma.avatar.update({
where: {
userId: user.sub
},
data: {
altText: typeof submission.altText == "string" ? submission.altText : null,
source: typeof submission.source == "string" ? submission.source : null,
}
})
return { return {
success: true, success: true,

View file

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