feat: ✨ add libravatar API
This commit is contained in:
parent
c70db40ffa
commit
aa67b08239
|
@ -32,12 +32,12 @@ LIBRAVATAR__ENABLED=false
|
||||||
# Either jpeg or png. The type of image the libravatar API serves. Default png.
|
# Either jpeg or png. The type of image the libravatar API serves. Default png.
|
||||||
LIBRAVATAR__FORMAT=png
|
LIBRAVATAR__FORMAT=png
|
||||||
|
|
||||||
# What to do when libravatar requests a resolution that is not a valid output resolution. Default round.
|
# What to do when libravatar requests a resolution that is not a valid output resolution. Default nearest.
|
||||||
# Modes:
|
# Modes:
|
||||||
# round - Round to the nearest image size.
|
# nearest - Use the nearest image size.
|
||||||
# nocache - Render the image at the requested size without saving it to disk.
|
# 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.
|
# cache - Render the image at the requested size and save it to disk.
|
||||||
LIBRAVATAR__GENERATION_MODE=round
|
LIBRAVATAR__GENERATION_MODE=nearest
|
||||||
|
|
||||||
# Prisma database URL
|
# Prisma database URL
|
||||||
DATABASE_URL=file:../.data/data.db
|
DATABASE_URL=file:../.data/data.db
|
||||||
|
|
|
@ -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;
|
|
@ -19,11 +19,12 @@ model Token {
|
||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
userId String @id @unique
|
userId String @id @unique
|
||||||
identifier String
|
identifier String
|
||||||
name String?
|
name String?
|
||||||
avatars Avatar[]
|
avatars Avatar[]
|
||||||
webhooks Webhook[]
|
webhooks Webhook[]
|
||||||
|
emailHashes EmailHashes?
|
||||||
|
|
||||||
currentAvatarId String? @unique
|
currentAvatarId String? @unique
|
||||||
currentAvatar Avatar? @relation("CurrentAvatar", fields: [currentAvatarId], references: [id])
|
currentAvatar Avatar? @relation("CurrentAvatar", fields: [currentAvatarId], references: [id])
|
||||||
|
@ -48,3 +49,10 @@ model Webhook {
|
||||||
|
|
||||||
@@unique([url, userId])
|
@@unique([url, userId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model EmailHashes {
|
||||||
|
forUserId String @id
|
||||||
|
user User @relation(fields: [forUserId], references: [userId])
|
||||||
|
sha256 Bytes
|
||||||
|
md5 Bytes
|
||||||
|
}
|
||||||
|
|
|
@ -15,6 +15,16 @@ await mkdir(defaultAvatarDirectory, { recursive: true })
|
||||||
|
|
||||||
export const missingAvatarQueue = new Map<string, Promise<string>>()
|
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,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Generate an avatar at the selected size and format
|
* @description Generate an avatar at the selected size and format
|
||||||
* @param path Path to the avatar directory
|
* @param path Path to the avatar directory
|
||||||
|
@ -32,25 +42,14 @@ export function generateMissingAvatar(
|
||||||
|
|
||||||
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(
|
([a], [b]) => b - a
|
||||||
e =>
|
)[0][1]
|
||||||
[
|
|
||||||
parseInt(
|
|
||||||
e.match(/(.*)\..*/)?.[1] || "",
|
|
||||||
10
|
|
||||||
),
|
|
||||||
e,
|
|
||||||
] as [number, string]
|
|
||||||
)
|
|
||||||
.sort(([a], [b]) => b - a)[0][1]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const buf = await readFile(pathToBestQualityImg)
|
const buf = await readFile(pathToBestQualityImg)
|
||||||
|
@ -72,7 +71,8 @@ export function generateMissingAvatar(
|
||||||
export async function getPathToAvatar(
|
export async function getPathToAvatar(
|
||||||
avatarId?: string,
|
avatarId?: string,
|
||||||
size: number = configuration.images.default_resolution,
|
size: number = configuration.images.default_resolution,
|
||||||
fmt?: string
|
fmt?: string,
|
||||||
|
bypass_size_limits: boolean = false
|
||||||
) {
|
) {
|
||||||
if (avatarId?.includes("/")) throw Error("AvatarID cannot include /")
|
if (avatarId?.includes("/")) throw Error("AvatarID cannot include /")
|
||||||
|
|
||||||
|
@ -112,7 +112,10 @@ export async function getPathToAvatar(
|
||||||
.find(s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size)
|
.find(s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size)
|
||||||
|
|
||||||
if (targetAvatar) return join(targetAvatarDirectory, targetAvatar.name)
|
if (targetAvatar) return join(targetAvatarDirectory, targetAvatar.name)
|
||||||
else if (configuration.images.output_resolutions.includes(size))
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -59,11 +59,11 @@ const configuration = {
|
||||||
)
|
)
|
||||||
? env.LIBRAVATAR__FORMAT
|
? env.LIBRAVATAR__FORMAT
|
||||||
: "jpeg") as keyof FormatEnum,
|
: "jpeg") as keyof FormatEnum,
|
||||||
resize_mode: ["round", "nocache", "cache"].includes(
|
resize_mode: (["nearest", "nocache", "cache"].includes(
|
||||||
env.LIBRAVATAR__GENERATION_MODE
|
env.LIBRAVATAR__GENERATION_MODE
|
||||||
)
|
)
|
||||||
? env.LIBRAVATAR__GENERATION_MODE
|
? env.LIBRAVATAR__GENERATION_MODE
|
||||||
: "round",
|
: "nearest") as "nearest" | "nocache" | "cache",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
|
154
src/lib/oidc.ts
154
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,71 +27,72 @@ 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(
|
||||||
`?${searchParams.toString()}`,
|
`?${searchParams.toString()}`,
|
||||||
configuration.oauth2.endpoints.authenticate
|
configuration.oauth2.endpoints.authenticate
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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())
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @description Request a token from the OAuth server
|
* @description Request a token from the OAuth server
|
||||||
* @param params
|
* @param params
|
||||||
* @returns Access token, its time-to-expiration, and refresh token if applicable
|
* @returns Access token, its time-to-expiration, and refresh token if applicable
|
||||||
*/
|
*/
|
||||||
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
|
||||||
|
@ -199,4 +245,4 @@ export async function getRequestUser(request: Request, cookies: Cookies) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return userinfo
|
return userinfo
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,4 +2,5 @@ export interface User {
|
||||||
name: string
|
name: string
|
||||||
sub: string
|
sub: string
|
||||||
identifier: string
|
identifier: string
|
||||||
}
|
email?: string
|
||||||
|
}
|
||||||
|
|
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) || "",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
Loading…
Reference in a new issue