add alt text, source
this code is fucking terrible. i'll clean it up later
This commit is contained in:
parent
b7068ed630
commit
968a49bd46
4
package-lock.json
generated
4
package-lock.json
generated
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "ava-node",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ava-node",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.1",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.0.18",
|
||||
"@fontsource-variable/noto-sans-mono": "^5.0.20",
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "ava-node",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
|
|
|
@ -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");
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "Avatar" ADD COLUMN "altText" TEXT;
|
||||
ALTER TABLE "Avatar" ADD COLUMN "source" TEXT;
|
2
prisma/migrations/20240823034401_add_name/migration.sql
Normal file
2
prisma/migrations/20240823034401_add_name/migration.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "name" TEXT;
|
|
@ -21,4 +21,14 @@ model Token {
|
|||
model User {
|
||||
userId String @id @unique
|
||||
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?
|
||||
}
|
|
@ -4,6 +4,7 @@ import { join } from "node:path"
|
|||
import { prisma } from "./clientsingleton"
|
||||
import configuration from "./configuration"
|
||||
import Sharp, { type FormatEnum } from "sharp"
|
||||
import type { Avatar } from "@prisma/client"
|
||||
|
||||
// todo: make customizable
|
||||
export const avatarDirectory = "./.data/avatars"
|
||||
|
@ -104,6 +105,42 @@ export async function getPathToAvatarForIdentifier(identifier: string, size: num
|
|||
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
|
||||
* @param bin Image to rerender
|
||||
|
@ -126,7 +163,7 @@ export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number,
|
|||
if (format) img.toFormat(format)
|
||||
|
||||
return {
|
||||
buf: await img.toBuffer(),
|
||||
img,
|
||||
extension: format || metadata.format,
|
||||
requestedFormat: format,
|
||||
squareSize,
|
||||
|
@ -153,7 +190,7 @@ export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<Ret
|
|||
|
||||
await writeFile(
|
||||
targetPath,
|
||||
renderedAvatar.buf
|
||||
renderedAvatar.img
|
||||
)
|
||||
|
||||
return targetPath
|
||||
|
@ -163,9 +200,14 @@ export async function setNewAvatar(uid: string, avatar?: File) {
|
|||
if (uid?.includes("/"))
|
||||
throw Error("UID cannot include /")
|
||||
|
||||
// Delete current avatar directory
|
||||
// Delete current avatar directory and avatar database entry
|
||||
const userAvatarDirectory = join(avatarDirectory, uid)
|
||||
await rm(userAvatarDirectory, { recursive: true, force: true })
|
||||
await prisma.avatar.deleteMany({
|
||||
where: {
|
||||
userId: uid
|
||||
}
|
||||
})
|
||||
|
||||
if (!avatar) return {} // we don't need to set a new one
|
||||
|
||||
|
@ -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
|
||||
}
|
|
@ -1,38 +1,32 @@
|
|||
import Sharp, { type FormatEnum } from "sharp"
|
||||
import Sharp, { type FormatEnum } from 'sharp';
|
||||
import { env } from '$env/dynamic/private';
|
||||
const configuration = {
|
||||
oauth2: {
|
||||
endpoints: {
|
||||
authenticate: process.env.OAUTH2__AUTHENTICATE!,
|
||||
logout: process.env.OAUTH2__LOGOUT,
|
||||
token: process.env.OAUTH2__GET_TOKEN!
|
||||
authenticate: env.OAUTH2__AUTHENTICATE!,
|
||||
logout: env.OAUTH2__LOGOUT,
|
||||
token: env.OAUTH2__GET_TOKEN!
|
||||
},
|
||||
client: {
|
||||
id: process.env.OAUTH2__CLIENT_ID!,
|
||||
secret: process.env.OAUTH2__CLIENT_SECRET!,
|
||||
scopes: process.env.OAUTH2__SCOPES!
|
||||
id: env.OAUTH2__CLIENT_ID!,
|
||||
secret: env.OAUTH2__CLIENT_SECRET!,
|
||||
scopes: env.OAUTH2__SCOPES!
|
||||
}
|
||||
},
|
||||
userinfo: {
|
||||
route: process.env.USERINFO__ROUTE!,
|
||||
identifier: process.env.USERINFO__IDENTIFIER!
|
||||
route: env.USERINFO__ROUTE!,
|
||||
identifier: env.USERINFO__IDENTIFIER!
|
||||
},
|
||||
images: {
|
||||
permitted_input:
|
||||
(
|
||||
process.env.ALLOWED_TYPES
|
||||
|| process.env.IMAGES__ALLOWED_INPUT_FORMATS
|
||||
)?.split(",") || [],
|
||||
default_resolution: parseInt((process.env.IMAGES__DEFAULT_RESOLUTION || "").toString(), 10) || 512,
|
||||
permitted_input: (env.ALLOWED_TYPES || env.IMAGES__ALLOWED_INPUT_FORMATS)?.split(',') || [],
|
||||
default_resolution: parseInt((env.IMAGES__DEFAULT_RESOLUTION || '').toString(), 10) || 512,
|
||||
extra_output_types:
|
||||
process.env.IMAGES__EXTRA_OUTPUT_FORMATS
|
||||
?.split(",")
|
||||
.filter(e => e in Sharp.format) as (keyof FormatEnum)[]
|
||||
|| [],
|
||||
output_resolutions:
|
||||
process.env.IMAGES__OUTPUT_RESOLUTIONS
|
||||
?.split(",")
|
||||
.map(e => parseInt(e,10))
|
||||
|| [ 1024, 512, 256, 128, 64, 32 ]
|
||||
(env.IMAGES__EXTRA_OUTPUT_FORMATS?.split(',').filter(
|
||||
(e) => e in Sharp.format
|
||||
) as (keyof FormatEnum)[]) || [],
|
||||
output_resolutions: env.IMAGES__OUTPUT_RESOLUTIONS?.split(',').map((e) => parseInt(e, 10)) || [
|
||||
1024, 512, 256, 128, 64, 32
|
||||
]
|
||||
}
|
||||
}
|
||||
export default configuration
|
||||
};
|
||||
export default configuration;
|
||||
|
|
15
src/routes/info/[identifier]/+page.server.ts
Normal file
15
src/routes/info/[identifier]/+page.server.ts
Normal 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
|
||||
}
|
||||
}
|
46
src/routes/info/[identifier]/+page.svelte
Normal file
46
src/routes/info/[identifier]/+page.svelte
Normal 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>
|
10
src/routes/info/[identifier]/json/+server.ts
Normal file
10
src/routes/info/[identifier]/json/+server.ts
Normal 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)
|
||||
)
|
||||
)
|
||||
}
|
15
src/routes/info/[identifier]/source/+server.ts
Normal file
15
src/routes/info/[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 = `/info/${identifier}`
|
||||
|
||||
if (source) {
|
||||
try {
|
||||
finalUrl = new URL(source)
|
||||
} catch {}
|
||||
}
|
||||
|
||||
throw redirect(302, finalUrl)
|
||||
}
|
|
@ -1,15 +1,17 @@
|
|||
import {getRequestUser, launchLogin} from "$lib/oidc"
|
||||
import configuration from "$lib/configuration.js";
|
||||
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 { prisma } from "$lib/clientsingleton";
|
||||
export async function load({ request, parent, url }) {
|
||||
const { user } = await parent();
|
||||
if (!user)
|
||||
launchLogin(url.toString())
|
||||
return launchLogin(url.toString())
|
||||
|
||||
return {
|
||||
url: url.toString(),
|
||||
avatar: await getMetadataForUserId(user.sub),
|
||||
allowedImageTypes: configuration.images.permitted_input,
|
||||
renderSizes: configuration.images.output_resolutions
|
||||
}
|
||||
|
@ -21,16 +23,33 @@ export const actions = {
|
|||
if (!user)
|
||||
return fail(401, {error: "unauthenticated"})
|
||||
|
||||
let submission = await request.formData();
|
||||
let newAvatar = undefined
|
||||
if (submission.get("action") != "Clear") {
|
||||
newAvatar = submission.get("newAvatar")
|
||||
if (newAvatar !== undefined && !(newAvatar instanceof File))
|
||||
return fail(400, {success: false, error: "incorrect entry type"})
|
||||
let submission = Object.fromEntries((await request.formData()).entries());
|
||||
let newAvatar = submission.newAvatar
|
||||
let timing: Awaited<ReturnType<typeof setNewAvatar>> = {}
|
||||
let isUploadingNewFile = submission.action == "Clear"
|
||||
|
||||
if (
|
||||
!isUploadingNewFile // if action isn't already clear
|
||||
&& newAvatar !== undefined // and avatar is defined
|
||||
&& (newAvatar instanceof File && newAvatar.size > 0)
|
||||
) {
|
||||
if (!configuration.images.permitted_input.includes(newAvatar.type))
|
||||
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
|
||||
isUploadingNewFile = true
|
||||
}
|
||||
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 {
|
||||
success: true,
|
||||
|
|
|
@ -2,37 +2,66 @@
|
|||
import type { User } from "$lib/types";
|
||||
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;
|
||||
let files: FileList;
|
||||
let fileSrc = `/avatar/${data.user.identifier}/`
|
||||
|
||||
$: 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)!)
|
||||
} else fileSrc = `/avatar/${data.user.identifier}/`
|
||||
</script>
|
||||
|
||||
<style>
|
||||
form {
|
||||
flex-direction: column;
|
||||
}
|
||||
form, form > .buttons, form > .metadata {
|
||||
display: flex;
|
||||
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;
|
||||
}
|
||||
form > input[type="file"] {
|
||||
width: 100%;
|
||||
flex-basis: 100%;
|
||||
min-height: 1em;
|
||||
}
|
||||
form > input[type="submit"], form > input[type="file"] {
|
||||
padding: 0.5em 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;
|
||||
}
|
||||
|
@ -73,13 +102,19 @@
|
|||
</p>
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<input type="file" bind:files={files} accept={data.allowedImageTypes.join(",")} name="newAvatar">
|
||||
<input type="submit" name="action" value="Upload">
|
||||
<div class="metadata">
|
||||
<textarea name="altText" placeholder="Describe your image" disabled={data.avatar.default}>{data.avatar.altText}</textarea>
|
||||
<textarea name="source" placeholder="Provide a source for your image" disabled={data.avatar.default}>{data.avatar.source}</textarea>
|
||||
</div>
|
||||
<div class="buttons">
|
||||
<input type="submit" name="action" value="Save">
|
||||
<input type="submit" name="action" value="Clear">
|
||||
</div>
|
||||
</form>
|
||||
{#if form}
|
||||
{#if form.success}
|
||||
<details>
|
||||
<summary><small>Avatar set successfully</small></summary>
|
||||
<summary><small>Avatar updated successfully</small></summary>
|
||||
<div>
|
||||
<pre>{form.message}</pre>
|
||||
</div>
|
||||
|
|
Loading…
Reference in a new issue