add profile picture set
This commit is contained in:
parent
6088659cdb
commit
55e0e1f91e
134
imagemagick.policy.xml
Normal file
134
imagemagick.policy.xml
Normal file
|
@ -0,0 +1,134 @@
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Creating a security policy that fits your specific local environment
|
||||||
|
before making use of ImageMagick is highly advised. You can find guidance on
|
||||||
|
setting up this policy at https://imagemagick.org/script/security-policy.php,
|
||||||
|
and it's important to verify your policy using the validation tool located
|
||||||
|
at https://imagemagick-secevaluator.doyensec.com/.
|
||||||
|
|
||||||
|
|
||||||
|
Web-safe ImageMagick security policy:
|
||||||
|
|
||||||
|
This security protocol designed for web-safe usage focuses on situations
|
||||||
|
where ImageMagick is applied in publicly accessible contexts, like websites.
|
||||||
|
It deactivates the capability to read from or write to any image formats
|
||||||
|
other than web-safe formats like GIF, JPEG, and PNG. Additionally, this
|
||||||
|
policy prohibits the execution of image filters and indirect reads, thereby
|
||||||
|
thwarting potential security breaches. By implementing these limitations,
|
||||||
|
the web-safe policy fortifies the safeguarding of systems accessible to
|
||||||
|
the public, reducing the risk of exploiting ImageMagick's capabilities
|
||||||
|
for potential attacks.
|
||||||
|
-->
|
||||||
|
<policymap>
|
||||||
|
<!-- Set maximum parallel threads. -->
|
||||||
|
<policy domain="resource" name="thread" value="2"/>
|
||||||
|
<!--
|
||||||
|
Set maximum time to live in seconds or mnemonics, e.g. "2 minutes". When
|
||||||
|
this limit is exceeded, an exception is thrown and processing stops.
|
||||||
|
-->
|
||||||
|
<policy domain="resource" name="time" value="60"/>
|
||||||
|
<!--
|
||||||
|
Set maximum number of open pixel cache files. When this limit is
|
||||||
|
exceeded, any subsequent pixels cached to disk are closed and reopened
|
||||||
|
on demand.
|
||||||
|
-->
|
||||||
|
<policy domain="resource" name="file" value="768"/>
|
||||||
|
<!--
|
||||||
|
Set maximum amount of memory in bytes to allocate for the pixel cache
|
||||||
|
from the heap. When this limit is exceeded, the image pixels are cached
|
||||||
|
to memory-mapped disk.
|
||||||
|
-->
|
||||||
|
<policy domain="resource" name="memory" value="256MiB"/>
|
||||||
|
<!--
|
||||||
|
Set maximum amount of memory map in bytes to allocate for the pixel
|
||||||
|
cache. When this limit is exceeded, the image pixels are cached to
|
||||||
|
disk.
|
||||||
|
-->
|
||||||
|
<policy domain="resource" name="map" value="512MiB"/>
|
||||||
|
<!--
|
||||||
|
Set the maximum width * height of an image that can reside in the pixel
|
||||||
|
cache memory. Images that exceed the area limit are cached to disk.
|
||||||
|
-->
|
||||||
|
<policy domain="resource" name="area" value="16KP"/>
|
||||||
|
<!--
|
||||||
|
Set maximum amount of disk space in bytes permitted for use by the pixel
|
||||||
|
cache. When this limit is exceeded, the pixel cache is not be created
|
||||||
|
and an exception is thrown.
|
||||||
|
-->
|
||||||
|
<policy domain="resource" name="disk" value="1GiB"/>
|
||||||
|
<!--
|
||||||
|
Set the maximum length of an image sequence. When this limit is
|
||||||
|
exceeded, an exception is thrown.
|
||||||
|
-->
|
||||||
|
<policy domain="resource" name="list-length" value="16"/>
|
||||||
|
<!--
|
||||||
|
Set the maximum width of an image. When this limit is exceeded, an
|
||||||
|
exception is thrown.
|
||||||
|
-->
|
||||||
|
<policy domain="resource" name="width" value="4KP"/>
|
||||||
|
<!--
|
||||||
|
Set the maximum height of an image. When this limit is exceeded, an
|
||||||
|
exception is thrown.
|
||||||
|
-->
|
||||||
|
<policy domain="resource" name="height" value="4KP"/>
|
||||||
|
<!--
|
||||||
|
Periodically yield the CPU for at least the time specified in
|
||||||
|
milliseconds.
|
||||||
|
-->
|
||||||
|
<policy domain="resource" name="throttle" value="2"/>
|
||||||
|
<!--
|
||||||
|
Do not create temporary files in the default shared directories, instead
|
||||||
|
specify a private area to store only ImageMagick temporary files.
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
<policy domain="resource" name="temporary-path" value="/magick/tmp/"/>
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
Force memory initialization by memory mapping select memory
|
||||||
|
allocations.
|
||||||
|
-->
|
||||||
|
<policy domain="cache" name="memory-map" value="anonymous"/>
|
||||||
|
<!--
|
||||||
|
Ensure all image data is fully flushed and synchronized to disk.
|
||||||
|
-->
|
||||||
|
<policy domain="cache" name="synchronize" value="true"/>
|
||||||
|
<!--
|
||||||
|
Replace passphrase for secure distributed processing
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
<policy domain="cache" name="shared-secret" value="secret-passphrase" stealth="true"/>
|
||||||
|
-->
|
||||||
|
<!-- Do not permit any delegates to execute. -->
|
||||||
|
<policy domain="delegate" rights="none" pattern="*"/>
|
||||||
|
<!-- Do not permit any image filters to load. -->
|
||||||
|
<policy domain="filter" rights="none" pattern="*"/>
|
||||||
|
<!-- Don't read/write from/to stdin/stdout. -->
|
||||||
|
<policy domain="path" rights="none" pattern="-"/>
|
||||||
|
<!-- don't read sensitive paths. -->
|
||||||
|
<policy domain="path" rights="none" pattern="/etc/*"/>
|
||||||
|
<!-- Indirect reads are not permitted. -->
|
||||||
|
<policy domain="path" rights="none" pattern="@*"/>
|
||||||
|
<!--
|
||||||
|
Deny all image modules and specifically exempt reading or writing
|
||||||
|
web-safe image formats.
|
||||||
|
-->
|
||||||
|
<policy domain="module" rights="none" pattern="*"/>
|
||||||
|
<policy domain="module" rights="read | write" pattern="{BMP,GIF,JPEG,PNG,TIFF,WEBP}"/>
|
||||||
|
<policy domain="module" rights="read | write" pattern="{MPC}" stealth="true"/>
|
||||||
|
<policy domain="module" rights="write" pattern="{JSON,INFO,PNM,PS}"/>
|
||||||
|
<!--
|
||||||
|
This policy sets the number of times to replace content of certain
|
||||||
|
memory buffers and temporary files before they are freed or deleted.
|
||||||
|
-->
|
||||||
|
<policy domain="system" name="shred" value="1"/>
|
||||||
|
<!--
|
||||||
|
Enable the initialization of buffers with zeros, resulting in a minor
|
||||||
|
performance penalty but with improved security.
|
||||||
|
-->
|
||||||
|
<policy domain="system" name="memory-map" value="anonymous"/>
|
||||||
|
<!--
|
||||||
|
Set the maximum amount of memory in bytes that are permitted for
|
||||||
|
allocation requests.
|
||||||
|
-->
|
||||||
|
<policy domain="system" name="max-memory-request" value="256MiB"/>
|
||||||
|
</policymap>
|
|
@ -30,7 +30,8 @@
|
||||||
"@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",
|
||||||
"magickwand.js": "^1.1.0"
|
"magickwand.js": "^1.1.0",
|
||||||
|
"mime": "^4.0.4"
|
||||||
},
|
},
|
||||||
"trustedDependencies": [
|
"trustedDependencies": [
|
||||||
"magickwand.js"
|
"magickwand.js"
|
||||||
|
|
|
@ -1,14 +1,19 @@
|
||||||
import { readdir } from "node:fs/promises"
|
import { mkdir, readdir, rm } from "node:fs/promises"
|
||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
import { basename, join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { prisma } from "./clientsingleton"
|
import { prisma } from "./clientsingleton"
|
||||||
|
import configuration from "./configuration"
|
||||||
|
import { Magick, MagickCore } from "magickwand.js"
|
||||||
|
import mime from "mime"
|
||||||
|
|
||||||
|
Magick.SetSecurityPolicy(await Bun.file('./imagemagick.policy.xml').text())
|
||||||
|
|
||||||
// todo: make customizable
|
// todo: make customizable
|
||||||
export const avatarDirectory = "./.data/avatars"
|
export const avatarDirectory = "./.data/avatars"
|
||||||
export const defaultAvatarDirectory = "./static/default/"
|
export const defaultAvatarDirectory = "./static/default/"
|
||||||
export const renderSizes = [ 512, 256, 128, 64, 32 ]
|
export const renderSizes = [ 1024, 512, 256, 128, 64, 32 ]
|
||||||
|
|
||||||
export async function getPathToAvatarForUid(uid?: string, size: string = "index") {
|
export async function getPathToAvatarForUid(uid?: string, size: string = "512") {
|
||||||
if (uid?.includes("/"))
|
if (uid?.includes("/"))
|
||||||
throw Error("UID cannot include /")
|
throw Error("UID cannot include /")
|
||||||
|
|
||||||
|
@ -22,12 +27,68 @@ export async function getPathToAvatarForUid(uid?: string, size: string = "index"
|
||||||
return join(userAvatarDirectory, targetAvatar)
|
return join(userAvatarDirectory, targetAvatar)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPathToAvatarForIdentifier(identifier: string, size: string = "index") {
|
export async function getPathToAvatarForIdentifier(identifier: string, size: string = "512") {
|
||||||
let user = await prisma.user.findFirst({
|
let user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
identifier
|
identifier
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return getPathToAvatarForUid(user?.userId || "", size)
|
return getPathToAvatarForUid(user?.userId, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function rerenderAvatar(bin: ArrayBuffer, squareSize?: number) {
|
||||||
|
let img = new Magick.Image;
|
||||||
|
// read file
|
||||||
|
await img.readAsync(new Magick.Blob(bin),"")
|
||||||
|
if (squareSize) {
|
||||||
|
// resize, but don't upscale, while filling without squishing
|
||||||
|
await img.scaleAsync(`${squareSize}x${squareSize}^>`)
|
||||||
|
}
|
||||||
|
// center crop
|
||||||
|
const size = img.size()
|
||||||
|
squareSize = Math.min(size.width(), size.height())
|
||||||
|
await img.extentAsync(`${squareSize}x${squareSize}`, MagickCore.CenterGravity)
|
||||||
|
|
||||||
|
// return avatar buffer
|
||||||
|
let tempBlob = new Magick.Blob()
|
||||||
|
await img.writeAsync(tempBlob)
|
||||||
|
return tempBlob.dataAsync()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setNewAvatar(uid: string, avatar?: File) {
|
||||||
|
if (uid?.includes("/"))
|
||||||
|
throw Error("UID cannot include /")
|
||||||
|
|
||||||
|
// Delete current avatar directory
|
||||||
|
const userAvatarDirectory = join(avatarDirectory, uid)
|
||||||
|
await rm(userAvatarDirectory, { recursive: true, force: true })
|
||||||
|
|
||||||
|
if (!avatar) return {} // we don't need to set a new one
|
||||||
|
|
||||||
|
// make a new directory
|
||||||
|
mkdir(userAvatarDirectory, { recursive: true })
|
||||||
|
|
||||||
|
let time: Record<number, number> = {}
|
||||||
|
|
||||||
|
// render all images and write to disk
|
||||||
|
let avatarData = await avatar.arrayBuffer()
|
||||||
|
let fileExtension = mime.getExtension(avatar.type)
|
||||||
|
for (let x of renderSizes) {
|
||||||
|
console.log(x)
|
||||||
|
try {
|
||||||
|
let start = Date.now()
|
||||||
|
let rerenderedAvatarData = await rerenderAvatar(avatarData, x)
|
||||||
|
await Bun.write(
|
||||||
|
join(userAvatarDirectory, `${x}.${fileExtension}`),
|
||||||
|
rerenderedAvatarData
|
||||||
|
)
|
||||||
|
time[x] = Date.now()-start
|
||||||
|
} catch (e) { // clear pfp and throw if error encountered
|
||||||
|
await rm(userAvatarDirectory, { recursive: true, force: true })
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return time
|
||||||
}
|
}
|
|
@ -96,7 +96,7 @@ export async function getUserInfo(id: string) {
|
||||||
if (userInfoCache.has(tokenInfo.owner))
|
if (userInfoCache.has(tokenInfo.owner))
|
||||||
userInfo = userInfoCache.get(tokenInfo.owner)
|
userInfo = userInfoCache.get(tokenInfo.owner)
|
||||||
else {
|
else {
|
||||||
let userInfoRequest = await fetchUserInfo(tokenInfo.token)
|
let userInfoRequest = await fetchUserInfo(tokenInfo.token)
|
||||||
if (!userInfoRequest.ok) {
|
if (!userInfoRequest.ok) {
|
||||||
// assume that token has expired.
|
// assume that token has expired.
|
||||||
// try fetching a new one
|
// try fetching a new one
|
||||||
|
@ -108,7 +108,7 @@ export async function getUserInfo(id: string) {
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!token) return // refresh failed. back out
|
if (!token) return // refresh failed. back out
|
||||||
prisma.token.update({
|
await prisma.token.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
token: token.access_token,
|
token: token.access_token,
|
||||||
|
@ -121,6 +121,21 @@ export async function getUserInfo(id: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
userInfo = await userInfoRequest.json()
|
userInfo = await userInfoRequest.json()
|
||||||
|
|
||||||
|
// update user
|
||||||
|
console.log('aaa')
|
||||||
|
await prisma.user.upsert({
|
||||||
|
where: {
|
||||||
|
userId: userInfo.sub,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
identifier: userInfo[configuration.userinfo.identifier]
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: userInfo.sub,
|
||||||
|
identifier: userInfo[configuration.userinfo.identifier]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// cache userinfo
|
// cache userinfo
|
||||||
userInfoCache.set(tokenInfo.owner, userInfo)
|
userInfoCache.set(tokenInfo.owner, userInfo)
|
||||||
|
|
|
@ -50,7 +50,7 @@
|
||||||
nav > * {
|
nav > * {
|
||||||
display: flex; /* Flexbox fixes everything! */
|
display: flex; /* Flexbox fixes everything! */
|
||||||
}
|
}
|
||||||
a {
|
a, small {
|
||||||
color: var(--link)
|
color: var(--link)
|
||||||
}
|
}
|
||||||
code, pre {
|
code, pre {
|
||||||
|
|
|
@ -1,5 +1,8 @@
|
||||||
import {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 { avatarDirectory, renderSizes, setNewAvatar } from "$lib/avatars.js";
|
||||||
|
import { join } from "path";
|
||||||
export async function load({ request, parent }) {
|
export async function load({ request, parent }) {
|
||||||
const { user } = await parent();
|
const { user } = await parent();
|
||||||
if (!user)
|
if (!user)
|
||||||
|
@ -7,6 +10,31 @@ export async function load({ request, parent }) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: request.url,
|
url: request.url,
|
||||||
allowedImageTypes: configuration.allowed_types
|
allowedImageTypes: configuration.allowed_types,
|
||||||
|
renderSizes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const actions = {
|
||||||
|
set: async ({request, cookies}) => {
|
||||||
|
let user = await getRequestUser(request, cookies);
|
||||||
|
if (!user)
|
||||||
|
return fail(401, {error: "unauthenticated"})
|
||||||
|
|
||||||
|
let submission = await request.formData();
|
||||||
|
let newAvatar = submission.get("newAvatar")
|
||||||
|
if (newAvatar !== undefined && !(newAvatar instanceof File))
|
||||||
|
return fail(400, {success: false, error: "incorrect entry type"})
|
||||||
|
if (!configuration.allowed_types.includes(newAvatar.type))
|
||||||
|
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
|
||||||
|
|
||||||
|
let time = await setNewAvatar(user.sub, newAvatar)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: Object.entries(time)
|
||||||
|
.map(([res, time]) => `${res}x${res} took ${time}ms to render`)
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -2,7 +2,8 @@
|
||||||
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[]};
|
export let data: {user: User, url: string, allowedImageTypes: string[], renderSizes: number[]};
|
||||||
|
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}/`
|
||||||
|
|
||||||
|
@ -66,18 +67,30 @@
|
||||||
<summary>Avatar URLs...</summary>
|
<summary>Avatar URLs...</summary>
|
||||||
<div>
|
<div>
|
||||||
<ul>
|
<ul>
|
||||||
{#each ["", "32", "64", "128", "256", "512"] as variant}
|
{#each ["", ...data.renderSizes] as variant}
|
||||||
<li>{new URL(`/avatar/${data.user.identifier}/${variant}`, data.url)}</li>
|
<li>{new URL(`/avatar/${data.user.identifier}/${variant}`, data.url)}</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
</p>
|
</p>
|
||||||
<form method="post" enctype="multipart/form-data">
|
<form method="post" enctype="multipart/form-data" action="?/set">
|
||||||
<label for="newAvatar">Set a new avatar ➜</label>
|
<label for="newAvatar">Set a new avatar ➜</label>
|
||||||
<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" value="Upload">
|
<input type="submit" value="Upload">
|
||||||
</form>
|
</form>
|
||||||
|
{#if form}
|
||||||
|
{#if form.success}
|
||||||
|
<details>
|
||||||
|
<summary><small>Avatar set successfully</small></summary>
|
||||||
|
<div>
|
||||||
|
<pre>{form.message}</pre>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{:else}
|
||||||
|
<small>An error occurred: {form.error}</small>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
{#key fileSrc}
|
{#key fileSrc}
|
||||||
<br>
|
<br>
|
||||||
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:8px;" />
|
<FilePreviewSet avatarUrl={fileSrc} style="border-radius:8px;" />
|
||||||
|
|
BIN
static/default/1024.png
Normal file
BIN
static/default/1024.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 31 KiB |
Loading…
Reference in a new issue