Release 2025-05-19

This commit is contained in:
pluja
2025-05-19 10:19:49 +00:00
parent 046c4559e5
commit 2657f936bc
267 changed files with 0 additions and 49432 deletions

View File

@@ -1,28 +0,0 @@
import { generateUsername } from 'unique-username-generator'
import { prisma } from './prisma'
import { generateUserSecretToken, hashUserSecretToken } from './userSecretToken'
/**
* Generate a random username.
* Format: `adjective_noun_1234`
*/
function generateRandomUsername() {
return `${generateUsername('_')}_${Math.floor(Math.random() * 10000).toString()}`
}
export async function createAccount(preGeneratedToken?: string) {
const token = preGeneratedToken ?? generateUserSecretToken()
const hash = hashUserSecretToken(token)
const username = generateRandomUsername()
const user = await prisma.user.create({
data: {
name: username,
secretTokenHash: hash,
notificationPreferences: { create: {} },
},
})
return { token, user }
}

View File

@@ -1,130 +0,0 @@
import { z } from 'astro/zod'
import { map, xor, some, every } from 'lodash-es'
/**
* Returns a tuple of values from a tuple of records.
*
* @example
* TupleValues<'value', [{ value: 'a' }, { value: 'b' }]> // -> ['a', 'b']
*/
type TupleValues<K extends string, T extends readonly Record<K, string>[]> = {
[I in keyof T]: T[I][K]
}
/**
* Returns a tuple of values from a tuple of records.
*
* @example
* const options = [{ value: 'a' }, { value: 'b' }]
* const values = getTupleValues(options, 'value') // -> ['a', 'b']
*/
export function getTupleValues<K extends string, T extends readonly Record<K, string>[]>(
arr: T,
key: K
): TupleValues<K, T> {
return map(arr, key) as unknown as TupleValues<K, T>
}
/**
* Returns a zod enum from a constant.
*
* @example
* const options = [{ value: 'a' }, { value: 'b' }]
* const zodEnum = zodEnumFromConstant(options, 'value') // -> z.enum(['a', 'b'])
*/
export function zodEnumFromConstant<K extends string, T extends readonly Record<K, string>[]>(
arr: T,
key: K
) {
return z.enum(getTupleValues(arr as unknown as [T[number], ...T[number][]], key))
}
/**
* Returns a random element from an array.
*
* @example
* const options = ['a', 'b']
* const randomElement = getRandomElement(options) // -> 'a' or 'b'
*/
export function getRandom<T>(array: readonly T[]): T {
return array[Math.floor(Math.random() * array.length)] as T
}
/**
* Typed version of Array.prototype.join.
*
* @example
* TypedJoin<['a', 'b']> // -> 'ab'
* TypedJoin<['a', 'b'], '-'> // -> 'a-b'
*/
type TypedJoin<
T extends readonly string[],
Separator extends string = '',
First extends boolean = true,
> = T extends readonly []
? ''
: T extends readonly [infer U extends string, ...infer R extends readonly string[]]
? `${First extends true ? '' : Separator}${U}${TypedJoin<R, Separator, false>}`
: string
/**
* Joins an array of strings with a separator.
*
* @example
* const options = ['a', 'b'] as const
* const joined = typedJoin(options) // -> 'ab'
* const joinedWithSeparator = typedJoin(options, '-') // -> 'a-b'
*/
export function typedJoin<T extends readonly string[], Separator extends string = ''>(
array: T,
separator: Separator = '' as Separator
) {
return array.join(separator) as TypedJoin<T, Separator>
}
/**
* Checks if two or more arrays are equal without considering order.
*
* @example
* const a = [1, 2, 3]
* const b = [3, 2, 1]
* areEqualArraysWithoutOrder(a, b) // -> true
*/
export function areEqualArraysWithoutOrder<T>(...arrays: (T[] | null | undefined)[]): boolean {
return xor(...arrays).length === 0
}
/**
* Checks if some but not all of the arguments are true.
*
* @example
* someButNotAll(true, false, true) // -> true
* someButNotAll(true, true, true) // -> false
* someButNotAll(false, false, false) // -> false
*/
export const someButNotAll = (...args: boolean[]) => some(args) && !every(args)
/**
* Returns undefined if the array is empty.
*
* @example
* const a = [1, 2, 3]
* const b = []
* const c = null
* const d = undefined
* undefinedIfEmpty(a) // -> [1, 2, 3]
* undefinedIfEmpty(b) // -> undefined
* undefinedIfEmpty(c) // -> undefined
* undefinedIfEmpty(d) // -> undefined
*/
export function undefinedIfEmpty<T>(value: T) {
return (value && Array.isArray(value) && value.length > 0 ? value : undefined) as T extends (infer U)[]
? U[] | undefined
: T extends readonly [...infer U]
? U | undefined
: undefined
}
export function isNotArray<T>(value: T | T[]): value is T {
return !Array.isArray(value)
}

View File

@@ -1,11 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable @typescript-eslint/no-empty-object-type */
import type { ComponentProps, HTMLTag, Polymorphic } from 'astro/types'
export type AstroComponent = (args: any) => any
export type PolymorphicComponent<Component extends AstroComponent | HTMLTag> =
(Component extends AstroComponent ? ComponentProps<Component> & { as?: Component } : {}) &
(Component extends HTMLTag ? Polymorphic<{ as: Component }> : {})
export type AstroChildren = any

View File

@@ -1,17 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import type { z } from 'astro/zod'
import type { ActionAccept, ActionClient, SafeResult } from 'astro:actions'
export type AnyAction = ActionClient<unknown, ActionAccept, z.ZodType> & string
export type FormAction = ActionClient<any, 'form', any> & string
export type JsonAction = ActionClient<any, 'json', any> & string
export type ActionInput<Action extends AnyAction> = Parameters<Action>[0]
export type ActionOutput<Action extends AnyAction> =
ReturnType<Action> extends Promise<SafeResult<infer TOutput, void>> ? TOutput : never
/** Returns the input type of an action, or the output type if the input type is not a record. */
export type ActionInputNoFormData<Action extends AnyAction> =
ActionInput<Action> extends Record<string, unknown> ? ActionInput<Action> : ActionOutput<Action>

View File

@@ -1,152 +0,0 @@
import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
import { formatDateShort } from './timeAgo'
import type { Prisma } from '@prisma/client'
export function sortAttributes<
T extends Prisma.AttributeGetPayload<{
select: {
title: true
privacyPoints: true
trustPoints: true
category: true
type: true
}
}>,
>(attributes: T[]): T[] {
return orderBy(
attributes,
[
({ privacyPoints, trustPoints }) => (privacyPoints + trustPoints < 0 ? 1 : 2),
({ privacyPoints, trustPoints }) => Math.abs(privacyPoints + trustPoints),
({ type }) => getAttributeTypeInfo(type).order,
({ category }) => getAttributeCategoryInfo(category).order,
'title',
],
['asc', 'desc', 'asc', 'asc', 'asc']
)
}
export function makeNonDbAttributes(
service: Prisma.ServiceGetPayload<{
select: {
verificationStatus: true
isRecentlyListed: true
listedAt: true
createdAt: true
}
}>,
{ filter = false }: { filter?: boolean } = {}
) {
const nonDbAttributes: (Prisma.AttributeGetPayload<{
select: {
title: true
type: true
category: true
description: true
privacyPoints: true
trustPoints: true
}
}> & {
show: boolean
links: {
url: string
label: string
icon: string
}[]
})[] = [
{
title: 'Verified',
show: service.verificationStatus === 'VERIFICATION_SUCCESS',
type: 'GOOD',
category: 'TRUST',
description: `${verificationStatusesByValue.VERIFICATION_SUCCESS.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
privacyPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.privacyPoints,
trustPoints: verificationStatusesByValue.VERIFICATION_SUCCESS.trustPoints,
links: [
{
url: '/?verification=verified',
label: 'Search with this',
icon: 'ri:search-line',
},
],
},
{
title: 'Approved',
show: service.verificationStatus === 'APPROVED',
type: 'INFO',
category: 'TRUST',
description: `${verificationStatusesByValue.APPROVED.description} ${READ_MORE_SENTENCE_LINK}`,
privacyPoints: verificationStatusesByValue.APPROVED.privacyPoints,
trustPoints: verificationStatusesByValue.APPROVED.trustPoints,
links: [
{
url: '/?verification=verified&verification=approved',
label: 'Search with this',
icon: 'ri:search-line',
},
],
},
{
title: 'Community contributed',
show: service.verificationStatus === 'COMMUNITY_CONTRIBUTED',
type: 'WARNING',
category: 'TRUST',
description: `${verificationStatusesByValue.COMMUNITY_CONTRIBUTED.description} ${READ_MORE_SENTENCE_LINK}`,
privacyPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.privacyPoints,
trustPoints: verificationStatusesByValue.COMMUNITY_CONTRIBUTED.trustPoints,
links: [
{
url: '/?verification=community',
label: 'With this',
icon: 'ri:search-line',
},
{
url: '/?verification=verified&verification=approved',
label: 'Without this',
icon: 'ri:search-line',
},
],
},
{
title: 'Is SCAM',
show: service.verificationStatus === 'VERIFICATION_FAILED',
type: 'BAD',
category: 'TRUST',
description: `${verificationStatusesByValue.VERIFICATION_FAILED.description} ${READ_MORE_SENTENCE_LINK}\n\nCheck out the [proof](#verification).`,
privacyPoints: verificationStatusesByValue.VERIFICATION_FAILED.privacyPoints,
trustPoints: verificationStatusesByValue.VERIFICATION_FAILED.trustPoints,
links: [
{
url: '/?verification=scam',
label: 'With this',
icon: 'ri:search-line',
},
{
url: '/?verification=verified&verification=approved',
label: 'Without this',
icon: 'ri:search-line',
},
],
},
{
title: 'Recently listed',
show: service.isRecentlyListed,
type: 'WARNING',
category: 'TRUST',
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
privacyPoints: 0,
trustPoints: -5,
links: [],
},
]
if (filter) return nonDbAttributes.filter(({ show }) => show)
return nonDbAttributes
}

View File

@@ -1,184 +0,0 @@
import { serialize } from 'object-to-formdata'
import { urlParamsToFormData, urlParamsToObject } from './urls'
import type { AstroGlobal } from 'astro'
import type { ActionAccept, ActionClient, SafeResult } from 'astro:actions'
import type { z } from 'astro:schema'
/**
* Call an Action directly from an Astro page or API endpoint.
*
* The action input is obtained from the URL search params.
*
* Returns a Promise with the action result.
*
* It uses {@link AstroGlobal.callAction} internally.
*
* Example usage:
*
* ```typescript
* import { actions } from 'astro:actions';
*
* const result = await callActionWithUrlParamsUnhandledErrors(Astro, actions.getPost, 'form');
* ```
*/
export function callActionWithUrlParamsUnhandledErrors<
TAccept extends ActionAccept,
TInputSchema extends z.ZodType,
TOutput,
TAction extends
| ActionClient<TOutput, TAccept, TInputSchema>
| ActionClient<TOutput, TAccept, TInputSchema>['orThrow'],
P extends Parameters<TAction>[0],
>(context: AstroGlobal, action: TAction, accept: P extends FormData ? 'form' : 'json') {
const input =
accept === 'form'
? urlParamsToFormData(context.url.searchParams)
: urlParamsToObject(context.url.searchParams)
return context.callAction(action, input as P)
}
/**
* Call an Action directly from an Astro page or API endpoint.
*
* The action input is obtained from the URL search params.
*
* Returns a Promise with the action result's data.
*
* It stores the errors in {@link context.locals.banners}
*
* It uses {@link AstroGlobal.callAction} internally.
*
* Example usage:
*
* ```typescript
* import { actions } from 'astro:actions';
*
* const data = await callActionWithUrlParams(Astro, actions.getPost, 'form');
* ```
*/
export async function callActionWithUrlParams<
TAccept extends ActionAccept,
TInputSchema extends z.ZodType,
TOutput,
TAction extends
| ActionClient<TOutput, TAccept, TInputSchema>
| ActionClient<TOutput, TAccept, TInputSchema>['orThrow'],
P extends Parameters<TAction>[0],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TInputSchema2 extends ReturnType<TAction> extends Promise<SafeResult<infer I, any>> ? I : never,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TOutput2 extends ReturnType<TAction> extends Promise<SafeResult<any, infer O>> ? O : never,
>(context: AstroGlobal, action: TAction, accept: P extends FormData ? 'form' : 'json') {
const input =
accept === 'form'
? urlParamsToFormData(context.url.searchParams)
: urlParamsToObject(context.url.searchParams)
const result = (await context.callAction(action, input as P)) as SafeResult<
TInputSchema2,
Awaited<TOutput2>
>
if (result.error) {
context.locals.banners.add({
uiMessage: result.error.message,
type: 'error',
origin: 'action',
error: result.error,
})
}
return result.data
}
/**
* Call an Action directly from an Astro page or API endpoint.
*
* Returns a Promise with the action result.
*
* It uses {@link AstroGlobal.callAction} internally.
*
* Example usage:
*
* ```typescript
* import { actions } from 'astro:actions';
*
* const input = { id: 123 }
* const result = await callActionWithObjectUnhandledErrors(Astro, actions.getPost, input, 'form');
* ```
*/
export function callActionWithObjectUnhandledErrors<
TAccept extends ActionAccept,
TInputSchema extends z.ZodType,
TOutput,
TAction extends
| ActionClient<TOutput, TAccept, TInputSchema>
| ActionClient<TOutput, TAccept, TInputSchema>['orThrow'],
P extends Parameters<TAction>[0],
TInputSchema2 extends ReturnType<TAction> extends Promise<SafeResult<infer I, unknown>> ? I : never,
>(
context: AstroGlobal,
action: TAction,
input: [TInputSchema2] extends [FormData] | [never] ? undefined : TInputSchema2,
accept: P extends FormData ? 'form' : 'json'
) {
const parsedInput = accept === 'form' ? serialize(input) : input
return context.callAction(action, parsedInput as P)
}
/**
* Call an Action directly from an Astro page or API endpoint.
*
* The action input is a plain object.
*
* Returns a Promise with the action result's data.
*
* It stores the errors in {@link context.locals.banners}
*
* It uses {@link AstroGlobal.callAction} internally.
*
* Example usage:
*
* ```typescript
* import { actions } from 'astro:actions';
*
* const input = { id: 123 }
* const data = await callActionWithObject(Astro, actions.getPost, input, 'form');
* ```
*/
export async function callActionWithObject<
TAccept extends ActionAccept,
TInputSchema extends z.ZodType,
TOutput,
TAction extends
| ActionClient<TOutput, TAccept, TInputSchema>
| ActionClient<TOutput, TAccept, TInputSchema>['orThrow'],
P extends Parameters<TAction>[0],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TInputSchema2 extends ReturnType<TAction> extends Promise<SafeResult<infer I, any>> ? I : never,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
TOutput2 extends ReturnType<TAction> extends Promise<SafeResult<any, infer O>> ? O : never,
>(
context: AstroGlobal,
action: TAction,
input: [TInputSchema2] extends [FormData] | [never] ? undefined : TInputSchema2,
accept: P extends FormData ? 'form' : 'json'
) {
const parsedInput = accept === 'form' ? serialize(input) : input
const result = (await context.callAction(action, parsedInput as P)) as SafeResult<
TInputSchema2,
Awaited<TOutput2>
>
if (result.error) {
context.locals.banners.add({
uiMessage: result.error.message,
type: 'error',
origin: 'action',
error: result.error,
})
}
return result.data
}

View File

@@ -1,169 +0,0 @@
import { createHash } from 'crypto'
import { createCanvas } from 'canvas'
export const CAPTCHA_LENGTH = 6
const CAPTCHA_CHARS = 'ABCDEFGHIJKMNOPRSTUVWXYZ123456789' // Notice that ambiguous characters are removed
/** Hash a captcha value */
function hashCaptchaValue(value: string): string {
return createHash('sha256').update(value).digest('hex')
}
/** Generate a captcha image as a data URI */
export function generateCaptchaImage(text: string) {
const width = 144
const height = 48
const canvas = createCanvas(width, height)
const ctx = canvas.getContext('2d')
// Fill background with gradient
const gradient = ctx.createLinearGradient(0, 0, width, height)
gradient.addColorStop(0, '#1a202c')
gradient.addColorStop(1, '#2d3748')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, width, height)
// Add grid pattern background
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)'
ctx.lineWidth = 1
for (let i = 0; i < width; i += 10) {
ctx.beginPath()
ctx.moveTo(i, 0)
ctx.lineTo(i, height)
ctx.stroke()
}
for (let i = 0; i < height; i += 10) {
ctx.beginPath()
ctx.moveTo(0, i)
ctx.lineTo(width, i)
ctx.stroke()
}
// Add wavy lines
for (let i = 0; i < 3; i++) {
const r = Math.floor(Math.random() * 200 + 55).toString()
const g = Math.floor(Math.random() * 200 + 55).toString()
const b = Math.floor(Math.random() * 200 + 55).toString()
ctx.strokeStyle = 'rgba(' + r + ', ' + g + ', ' + b + ', 0.5)'
ctx.lineWidth = Math.random() * 3 + 1
ctx.beginPath()
const startY = Math.random() * height
let curveX = 0
let curveY = startY
ctx.moveTo(curveX, curveY)
while (curveX < width) {
const nextX = curveX + Math.random() * 20 + 10
const nextY = startY + Math.sin(curveX / 20) * (Math.random() * 15 + 5)
ctx.quadraticCurveTo(curveX + (nextX - curveX) / 2, curveY + Math.random() * 20 - 10, nextX, nextY)
curveX = nextX
curveY = nextY
}
ctx.stroke()
}
// Add random dots
for (let i = 0; i < 100; i++) {
const r = Math.floor(Math.random() * 200 + 55).toString()
const g = Math.floor(Math.random() * 200 + 55).toString()
const b = Math.floor(Math.random() * 200 + 55).toString()
const alpha = (Math.random() * 0.5 + 0.1).toString()
ctx.fillStyle = 'rgba(' + r + ', ' + g + ', ' + b + ', ' + alpha + ')'
ctx.beginPath()
ctx.arc(Math.random() * width, Math.random() * height, Math.random() * 2 + 1, 0, Math.PI * 2)
ctx.fill()
}
// Draw captcha text with shadow and more distortion
ctx.shadowColor = 'rgba(0, 0, 0, 0.5)'
ctx.shadowBlur = 3
ctx.shadowOffsetX = 2
ctx.shadowOffsetY = 2
// Calculate text positioning
const fontSize = Math.floor(height * 0.55)
const padding = 15
const charWidth = (width - padding * 2) / text.length
// Draw each character with various effects
for (let i = 0; i < text.length; i++) {
const r = Math.floor(Math.random() * 100 + 155).toString()
const g = Math.floor(Math.random() * 100 + 155).toString()
const b = Math.floor(Math.random() * 100 + 155).toString()
ctx.fillStyle = 'rgb(' + r + ', ' + g + ', ' + b + ')'
// Vary font style for each character
const fontStyle = Math.random() > 0.5 ? 'bold' : 'normal'
const fontFamily = Math.random() > 0.5 ? 'monospace' : 'Arial'
const fontSizeStr = fontSize.toString()
ctx.font = fontStyle + ' ' + fontSizeStr + 'px ' + fontFamily
// Position with variations
const xPos = padding + i * charWidth + (Math.random() * 8 - 4)
const yPos = height * 0.6 + (Math.random() * 12 - 6)
const rotation = Math.random() * 0.6 - 0.3
const scale = 0.8 + Math.random() * 0.4
ctx.save()
ctx.translate(xPos, yPos)
ctx.rotate(rotation)
ctx.scale(scale, scale)
// Add slight perspective effect
if (Math.random() > 0.7) {
ctx.transform(1, 0, 0.1, 1, 0, 0)
} else if (Math.random() > 0.5) {
ctx.transform(1, 0, -0.1, 1, 0, 0)
}
ctx.fillText(text.charAt(i), 0, 0)
ctx.restore()
}
// Add some noise on top
for (let x = 0; x < width; x += 3) {
for (let y = 0; y < height; y += 3) {
if (Math.random() > 0.95) {
const alpha = (Math.random() * 0.2 + 0.1).toString()
ctx.fillStyle = 'rgba(255, 255, 255, ' + alpha + ')'
ctx.fillRect(x, y, 2, 2)
}
}
}
return {
src: canvas.toDataURL('image/png'),
width,
height,
format: 'png',
} as const satisfies ImageMetadata
}
/** Generate a new captcha with solution, its hash, and image data URI */
export function generateCaptcha() {
const solution = Array.from({ length: CAPTCHA_LENGTH }, () =>
CAPTCHA_CHARS.charAt(Math.floor(Math.random() * CAPTCHA_CHARS.length))
).join('')
return {
solution,
solutionHash: hashCaptchaValue(solution),
image: generateCaptchaImage(solution),
}
}
/** Verify a captcha input against the expected hash */
export function verifyCaptcha(value: string, solutionHash: string): boolean {
const correctedValue = value
.toUpperCase()
.replace(/[^A-Z0-9]/g, '')
.replace(/0/g, 'O')
.replace(/Q/g, 'O')
.replace(/L/g, 'I')
const valueHash = hashCaptchaValue(correctedValue)
return valueHash === solutionHash
}

View File

@@ -1,29 +0,0 @@
import { z, type RefinementCtx } from 'astro/zod'
import { CAPTCHA_LENGTH, verifyCaptcha } from './captcha'
export const captchaFormSchemaProperties = {
'captcha-value': z
.string()
.length(CAPTCHA_LENGTH, `Captcha must be ${CAPTCHA_LENGTH.toLocaleString()} characters long`)
.regex(/^[A-Za-z0-9]+$/, 'Captcha must contain only uppercase letters and numbers'),
'captcha-solution-hash': z.string().min(1, 'Missing internal captcha data'),
} as const
export const captchaFormSchemaSuperRefine = (
value: z.infer<z.ZodObject<typeof captchaFormSchemaProperties>>,
ctx: RefinementCtx
) => {
const isValidCaptcha = verifyCaptcha(value['captcha-value'], value['captcha-solution-hash'])
if (!isValidCaptcha) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['captcha-value'],
message: 'Incorrect captcha',
})
}
}
export const captchaFormSchema = z
.object(captchaFormSchemaProperties)
.superRefine(captchaFormSchemaSuperRefine)

View File

@@ -1,14 +0,0 @@
import { clsx, type ClassValue } from 'clsx'
import { extendTailwindMerge } from 'tailwind-merge'
const twMerge = extendTailwindMerge({
extend: {
classGroups: {
'font-family': ['font-title'],
},
},
})
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -1,174 +0,0 @@
import { z } from 'zod'
import type { Prisma } from '@prisma/client'
export const MAX_COMMENT_DEPTH = 12
const commentReplyQuery = {
select: {
id: true,
status: true,
suspicious: true,
upvotes: true,
requiresAdminReview: true,
createdAt: true,
communityNote: true,
internalNote: true,
privateContext: true,
content: true,
serviceId: true,
parentId: true,
rating: true,
ratingActive: true,
orderId: true,
orderIdStatus: true,
kycRequested: true,
fundsBlocked: true,
author: {
select: {
id: true,
name: true,
verified: true,
admin: true,
verifier: true,
createdAt: true,
displayName: true,
picture: true,
totalKarma: true,
spammer: true,
verifiedLink: true,
serviceAffiliations: {
select: {
role: true,
service: {
select: {
name: true,
slug: true,
},
},
},
},
},
},
votes: {
select: {
userId: true,
downvote: true,
},
},
},
orderBy: [{ suspicious: 'asc' }, { createdAt: 'desc' }],
} as const satisfies Prisma.CommentFindManyArgs
export type CommentWithReplies<T extends Record<string, unknown> = Record<never, never>> =
Prisma.CommentGetPayload<typeof commentReplyQuery> &
T & {
replies?: CommentWithReplies<T>[]
}
export type CommentWithRepliesPopulated = CommentWithReplies<{
isWatchingReplies: boolean
}>
export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest')
export type CommentSortOption = z.infer<typeof commentSortSchema>
export function makeCommentsNestedQuery({
depth = 0,
user,
showPending,
serviceId,
sort,
}: {
depth?: number
user: Prisma.UserGetPayload<{
select: {
id: true
}
}> | null
showPending?: boolean
serviceId: number
sort: CommentSortOption
}) {
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
switch (sort) {
case 'upvotes':
orderByClause.push({ upvotes: 'desc' })
break
case 'status':
orderByClause.push({ status: 'asc' }) // PENDING, APPROVED, VERIFIED, REJECTED
break
case 'newest': // Default
default:
orderByClause.push({ createdAt: 'desc' })
break
}
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
const baseQuery = {
...commentReplyQuery,
orderBy: orderByClause,
where: {
OR: [
...(user ? [{ authorId: user.id } as const satisfies Prisma.CommentWhereInput] : []),
showPending
? ({
status: { in: ['APPROVED', 'VERIFIED', 'PENDING', 'HUMAN_PENDING'] },
} as const satisfies Prisma.CommentWhereInput)
: ({
status: { in: ['APPROVED', 'VERIFIED'] },
} as const satisfies Prisma.CommentWhereInput),
],
parentId: null,
serviceId,
},
} as const satisfies Prisma.CommentFindManyArgs
if (depth <= 0) return baseQuery
return {
...baseQuery,
select: {
...baseQuery.select,
replies: makeRepliesQuery(commentReplyQuery, depth - 1),
},
}
}
type RepliesQueryRecursive<T extends Prisma.CommentFindManyArgs> =
| T
| (Omit<T, 'select'> & {
select: Omit<T['select'], 'replies'> & {
replies: RepliesQueryRecursive<T>
}
})
export function makeRepliesQuery<T extends Prisma.CommentFindManyArgs>(
query: T,
currentDepth: number
): RepliesQueryRecursive<T> {
if (currentDepth <= 0) return query
return {
...query,
select: {
...query.select,
replies: makeRepliesQuery(query, currentDepth - 1),
},
}
}
export function makeCommentUrl({
serviceSlug,
commentId,
origin,
}: {
serviceSlug: string
commentId: number
origin: string
}) {
return `${origin}/service/${serviceSlug}?comment=${commentId.toString()}#comment-${commentId.toString()}` as const
}

View File

@@ -1,60 +0,0 @@
import { parsePhoneNumberWithError } from 'libphonenumber-js'
type Formatter = {
id: string
matcher: RegExp
formatter: (value: string) => string | null
}
const formatters = [
{
id: 'email',
matcher: /mailto:(.*)/,
formatter: (value) => value,
},
{
id: 'telephone',
matcher: /tel:(.*)/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
},
},
{
id: 'whatsapp',
matcher: /https?:\/\/wa\.me\/(.*)\/?/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
},
},
{
id: 'telegram',
matcher: /https?:\/\/t\.me\/(.*)\/?/,
formatter: (value) => `t.me/${value}`,
},
{
id: 'linkedin',
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.*)\/?/,
formatter: (value) => `in/${value}`,
},
{
id: 'website',
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: (value) => value,
},
] as const satisfies Formatter[]
export function formatContactMethod(url: string) {
for (const formatter of formatters) {
const captureGroup = url.match(formatter.matcher)?.[1]
if (!captureGroup) continue
const formattedValue = formatter.formatter(captureGroup)
if (!formattedValue) continue
return {
type: formatter.id,
formattedValue,
} as const
}
return null
}

View File

@@ -1,124 +0,0 @@
import {
ActionError,
defineAction,
type ActionAccept,
type ActionAPIContext,
type ActionHandler,
} from 'astro:actions'
import type { MaybePromise } from 'astro/actions/runtime/utils.js'
import type { z } from 'astro/zod'
type SpecialUserPermission = 'admin' | 'verified' | 'verifier'
type Permission = SpecialUserPermission | 'guest' | 'not-spammer' | 'user'
type ActionAPIContextWithUser = ActionAPIContext & {
locals: {
user: NonNullable<ActionAPIContext['locals']['user']>
}
}
type ActionHandlerWithUser<TInputSchema, TOutput> = TInputSchema extends z.ZodType
? (input: z.infer<TInputSchema>, context: ActionAPIContextWithUser) => MaybePromise<TOutput>
: (
// eslint-disable-next-line @typescript-eslint/no-explicit-any
input: any,
context: ActionAPIContextWithUser
) => MaybePromise<TOutput>
export function defineProtectedAction<
P extends Permission | SpecialUserPermission[],
TOutput,
TAccept extends ActionAccept | undefined = undefined,
TInputSchema extends z.ZodType | undefined = TAccept extends 'form' ? z.ZodType<FormData> : undefined,
>({
accept,
input: inputSchema,
handler,
permissions,
}: {
input?: TInputSchema
accept?: TAccept
handler: P extends 'guest'
? ActionHandler<TInputSchema, TOutput>
: ActionHandlerWithUser<TInputSchema, TOutput>
permissions: P
}) {
return defineAction({
accept,
input: inputSchema,
handler: ((input, context) => {
if (permissions === 'guest' || (Array.isArray(permissions) && permissions.length === 0)) {
return handler(
input,
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-explicit-any
context as any
)
}
if (!context.locals.user) {
throw new ActionError({
code: 'UNAUTHORIZED',
message: 'You must be logged in to perform this action.',
})
}
if (permissions === 'not-spammer' && context.locals.user.spammer) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Spammer users are not allowed to perform this action.',
})
}
if (
(permissions === 'verified' || (Array.isArray(permissions) && permissions.includes('verified'))) &&
!context.locals.user.verified
) {
if (context.locals.user.spammer) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Spammer users are not allowed to perform this action.',
})
}
throw new ActionError({
code: 'FORBIDDEN',
message: 'Verified user privileges required.',
})
}
if (
(permissions === 'verifier' || (Array.isArray(permissions) && permissions.includes('verifier'))) &&
!context.locals.user.verifier
) {
if (context.locals.user.spammer) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Spammer users are not allowed to perform this action.',
})
}
throw new ActionError({
code: 'FORBIDDEN',
message: 'Verifier privileges required.',
})
}
if (
(permissions === 'admin' || (Array.isArray(permissions) && permissions.includes('admin'))) &&
!context.locals.user.admin
) {
if (context.locals.user.spammer) {
throw new ActionError({
code: 'FORBIDDEN',
message: 'Spammer users are not allowed to perform this action.',
})
}
throw new ActionError({
code: 'FORBIDDEN',
message: 'Admin privileges required.',
})
}
return handler(input, context as ActionAPIContextWithUser)
}) as ActionHandler<TInputSchema, TOutput>,
})
}

View File

@@ -1,5 +0,0 @@
import { z } from 'astro/zod'
const schema = z.enum(['development', 'staging', 'production'])
export const DEPLOYMENT_MODE = schema.parse(import.meta.env.PROD ? import.meta.env.MODE : 'development')

View File

@@ -1,228 +0,0 @@
import type { APIContext, AstroGlobal } from 'astro'
import type { ErrorInferenceObject } from 'astro/actions/runtime/utils.js'
import type { ActionError, SafeResult } from 'astro:actions'
type MessageObject =
| {
uiMessage: string
type: 'success'
origin: 'action' | 'runtime' | 'url' | `custom_${string}`
error?: undefined
}
| ({
uiMessage: string
type: 'error'
} & (
| {
origin: 'action'
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: ActionError<any>
}
| { origin: 'runtime'; error?: unknown }
| { origin: 'url'; error?: undefined }
| { origin: `custom_${string}`; error?: unknown }
))
/**
* Helper class for handling errors and success messages.
* It automatically adds messages from the query string to the messages array.
*
* @example
* ```astro
* ---
* const messages = new ErrorBanners(Astro)
* const data = await messages.try('Oops!', () => fetchData())
* ---
* <BaseLayout errors={messages.errors} success={messages.successes}>
* {data ? data : 'No data'}
* </BaseLayout>
* ```
*/
export class ErrorBanners {
private _messages: MessageObject[] = []
constructor(messages?: MessageObject[]) {
this._messages = messages ?? []
}
/**
* Get all stored messages.
*/
public get get() {
return [...this._messages]
}
/**
* Get all error messages.
*/
public get errors() {
return this._messages.filter((msg) => msg.type === 'error').map((error) => error.uiMessage)
}
/**
* Get all success messages.
*/
public get successes() {
return this._messages.filter((msg) => msg.type === 'success').map((success) => success.uiMessage)
}
/**
* Handler for errors. Intended to be used in `.catch()` blocks.
*
* @example
* ```ts
* const data = await fetchData().catch(messages.handler('Oops!'))
* ```
*
* @param uiMessage The UI message to display, or function to generate it.
* @returns The handler function.
*/
public handler(uiMessage: string | ((error: unknown) => string)) {
return (error: unknown) => {
const message = typeof uiMessage === 'function' ? uiMessage(error) : uiMessage
console.error(`[ErrorBanners] ${message}`, error)
this._messages.push({
uiMessage: message,
type: 'error',
error,
origin: 'runtime',
})
}
}
/**
* Run a function and catch its errors.
*
* @example
* ```ts
* const items = await messages.try("Oops!", () => fetchItems()) // Item[] | undefined
* const items = await messages.try("Oops!", () => fetchItems(), []) // Item[]
* ```
*
* @param uiMessage The UI message to display, or function to generate it.
* @param fn The function to execute.
* @param fallback The fallback value.
* @returns The result of the function.
*/
public async try<T, F = undefined>(
uiMessage: string | ((error: unknown) => string),
fn: () => Promise<T> | T,
fallback?: F
) {
try {
const result = await fn()
return result
} catch (error) {
this.handler(uiMessage)(error)
return fallback as F
}
}
/**
* Run multiple functions in parallel and catch errors.
*
* @example
* ```ts
* const [categories, posts] = await messages.tryMany([
* ["Error in categories", () => fetchCategories(), []],
* ["Error in posts", () => fetchPosts()]
* ])
* ```
*
* @param operations The operations to run.
* @returns The results of the operations.
*/
public async tryMany<T extends readonly Parameters<typeof this.try<unknown, unknown>>[] | []>(
operations: T
) {
const results = await Promise.all(operations.map((args) => this.try(...args)))
return results as unknown as Promise<{
-readonly [P in keyof T]: Awaited<ReturnType<T[P][1]>> | T[P][2]
}>
}
/**
* Add one or multiple messages.
*/
public add(...messages: (MessageObject | null | undefined)[]) {
messages
.filter((message) => message !== null && message !== undefined)
.forEach((message) => {
this._messages.push(message)
if (message.type === 'error') {
console.error(`[ErrorBanners] ${message.uiMessage}`, message.error)
}
})
}
/**
* Add a success message.
*/
public addSuccess(message: string) {
this._messages.push({
uiMessage: message,
type: 'success',
origin: 'runtime',
})
}
/**
* Add a success message if the action result is successful.
*/
public addIfSuccess<TInput extends ErrorInferenceObject, TOutput>(
actionResult: SafeResult<TInput, TOutput> | undefined,
message: string | ((actionResult: TOutput) => string)
) {
if (actionResult && !actionResult.error) {
const actualMessage = typeof message === 'function' ? message(actionResult.data) : message
this._messages.push({
uiMessage: actualMessage,
type: 'success',
origin: 'action',
})
}
}
/**
* Clear all messages.
*/
public clear() {
this._messages = []
}
}
/**
* Generate message objects for {@link ErrorBanners} from the URL.
*/
export function getMessagesFromUrl(
astro: Pick<APIContext | AstroGlobal | Readonly<APIContext> | Readonly<AstroGlobal>, 'url'>
) {
const messages: MessageObject[] = []
// Get error messages
messages.push(
...astro.url.searchParams.getAll('error').map(
(error) =>
({
uiMessage: error,
type: 'error',
origin: 'url',
}) as const satisfies MessageObject
)
)
// Get success messages
messages.push(
...astro.url.searchParams.getAll('success').map(
(success) =>
({
uiMessage: success,
type: 'success',
origin: 'url',
}) as const satisfies MessageObject
)
)
return messages
}

View File

@@ -1,78 +0,0 @@
import { createHash } from 'crypto'
import fs from 'node:fs/promises'
import path from 'node:path'
import { UPLOAD_DIR } from 'astro:env/server'
/**
* Get the configured upload directory with a subdirectory
*/
function getUploadDir(subDir = ''): { fsPath: string; webPath: string } {
// Get the base upload directory from environment variable
let baseUploadDir = UPLOAD_DIR
// Determine if the path is absolute or relative
const isAbsolutePath = path.isAbsolute(baseUploadDir)
// If it's a relative path, resolve it relative to the project root
if (!isAbsolutePath) {
baseUploadDir = path.join(process.cwd(), baseUploadDir)
}
// For the filesystem path, combine the base dir with the subdirectory
const fsPath = path.join(baseUploadDir, subDir)
// For dynamic uploads, use the endpoint URL
let webPath = `/files${subDir ? `/${subDir}` : ''}`
// Normalize paths to ensure proper formatting
webPath = path.normalize(webPath).replace(/\\/g, '/')
webPath = sanitizePath(webPath)
return {
fsPath: path.normalize(fsPath),
webPath,
}
}
/**
* Generate a hash from file content
*/
async function generateFileHash(file: File): Promise<string> {
const buffer = await file.arrayBuffer()
const hash = createHash('sha1')
hash.update(Buffer.from(buffer))
return hash.digest('hex').substring(0, 10) // Use first 10 chars of hash
}
/**
* Save a file locally and return its web-accessible URL path
*/
export async function saveFileLocally(
file: File,
originalFileName: string,
subDir?: string
): Promise<string> {
const fileBuffer = await file.arrayBuffer()
const fileHash = await generateFileHash(file)
const fileExtension = path.extname(originalFileName)
const fileName = `${fileHash}${fileExtension}`
// Use the provided subDir or default to 'services/pictures'
const { fsPath: uploadDir, webPath: webUploadPath } = getUploadDir(subDir ?? 'services/pictures')
await fs.mkdir(uploadDir, { recursive: true })
const filePath = path.join(uploadDir, fileName)
await fs.writeFile(filePath, Buffer.from(fileBuffer))
const url = sanitizePath(`${webUploadPath}/${fileName}`)
return url
}
function sanitizePath(inputPath: string): string {
let sanitized = inputPath.replace(/\\+/g, '/')
// Collapse multiple slashes, but preserve protocol (e.g., http://)
sanitized = sanitized.replace(/([^:])\/+/g, '$1/')
sanitized = sanitized.replace(/\/(\?|#|$)/g, '$1')
return sanitized
}

View File

@@ -1,9 +0,0 @@
export const baseInputClassNames = {
input:
'bg-night-600 block placeholder:text-sm placeholder:text-day-600 text-day-100 w-full min-w-0 rounded-lg border border-night-400 px-3 leading-none h-9',
div: 'bg-night-600 rounded-lg border border-night-400 text-sm',
error: 'border-red-500 focus:border-red-500 focus:ring-red-500',
disabled: 'cursor-not-allowed',
textarea: 'resize-y min-h-16',
file: 'file:bg-day-700 file:text-day-100 hover:file:bg-day-600 file:mr-4 file:rounded-md file:border-0 file:px-4 file:py-2 file:text-sm file:font-medium h-12 p-1.25',
} as const satisfies Record<string, string>

View File

@@ -1,40 +0,0 @@
import { ActionError } from 'astro:actions'
import { prisma } from './prisma'
export const handleHoneypotTrap = async <T extends Record<string, unknown>>({
input,
honeyPotTrapField,
userId,
location,
dontMarkAsSpammer = false,
}: {
input: T
honeyPotTrapField: keyof T
userId: number | null | undefined
location: string
dontMarkAsSpammer?: boolean
}) => {
if (!input[honeyPotTrapField]) return
if (!dontMarkAsSpammer && !!userId) {
await prisma.user.update({
where: {
id: userId,
},
data: {
spammer: true,
internalNotes: {
create: {
content: `Marked as spammer because it fell for the honey pot trap in: ${location}`,
},
},
},
})
}
throw new ActionError({
message: 'Invalid request',
code: 'BAD_REQUEST',
})
}

View File

@@ -1,39 +0,0 @@
import { redisImpersonationSessions } from './redis/redisImpersonationSessions'
import type { APIContext, AstroCookies } from 'astro'
const IMPERSONATION_SESSION_COOKIE = 'impersonation_session_id'
export async function startImpersonating(
context: Pick<APIContext, 'cookies' | 'locals'>,
adminUser: NonNullable<APIContext['locals']['actualUser']>,
targetUser: NonNullable<APIContext['locals']['user']>
) {
const sessionId = await redisImpersonationSessions.store({
adminId: adminUser.id,
targetId: targetUser.id,
})
context.cookies.set(IMPERSONATION_SESSION_COOKIE, sessionId, {
path: '/',
secure: true,
httpOnly: true,
sameSite: 'strict',
maxAge: redisImpersonationSessions.expirationTime,
})
context.locals.user = targetUser
context.locals.actualUser = adminUser
}
export async function stopImpersonating(context: Pick<APIContext, 'cookies' | 'locals'>) {
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
await redisImpersonationSessions.delete(sessionId)
context.cookies.delete(IMPERSONATION_SESSION_COOKIE)
context.locals.user = context.locals.actualUser
context.locals.actualUser = null
}
export async function getImpersonationInfo(cookies: AstroCookies) {
const sessionId = cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
return await redisImpersonationSessions.get(sessionId)
}

View File

@@ -1,29 +0,0 @@
import { karmaUnlocksById, type KarmaUnlockInfo } from '../constants/karmaUnlocks'
export type KarmaUnlocks = {
[K in keyof typeof karmaUnlocksById]: boolean
}
export function computeKarmaUnlocks(karma: number) {
return Object.fromEntries(
Object.entries(karmaUnlocksById).map(([key, value]) => [
key,
value.karma >= 0 ? karma >= value.karma : karma <= value.karma,
])
) as KarmaUnlocks
}
export function makeUserWithKarmaUnlocks(user: null): null
export function makeUserWithKarmaUnlocks<T extends { totalKarma: number }>(
user: T
): T & { karmaUnlocks: KarmaUnlocks }
export function makeUserWithKarmaUnlocks<T extends { totalKarma: number }>(
user: T | null
): (T & { karmaUnlocks: KarmaUnlocks }) | null
export function makeUserWithKarmaUnlocks<T extends { totalKarma: number }>(user: T | null) {
return user ? { ...user, karmaUnlocks: computeKarmaUnlocks(user.totalKarma) } : null
}
export function makeKarmaUnlockMessage(karmaUnlock: KarmaUnlockInfo) {
return `You need ${karmaUnlock.karma.toLocaleString()} karma to ${karmaUnlock.verb}.` as const
}

View File

@@ -1,144 +0,0 @@
import { uniqBy } from 'lodash-es'
import { zodEnumFromConstant } from './arrays'
import { typedGroupBy, type TypedGroupBy } from './objects'
import type { ZodEnum } from 'astro/zod'
/**
* Creates utility functions to work with an array of options.
* Primarily a `getFn` and `useGetHook`, that return the option object based on the key, or a fallback value if the key is not found.
*
* @param dataArray - Array of objects, must be defined using `as const` to ensure type safety.
* @param key - The key to group the array by
*/
export function makeHelpersForOptions<
K extends string,
Fallback extends Record<K, string | null | undefined> &
Record<string, unknown> & { slug?: string | null | undefined },
TArray extends readonly (Fallback & Record<K, string>)[],
HasSlugs extends boolean = TArray extends Record<'slug', string>[] ? true : false,
>(key: K, makeFallback: (key: string | null | undefined) => Fallback, dataArray: TArray) {
const hasDuplicateIds = uniqBy(dataArray, key).length !== dataArray.length
if (hasDuplicateIds) {
throw new Error(`[makeHelpersForOptions] Duplicate ${key} in dataArray`)
}
const hasSlugs = dataArray.some((item) => 'slug' in item && typeof item.slug === 'string')
const allSlugsAreDefined = dataArray.every((item) => 'slug' in item && typeof item.slug === 'string')
if (hasSlugs) {
if (!allSlugsAreDefined) {
throw new Error('[makeHelpersForOptions] Some slugs are missing in dataArray')
}
const hasDuplicateSlugs = uniqBy(dataArray, 'slug').length !== dataArray.length
if (hasDuplicateSlugs) {
throw new Error('[makeHelpersForOptions] Duplicate slug in dataArray')
}
}
const dataObject = typedGroupBy<K, TArray[number]>(dataArray, key)
const dataObjectBySlug = (
allSlugsAreDefined
? typedGroupBy(dataArray as TArray extends Record<'slug', string>[] ? TArray : never, 'slug')
: undefined
) as HasSlugs extends true
? TypedGroupBy<'slug', TArray extends Record<'slug', string>[] ? TArray[number] : never>
: undefined
function getFn<T extends TArray[number][K]>(id: T): Extract<TArray[number], Record<K, T>>
function getFn<T extends string | null | undefined>(id: T): Extract<TArray[number], Record<K, T>> | Fallback
function getFn<T extends string | null | undefined>(
id: T
): Extract<TArray[number], Record<K, T>> | Fallback {
return typeof id === 'string' && id in dataObject
? dataObject[id as unknown as keyof typeof dataObject]
: makeFallback(id)
}
function getFnSlug<T extends TArray[number]['slug']>(slug: T): Extract<TArray[number], Record<'slug', T>>
function getFnSlug<T extends string | null | undefined>(
slug: T
): Extract<TArray[number], Record<'slug', T>> | Fallback
function getFnSlug<T extends string | null | undefined>(
slug: T
): Extract<TArray[number], Record<'slug', T>> | Fallback {
return typeof slug === 'string' && dataObjectBySlug && slug in dataObjectBySlug
? (dataObjectBySlug as NonNullable<typeof dataObjectBySlug>)[
slug as unknown as keyof NonNullable<typeof dataObjectBySlug>
]
: makeFallback(null)
}
// const useGetHook: typeof getFn = ((status: any) => {
// return useMemo(() => getFn(status), [status])
// }) as typeof getFn
const exposedMakeFallback = <O extends Omit<Partial<Fallback>, K>>(
id: Parameters<typeof makeFallback>[0],
options?: O
) => {
return {
...makeFallback(id),
...options,
} as Fallback & O
}
const zodEnumById = zodEnumFromConstant(dataArray, key)
const zodEnumBySlug = (
allSlugsAreDefined
? zodEnumFromConstant(dataArray as TArray extends Record<'slug', string>[] ? TArray : never, 'slug')
: undefined
) as HasSlugs extends true ? ZodEnum<[TArray[number]['slug'], ...TArray[number]['slug'][]]> : undefined
function slugToKey<T extends TArray[number]['slug']>(slug: T): Extract<TArray[number], Record<'slug', T>>[K]
function slugToKey<T extends string | null | undefined>(
slug: T
): Extract<TArray[number], Record<'slug', T>>[K] | undefined
function slugToKey<T extends string | null | undefined>(
slug: T
): Extract<TArray[number], Record<'slug', T>>[K] | undefined {
return typeof slug === 'string' && dataObjectBySlug && slug in dataObjectBySlug
? ((dataObjectBySlug as NonNullable<typeof dataObjectBySlug>)[
slug as unknown as keyof NonNullable<typeof dataObjectBySlug>
][key] as unknown as Extract<TArray[number], Record<'slug', T>>[K])
: undefined
}
function keyToSlug<T extends TArray[number][K]>(slug: T): Extract<TArray[number], Record<K, T>>['slug']
function keyToSlug<T extends string | null | undefined>(
slug: T
): Extract<TArray[number], Record<K, T>>['slug'] | undefined
function keyToSlug<T extends string | null | undefined>(
slug: T
): Extract<TArray[number], Record<K, T>>['slug'] | undefined {
return typeof slug === 'string' && slug in dataObject
? (dataObject[slug as unknown as keyof NonNullable<typeof dataObject>][key] as unknown as Extract<
TArray[number],
Record<K, T>
>['slug'])
: undefined
}
return {
dataArray,
dataObject,
/** Gets the info by key, if not found, returns a fallback value */
getFn,
/** Gets the info by key, if not found, returns a fallback value */
// useGetHook: useGetHook,
/** Generates a fallback value */
makeFallback: exposedMakeFallback,
zodEnumById,
dataObjectBySlug,
/** Gets the info by slug, if not found, returns a fallback value */
getFnSlug,
zodEnumBySlug,
/** Gets the id by slug, if not found, returns undefined */
slugToKey,
/** Gets the slug by id, if not found, returns undefined */
keyToSlug,
}
}

View File

@@ -1,5 +0,0 @@
/** A string containing Markdown. */
export type MarkdownString = string
/** A string containing HTML. */
export type HtmlString = string

View File

@@ -1,14 +0,0 @@
import { prisma } from './prisma'
import type { Prisma } from '@prisma/client'
export async function getOrCreateNotificationPreferences<T extends Prisma.NotificationPreferencesSelect>(
userId: number,
select: { [K in keyof T]: K extends keyof Prisma.NotificationPreferencesSelect ? T[K] : never },
tx: Prisma.TransactionClient = prisma
) {
return (
(await tx.notificationPreferences.findUnique({ where: { userId }, select })) ??
(await tx.notificationPreferences.create({ data: { userId }, select }))
)
}

View File

@@ -1,295 +0,0 @@
import { accountStatusChangesById } from '../constants/accountStatusChange'
import { commentStatusChangesById } from '../constants/commentStatusChange'
import { eventTypesById } from '../constants/eventTypes'
import { serviceVerificationStatusChangesById } from '../constants/serviceStatusChange'
import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatusChange'
import { makeCommentUrl } from './commentsWithReplies'
import type { Prisma } from '@prisma/client'
export function makeNotificationTitle(
notification: Prisma.NotificationGetPayload<{
select: {
type: true
aboutAccountStatusChange: true
aboutCommentStatusChange: true
aboutServiceVerificationStatusChange: true
aboutSuggestionStatusChange: true
aboutComment: {
select: {
author: { select: { id: true } }
status: true
parent: {
select: {
author: {
select: {
id: true
}
}
}
}
service: {
select: {
name: true
}
}
}
}
aboutServiceSuggestion: {
select: {
status: true
service: {
select: {
name: true
}
}
}
}
aboutServiceSuggestionMessage: {
select: {
suggestion: {
select: {
service: {
select: {
name: true
}
}
}
}
}
}
aboutEvent: {
select: {
type: true
service: {
select: {
name: true
}
}
}
}
aboutService: {
select: {
name: true
verificationStatus: true
}
}
}
}>,
user: Prisma.UserGetPayload<{ select: { id: true } }> | null
): string {
switch (notification.type) {
case 'COMMENT_STATUS_CHANGE': {
if (!notification.aboutComment) return 'A comment you are watching had a status change'
if (!notification.aboutCommentStatusChange) {
return `Comment on ${notification.aboutComment.service.name} had a status change`
}
const statusChange = commentStatusChangesById[notification.aboutCommentStatusChange]
const serviceName = notification.aboutComment.service.name
const isOwnComment = !!user && notification.aboutComment.author.id === user.id
const prefix = isOwnComment ? 'Your comment' : 'Watched comment'
return `${prefix} on ${serviceName} ${statusChange.notificationTitle}`
}
case 'REPLY_COMMENT_CREATED': {
if (!notification.aboutComment) return 'You have a new reply'
const serviceName = notification.aboutComment.service.name
if (!notification.aboutComment.parent) {
return `New reply to a comment on ${serviceName}`
}
const isOwnParentComment = !!user && notification.aboutComment.parent.author.id === user.id
return isOwnParentComment
? `New reply to your comment on ${serviceName}`
: `New reply to a watched comment on ${serviceName}`
}
case 'COMMUNITY_NOTE_ADDED': {
if (!notification.aboutComment) return 'A community note was added'
const serviceName = notification.aboutComment.service.name
const isOwnComment = !!user && notification.aboutComment.author.id === user.id
return isOwnComment
? `Community note added to your comment on ${serviceName}`
: `Community note added to a watched comment on ${serviceName}`
}
case 'ROOT_COMMENT_CREATED': {
if (!notification.aboutComment) return 'New comment'
const service = notification.aboutComment.service.name
return notification.aboutComment.status == 'PENDING'
? `New unmoderated comment on ${service}`
: `New comment on ${service}`
}
case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return 'New message on your suggestion'
const service = notification.aboutServiceSuggestionMessage.suggestion.service.name
return `New message for ${service} suggestion`
}
case 'SUGGESTION_STATUS_CHANGE': {
if (!notification.aboutServiceSuggestion) return 'Suggestion status updated'
const service = notification.aboutServiceSuggestion.service.name
if (!notification.aboutSuggestionStatusChange) {
return `${service} suggestion status updated`
}
const statusChange = serviceSuggestionStatusChangesById[notification.aboutSuggestionStatusChange]
return `${service} suggestion ${statusChange.notificationTitle}`
}
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK': {
// return 'New karma level unlocked'
// }
case 'ACCOUNT_STATUS_CHANGE': {
if (!notification.aboutAccountStatusChange) return 'Your account status has been updated'
const accountStatusChange = accountStatusChangesById[notification.aboutAccountStatusChange]
return accountStatusChange.notificationTitle
}
case 'EVENT_CREATED': {
if (!notification.aboutEvent) return 'New event on a service'
const service = notification.aboutEvent.service.name
const eventType = eventTypesById[notification.aboutEvent.type].label
return `${eventType} event on ${service}`
}
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
if (!notification.aboutService) return 'Service verification status updated'
const serviceName = notification.aboutService.name
if (!notification.aboutServiceVerificationStatusChange) {
return `${serviceName} verification status updated`
}
const statusChange =
serviceVerificationStatusChangesById[notification.aboutServiceVerificationStatusChange]
return `${serviceName} ${statusChange.notificationTitle}`
}
}
}
export function makeNotificationContent(
notification: Prisma.NotificationGetPayload<{
select: {
type: true
aboutComment: {
select: {
content: true
communityNote: true
}
}
aboutServiceSuggestionMessage: {
select: {
content: true
}
}
aboutEvent: {
select: {
title: true
}
}
}
}>
): string | null {
switch (notification.type) {
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK':
case 'SUGGESTION_STATUS_CHANGE':
case 'ACCOUNT_STATUS_CHANGE':
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
return null
}
case 'COMMENT_STATUS_CHANGE':
case 'REPLY_COMMENT_CREATED':
case 'ROOT_COMMENT_CREATED': {
if (!notification.aboutComment) return null
return notification.aboutComment.content
}
case 'COMMUNITY_NOTE_ADDED': {
if (!notification.aboutComment) return null
return notification.aboutComment.communityNote
}
case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return null
return notification.aboutServiceSuggestionMessage.content
}
case 'EVENT_CREATED': {
if (!notification.aboutEvent) return null
return notification.aboutEvent.title
}
}
}
export function makeNotificationLink(
notification: Prisma.NotificationGetPayload<{
select: {
type: true
aboutComment: {
select: {
id: true
service: {
select: {
slug: true
}
}
}
}
aboutServiceSuggestionId: true
aboutServiceSuggestionMessage: {
select: {
id: true
suggestion: {
select: {
id: true
}
}
}
}
aboutEvent: {
select: {
service: {
select: {
slug: true
}
}
}
}
aboutService: {
select: {
slug: true
}
}
}
}>,
origin: string
): string | null {
switch (notification.type) {
case 'COMMENT_STATUS_CHANGE':
case 'REPLY_COMMENT_CREATED':
case 'COMMUNITY_NOTE_ADDED':
case 'ROOT_COMMENT_CREATED': {
if (!notification.aboutComment) return null
return makeCommentUrl({
serviceSlug: notification.aboutComment.service.slug,
commentId: notification.aboutComment.id,
origin,
})
}
case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return null
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`
}
case 'SUGGESTION_STATUS_CHANGE': {
if (!notification.aboutServiceSuggestionId) return null
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`
}
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK': {
// return `${origin}/account#karma-unlocks`
// }
case 'ACCOUNT_STATUS_CHANGE': {
return `${origin}/account#account-status`
}
case 'EVENT_CREATED': {
if (!notification.aboutEvent) return null
return `${origin}/service/${notification.aboutEvent.service.slug}#events`
}
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
if (!notification.aboutService) return null
return `${origin}/service/${notification.aboutService.slug}#verification`
}
}
}

View File

@@ -1,35 +0,0 @@
import { round } from 'lodash-es'
export function parseIntWithFallback<F = null>(value: unknown, fallback: F = null as F) {
const parsed = Number(value)
if (!Number.isInteger(parsed)) return fallback
return parsed
}
/**
* Interpolates a value between a start and end value.
* @param value - The value to interpolate.
* @param start - The start value.
* @param end - The end value.
* @returns The interpolated value.
*/
export function interpolate(value: number, start: number, end: number) {
return start + (end - start) * value
}
export type FormatNumberOptions = Intl.NumberFormatOptions & {
roundDigits?: number
showSign?: boolean
removeTrailingZeros?: boolean
}
export function formatNumber(
value: number,
{ roundDigits = 0, showSign = true, removeTrailingZeros = true, ...formatOptions }: FormatNumberOptions = {}
) {
const rounded = round(value, roundDigits)
const formatted = rounded.toLocaleString(undefined, formatOptions)
const withoutTrailingZeros = removeTrailingZeros ? formatted.replace(/\.0+$/, '') : formatted
const withSign = showSign && value > 0 ? `+${withoutTrailingZeros}` : withoutTrailingZeros
return withSign
}

View File

@@ -1,164 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { isEqualWith } from 'lodash-es'
import { areEqualArraysWithoutOrder } from './arrays'
import type { Prettify } from 'ts-essentials'
import type TB from 'ts-toolbelt'
export function removeUndefined(obj: Record<string, unknown>) {
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined))
}
type RemoveUndefinedProps<T extends Record<string, unknown>> = {
[key in keyof T]-?: Exclude<T[key], undefined>
}
/**
* Assigns properties from `obj2` to `obj1`, but only if they are defined in `obj2`.
* @example
* assignDefinedOnly({ a: 1, b: 2}, { a: undefined, b: 3, c: 4 }) // result = { a: 1, b: 3, c: 4 }
*/
export const assignDefinedOnly = <T1 extends Record<string, unknown>, T2 extends Record<string, unknown>>(
obj1: T1,
obj2: T2
) => {
return { ...obj1, ...removeUndefined(obj2) } as Omit<RemoveUndefinedProps<T2>, keyof T1> &
Omit<T1, keyof T2> & {
[key in keyof T1 & keyof T2]-?: undefined extends T2[key]
? Exclude<T2[key], undefined> | T1[key]
: T2[key]
}
}
export type Paths<T> = T extends object
? {
[K in keyof T]: `${Exclude<K, symbol>}${'' | `.${Paths<T[K]>}`}`
}[keyof T]
: never
export type PathValue<T, K extends string> = TB.Object.Path<T, TB.String.Split<K, '.'>>
export type Leaves<T> = T extends object
? {
[K in keyof T]: `${Exclude<K, symbol>}${Leaves<T[K]> extends never ? '' : `.${Leaves<T[K]>}`}`
}[keyof T]
: never
// Start of paths with nested
type Digit = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9
type NextDigit = [1, 2, 3, 4, 5, 6, 7, 'STOP']
type Inc<T> = T extends Digit ? NextDigit[T] : 'STOP'
type StringOrNumKeys<TObj> = TObj extends unknown[] ? 0 : string & keyof TObj
type NestedPath<TValue, Prefix extends string, TValueNestedChild, TDepth> = TValue extends object
? `${Prefix}.${TDepth extends 'STOP' ? string : NestedFieldPaths<TValue, TValueNestedChild, TDepth>}`
: never
type GetValue<T, K extends number | string> = T extends unknown[]
? K extends number
? T[K]
: never
: K extends keyof T
? T[K]
: never
type NestedFieldPaths<TData = any, TValue = any, TDepth = 0> = {
[TKey in StringOrNumKeys<TData>]:
| NestedPath<GetValue<TData, TKey>, `${TKey}`, TValue, Inc<TDepth>>
| (GetValue<TData, TKey> extends TValue ? `${TKey}` : never)
}[StringOrNumKeys<TData>]
export type PathsWithNested<TData = any> = TData extends any ? NestedFieldPaths<TData, any, 1> : never
// End of paths with nested
export type TypedGroupBy<K extends string, T extends Record<K, string> & Record<string, unknown>> = {
[Id in T[K]]: Extract<T, Record<K, Id>>
}
/**
* Converts an array of objects to an object with the key as the id and the value as the object.
* @example
* typedGroupBy([
* { id: 'a', name: 'Letter A' },
* { id: 'b', name: 'Letter B' }
* ] as const, 'id')
* // result = {
* // a: { id: 'a', name: 'Letter A' },
* // b: { id: 'b', name: 'Letter B' }
* // }
*/
export const typedGroupBy = <K extends string, T extends Record<K, string> & Record<string, unknown>>(
array: T[] | readonly T[],
key: K
) => {
return Object.fromEntries(array.map((option) => [option[key], option])) as TypedGroupBy<K, T>
}
/**
* Merges two objects, so that each property is the union of that property from each object.
* - If a key is present in only one of the objects, it becomes optional.
* - If an object is undefined, the other object is returned.
*
* To {@link UnionizeTwo} more than two objects, use {@link Unionize}.
*
* @example
* UnionizeTwo<{ a: 1, shared: 1 }, { b: 2, shared: 2 }> // { a?: 1, b?: 2, shared: 1 | 2 }
*/
export type UnionizeTwo<
T1 extends Record<string, unknown> | undefined,
T2 extends Record<string, unknown> | undefined,
> = keyof T1 extends undefined
? T2
: keyof T2 extends undefined
? T1
: {
[K in Exclude<keyof T1 | keyof T2, keyof T1 & keyof T2>]+?:
| (K extends keyof T1 ? T1[K] : never)
| (K extends keyof T2 ? T2[K] : never)
} & {
[K in keyof T1 & keyof T2]-?: T1[K] | T2[K]
}
/**
* Merges multiple objects, so that each property is the union of that property from each object.
* - If a key is present in only one of the objects, it becomes optional.
* - If an object is undefined, it is ignored.
* - If no objects are provided, `undefined` is returned.
*
* Internally, it uses {@link UnionizeTwo} recursively.
*
* @example
* Unionize<[
* { a: 1, shared: 1 },
* { b: 2, shared: 2 },
* { a: 3, shared: 3 }
* ]>
* // result = {
* // a?: 1 | 3,
* // b?: 2,
* // shared: 1 | 2 | 3
* // }
*/
export type Unionize<T extends Record<string, unknown>[]> = Prettify<
T extends []
? undefined
: T extends [infer First, ...infer Rest]
? Rest extends Record<string, unknown>[]
? First extends Record<string, unknown>
? UnionizeTwo<First, Unionize<Rest>>
: undefined
: First
: undefined
>
/**
* Checks if two objects are equal without considering order in arrays.
* @example
* areEqualObjectsWithoutOrder({ a: [1, 2], b: 3 }, { b: 3, a: [2, 1] }) // true
*/
export function areEqualObjectsWithoutOrder<T extends Record<string, unknown>>(
a: T,
b: Record<string, unknown>
): b is T {
return isEqualWith(a, b, (a, b) => {
if (Array.isArray(a) && Array.isArray(b)) return areEqualArraysWithoutOrder(a, b)
return undefined
})
}

View File

@@ -1,8 +0,0 @@
import type { Tail } from 'ts-essentials'
export function addOnLoadEventListener(
...args: Tail<Parameters<typeof document.addEventListener<'astro:page-load'>>>
) {
document.addEventListener('astro:page-load', ...args)
document.addEventListener('htmx:afterSwap', ...args)
}

View File

@@ -1,311 +0,0 @@
import { z } from 'astro/zod'
import { isEqual, omit } from 'lodash-es'
import { areEqualObjectsWithoutOrder } from './objects'
import { getObjectSearchParam, makeObjectSearchParamKeyRegex } from './urls'
import type { APIContext, AstroGlobal } from 'astro'
import type { ZodError, ZodType, ZodTypeDef } from 'astro/zod'
type MyZodUnknown<Output = unknown, Def extends ZodTypeDef = ZodTypeDef, Input = Output> = ZodType<
Output,
Def,
Input
>
type ZodParseFromUrlOptions = {
allOptional?: boolean
}
/**
* Parses an array of values from a URL with zod.
*
* The wrong values are skipped, and the errors are returned.
*
* @example
* ```ts
* const schema = z.array(z.enum(['S', 'M', 'L', 'XL']))
* const urlValue = ['wrong', 'M', 'L']
* const { data, errors } = zodParseArray(schema, urlValue)
* // data: ['M', 'L']
* // errors: [{ key: 0, error: ZodError }]
* ```
*/
function zodParseArray<T extends MyZodUnknown>(schema: T, urlValue: string[] | readonly string[]) {
const unwrappedSchema = unwrapSchema(schema, {
default: true,
optional: true,
nullable: true,
})
const itemSchema =
unwrappedSchema instanceof z.ZodArray ? (unwrappedSchema as z.ZodArray<MyZodUnknown>).element : undefined
if (!itemSchema || urlValue.length === 0) {
const parsedArray = schema.safeParse(
schema instanceof z.ZodDefault && urlValue.length === 0 ? undefined : urlValue
)
return parsedArray.success
? {
data: parsedArray.data,
errors: [],
}
: {
data: schema instanceof z.ZodOptional ? undefined : [],
errors: [{ key: 0, error: parsedArray.error }],
}
}
const parsedItems = urlValue.map((item) => itemSchema.safeParse(item))
return {
data: parsedItems.filter((parsed) => parsed.success).map((r) => r.data),
errors: parsedItems.filter((parsed) => !parsed.success).map((r, i) => ({ key: i, error: r.error })),
}
}
/**
* Parses the query params of a URL with zod.
*
* The wrong values are set to `undefined`, and the errors are returned.
*
* @example
* ```ts
* const params = new URLSearchParams('sizes=M&sizes=L&max-price=wrong')
* const schema = {
* sizes: z.array(z.enum(['S', 'M', 'L', 'XL'])),
* 'max-price': z.coerce.number(),
* 'min-price': z.coerce.number().default(0),
* }
* const { data, errors } = zodParseQueryParams(schema, params)
* // data:
* // {
* // sizes: ['M', 'L'],
* // 'max-price': undefined,
* // 'min-price': 0
* // }
* // errors: [{ key: 'max-price', error: ZodError }]
* ```
*/
export function zodParseQueryParams<T extends Record<string, MyZodUnknown>, O extends ZodParseFromUrlOptions>(
shape: T,
params: URLSearchParams,
options?: O
) {
const errors: { key: string; error: ZodError }[] = []
const data = Object.fromEntries(
Object.entries(shape).map(([key, paramSchema]) => {
const schema =
!(paramSchema instanceof z.ZodDefault || paramSchema instanceof z.ZodEffects) &&
options?.allOptional !== false
? paramSchema.optional()
: paramSchema
const unwrappedSchema = unwrapSchema(schema, {
default: true,
optional: true,
nullable: true,
})
if (unwrappedSchema instanceof z.ZodArray) {
const parsed = zodParseArray(schema, params.getAll(key))
const firstError = parsed.errors[0]
if (firstError) errors.push({ key, error: firstError.error })
return [key, parsed.data]
}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const urlStringValue = params.get(key) || undefined
const urlValue =
unwrappedSchema instanceof z.ZodArray
? params.getAll(key)
: unwrappedSchema instanceof z.ZodObject || unwrappedSchema instanceof z.ZodRecord
? getObjectSearchParam(params, key)
: urlStringValue
const parsed = schema.safeParse(urlValue)
if (!parsed.success) {
errors.push({ key, error: parsed.error })
return [key, paramSchema.safeParse(undefined).data]
}
return [key, parsed.data]
})
) as {
[K in keyof T]: ReturnType<
(O['allOptional'] extends false
? T[K]
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
T[K] extends z.ZodArray<any> | z.ZodDefault<any> | z.ZodEffects<any>
? T[K]
: z.ZodOptional<T[K]>)['parse']
>
}
return { data, errors }
}
type CleanUrlOptions<T extends string> = {
removeUneededObjectParams?: boolean
removeParams?: {
[K in T]?: { if: 'another-is-unset'; prop: K } | { if: 'default' }
}
}
/**
* Parses the query params of the current URL with zod and stores the errors in the context.
*
* Wrong values are set to `undefined`, and the errors stored in `Astro.locals.banners`.
*
* @example
* ```ts
* const schema = {
* sizes: z.array(z.enum(['S', 'M', 'L', 'XL'])),
* 'max-price': z.coerce.number(),
* 'min-price': z.coerce.number().default(0),
* }
* const data = zodParseQueryParamsStoringErrors(schema, Astro)
* // data:
* // {
* // sizes: ['M', 'L'],
* // 'max-price': undefined,
* // 'min-price': 0
* // }
* // And 1 error stored in Astro.locals.banners (`max-price`).
* ```
*/
export function zodParseQueryParamsStoringErrors<
K extends string,
T extends Record<K, MyZodUnknown>,
O extends ZodParseFromUrlOptions & {
ignoredKeysForDefaultData?: K[]
cleanUrl?: CleanUrlOptions<K>
},
C extends Pick<APIContext | AstroGlobal | Readonly<APIContext> | Readonly<AstroGlobal>, 'locals' | 'url'>,
>(shape: T, context: C, options?: O) {
const { data, errors } = zodParseQueryParams(shape, context.url.searchParams, options)
context.locals.banners.add(
...errors.map(
(error) =>
({
uiMessage: `Error in the ${error.key} filter. Using default value.`,
type: 'error',
error: error.error,
origin: 'custom_filters',
}) as const
)
)
const defaultDataWithoutIgnoringKeys = zodParseQueryParams(shape, new URLSearchParams(), options).data
const defaultData = omit(defaultDataWithoutIgnoringKeys, options?.ignoredKeysForDefaultData ?? [])
const hasDefaultData = areEqualObjectsWithoutOrder(
omit(data, options?.ignoredKeysForDefaultData ?? []),
defaultData
)
const redirectUrl = makeCleanUrl(shape, context.url, options?.cleanUrl, data, defaultData)
return { data, defaultData, hasDefaultData, errors, schema: shape, redirectUrl }
}
function unwrapSchema<T extends MyZodUnknown>(
schema: T,
options: {
default?: boolean
optional?: boolean
nullable?: boolean
array?: boolean
} = {}
) {
if (options.default && schema instanceof z.ZodDefault) {
return unwrapSchema((schema as z.ZodDefault<T>).removeDefault(), options)
}
if (options.optional && schema instanceof z.ZodOptional) {
return unwrapSchema((schema as z.ZodOptional<T>).unwrap(), options)
}
if (options.nullable && schema instanceof z.ZodNullable) {
return unwrapSchema((schema as z.ZodNullable<T>).unwrap(), options)
}
if (options.array && schema instanceof z.ZodArray) {
return unwrapSchema((schema as z.ZodArray<T>).element, options)
}
return schema
}
function makeCleanUrl<K extends string, T extends Record<K, MyZodUnknown>>(
shape: T,
url: URL,
options?: CleanUrlOptions<K>,
data?: Record<K, unknown>,
defaultData?: Record<string, unknown>
) {
if (!options) return null
const paramsToRemove = [
...(options.removeUneededObjectParams ? getUneededObjectParams(shape, url) : []),
...(options.removeParams ? getParamsToRemove(shape, url, options.removeParams, data, defaultData) : []),
]
if (!paramsToRemove.length) return null
const cleanUrl = new URL(url)
paramsToRemove.forEach(([key, value]) => {
cleanUrl.searchParams.delete(key, value)
})
return cleanUrl
}
function getUneededObjectParams<T extends Record<string, MyZodUnknown>>(shape: T, url: URL) {
const objectParamsRegex = Object.entries(shape)
.filter(([_key, paramSchema]) => {
const schema = unwrapSchema(paramSchema, {
default: true,
optional: true,
nullable: true,
})
return schema instanceof z.ZodObject || schema instanceof z.ZodRecord
})
.map(([key]) => makeObjectSearchParamKeyRegex(key))
if (!objectParamsRegex.length) return []
const uneededParams = url.searchParams
.entries()
.filter(([key, value]) => objectParamsRegex.some((regex) => regex.test(key)) && value === '')
.toArray()
return uneededParams
}
function getParamsToRemove<K extends string, T extends Record<K, MyZodUnknown>>(
shape: T,
url: URL,
removeParams: NonNullable<CleanUrlOptions<K>['removeParams']>,
data?: Record<K, unknown>,
defaultData?: Record<K, unknown>
) {
return url.searchParams
.entries()
.filter(([key]) => {
const options = key in removeParams ? removeParams[key as K] : undefined
if (!options) return false
const paramSchema = key in shape ? shape[key as K] : undefined
if (!paramSchema) return false
switch (options.if) {
case 'another-is-unset': {
return !url.searchParams
.keys()
.some((key2) => key2 === options.prop || makeObjectSearchParamKeyRegex(options.prop).test(key2))
}
case 'default': {
const dataValue = data && key in data ? data[key as K] : undefined
const defaultDataValue = defaultData && key in defaultData ? defaultData[key as K] : undefined
return isEqual(dataValue, defaultDataValue)
}
default: {
return false
}
}
})
.toArray()
}

View File

@@ -1,125 +0,0 @@
import { transformCase } from './strings'
const knownPlurals = {
is: {
singular: 'Is',
plural: 'Are',
},
service: {
singular: 'Service',
plural: 'Services',
},
user: {
singular: 'User',
plural: 'Users',
},
note: {
singular: 'Note',
plural: 'Notes',
},
result: {
singular: 'Result',
plural: 'Results',
},
request: {
singular: 'Request',
plural: 'Requests',
},
something: {
singular: 'Something',
plural: 'Somethings',
},
} as const satisfies Record<
string,
{
singular: string
plural: string
}
>
type KnownPlural = keyof typeof knownPlurals
const synonyms = {
are: 'is',
} as const satisfies Record<string, KnownPlural>
type Synonym = keyof typeof synonyms
export type KnownPluralOrSynonym = KnownPlural | Synonym
function isKnownPlural(key: string): key is KnownPlural {
return key in knownPlurals
}
function isKnownPluralSynonym(key: string): key is Synonym {
return key in synonyms
}
/**
* Formats name into singular or plural form, and case type.
*
* @param entity - Entity name or synonym.
* @param count - Number of entities.
* @param caseType - Case type to apply to the entity name.
*/
export function pluralize<T extends KnownPluralOrSynonym>(
entity: T,
count: number | null = 1,
caseType: Exclude<Parameters<typeof transformCase>[1], 'original'> = 'lower'
) {
return pluralizeGeneric(entity, count, caseType)
}
/**
* Use {@link pluralize} preferably.
*
* Formats name into singular or plural form, and case type.
* If the provided entity is not from the {@link knownPlurals} object, it will return the string with the case type applied.
*
* @param entity - Entity name or synonym.
* @param count - Number of entities.
* @param caseType - Case type to apply to the entity name.
*/
export function pluralizeGeneric<T extends KnownPluralOrSynonym>(
entity: T,
count: number | null,
caseType: Exclude<Parameters<typeof transformCase>[1], 'original'>
): string
export function pluralizeGeneric<T extends string>(
entity: T,
count: number | null,
caseType: Exclude<Parameters<typeof transformCase>[1], 'original'>
): string
export function pluralizeGeneric<T extends string>(
entity: T,
count: number | null = 1,
caseType: Exclude<Parameters<typeof transformCase>[1], 'original'> = 'lower'
): string {
const originalEntity = isKnownPluralSynonym(entity) ? synonyms[entity] : entity
if (!isKnownPlural(originalEntity)) {
console.warn(`getEntityName: Unknown entity "${originalEntity}"`)
return transformCase(originalEntity, caseType)
}
const { singular, plural } = knownPlurals[originalEntity]
return pluralizeAny(singular, plural, count, caseType)
}
/**
* Use {@link pluralize} preferably.
*
* Formats name into singular or plural form, and case type.
*
* @param singular - Singular form of the entity.
* @param plural - Plural form of the entity.
* @param count - Number of entities.
* @param caseType - Case type to apply to the entity name.
*/
export function pluralizeAny(
singular: string,
plural: string,
count: number | null = 1,
caseType: Exclude<Parameters<typeof transformCase>[1], 'original'> = 'lower'
): string {
const name = count === 1 ? singular : plural
return transformCase(name, caseType)
}

View File

@@ -1,57 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { PrismaClient } from '@prisma/client'
import type { Prisma } from '@prisma/client'
const findManyAndCount = {
name: 'findManyAndCount',
model: {
$allModels: {
findManyAndCount<Model, Args>(
this: Model,
args: Prisma.Exact<Args, Prisma.Args<Model, 'findMany'>>
): Promise<
[Prisma.Result<Model, Args, 'findMany'>, number, Args extends { take: number } ? number : undefined]
> {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return prisma.$transaction([
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
(this as any).findMany(args),
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
(this as any).count({ where: (args as any).where }),
]) as any
},
},
},
}
type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
type ModelsWithCustomMethods = {
[Model in keyof PrismaClient]: PrismaClient[Model] extends {
findMany: (...args: any[]) => Promise<any>
}
? PrismaClient[Model] & {
findManyAndCount: FindManyAndCountType
}
: PrismaClient[Model]
}
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
function prismaClientSingleton(): ExtendedPrismaClient {
const prisma = new PrismaClient().$extends(findManyAndCount)
return prisma as unknown as ExtendedPrismaClient
}
declare global {
// eslint-disable-next-line no-var
var prisma: ReturnType<typeof prismaClientSingleton> | undefined
}
export const prisma = global.prisma ?? prismaClientSingleton()
if (process.env.NODE_ENV !== 'production') {
global.prisma = prisma
}

View File

@@ -1,61 +0,0 @@
export function makeLoginUrl(
currentUrl: URL,
{
redirect,
error,
logout,
message,
}: {
redirect?: URL | string | null
error?: string | null
logout?: boolean
message?: string | null
} = {}
) {
const loginUrl = new URL(currentUrl.origin)
loginUrl.pathname = '/account/login'
if (error) {
loginUrl.searchParams.set('error', error)
}
if (logout) {
loginUrl.searchParams.set('logout', 'true')
}
if (message) {
loginUrl.searchParams.set('message', message)
}
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const redirectUrl = new URL(redirect || currentUrl)
if (redirectUrl.pathname === '/account/login') {
const redirectUrlRedirectParam = redirectUrl.searchParams.get('redirect')
if (redirectUrlRedirectParam) {
loginUrl.searchParams.set('redirect', redirectUrlRedirectParam)
}
} else {
loginUrl.searchParams.set('redirect', redirectUrl.toString())
}
return loginUrl.toString()
}
export function makeUnimpersonateUrl(
currentUrl: URL,
{
redirect,
}: {
redirect?: URL | string | null
} = {}
) {
const url = new URL(currentUrl.origin)
url.pathname = '/account/impersonate'
url.searchParams.set('stop', 'true')
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const redirectUrl = new URL(redirect || currentUrl)
url.searchParams.set('redirect', redirectUrl.toString())
return url.toString()
}

View File

@@ -1,69 +0,0 @@
import { randomUUID } from 'node:crypto'
import { deserializeActionResult } from 'astro:actions'
import { z } from 'astro:content'
import { REDIS_ACTIONS_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
import { RedisGenericManager } from './redisGenericManager'
const dataSchema = z.object({
actionName: z.string(),
actionResult: z.union([
z.object({
type: z.literal('data'),
contentType: z.literal('application/json+devalue'),
status: z.literal(200),
body: z.string(),
}),
z.object({
type: z.literal('error'),
contentType: z.literal('application/json'),
status: z.number(),
body: z.string(),
}),
z.object({
type: z.literal('empty'),
status: z.literal(204),
}),
]),
})
class RedisActionsSessions extends RedisGenericManager {
async store(data: z.input<typeof dataSchema>) {
const sessionId = randomUUID()
const parsedData = dataSchema.parse(data)
await this.redisClient.set(`actions-session:${sessionId}`, JSON.stringify(parsedData), {
EX: this.expirationTime,
})
return sessionId
}
async get(sessionId: string | null | undefined) {
if (!sessionId) return null
const key = `actions-session:${sessionId}`
const rawData = await this.redisClient.get(key)
if (!rawData) return null
const data = dataSchema.parse(JSON.parse(rawData))
const deserializedActionResult = deserializeActionResult(data.actionResult)
return {
deserializedActionResult,
...data,
}
}
async delete(sessionId: string | null | undefined) {
if (!sessionId) return
await this.redisClient.del(`actions-session:${sessionId}`)
}
}
export const redisActionsSessions = await RedisActionsSessions.createAndConnect({
expirationTime: REDIS_ACTIONS_SESSION_EXPIRY_SECONDS,
})

View File

@@ -1,45 +0,0 @@
import { REDIS_URL } from 'astro:env/server'
import { createClient } from 'redis'
type RedisGenericManagerOptions = {
expirationTime: number
}
export abstract class RedisGenericManager {
protected redisClient
/** The expiration time of the Redis session. In seconds. */
readonly expirationTime: number
/** @deprecated Use {@link createAndConnect} instead */
constructor(options: RedisGenericManagerOptions) {
this.redisClient = createClient({
url: REDIS_URL,
})
this.expirationTime = options.expirationTime
this.redisClient.on('error', (err) => {
console.error(`[${this.constructor.name}] `, err)
})
}
/** Closes the Redis connection */
async close(): Promise<void> {
await this.redisClient.quit()
}
/** Connects to the Redis connection */
async connect(): Promise<void> {
await this.redisClient.connect()
}
static async createAndConnect<T extends RedisGenericManager>(
this: new (options: RedisGenericManagerOptions) => T,
options: RedisGenericManagerOptions
): Promise<T> {
const instance = new this(options)
await instance.connect()
return instance
}
}

View File

@@ -1,45 +0,0 @@
import { randomUUID } from 'node:crypto'
import { z } from 'astro:content'
import { REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
import { RedisGenericManager } from './redisGenericManager'
const dataSchema = z.object({
adminId: z.number(),
targetId: z.number(),
})
class RedisImpersonationSessions extends RedisGenericManager {
async store(data: z.input<typeof dataSchema>) {
const sessionId = randomUUID()
const parsedData = dataSchema.parse(data)
await this.redisClient.set(`impersonation-session:${sessionId}`, JSON.stringify(parsedData), {
EX: this.expirationTime,
})
return sessionId
}
async get(sessionId: string | null | undefined) {
if (!sessionId) return null
const key = `impersonation-session:${sessionId}`
const rawData = await this.redisClient.get(key)
if (!rawData) return null
return dataSchema.parse(JSON.parse(rawData))
}
async delete(sessionId: string | null | undefined) {
if (!sessionId) return
await this.redisClient.del(`impersonation-session:${sessionId}`)
}
}
export const redisImpersonationSessions = await RedisImpersonationSessions.createAndConnect({
expirationTime: REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS,
})

View File

@@ -1,34 +0,0 @@
import { REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS } from 'astro:env/server'
import { RedisGenericManager } from './redisGenericManager'
class RedisPreGeneratedSecretTokens extends RedisGenericManager {
/**
* Stores a pre-generated token with expiration
* @param token The pre-generated token
*/
async storePreGeneratedToken(token: string): Promise<void> {
await this.redisClient.set(`pregenerated-user-secret-token:${token}`, '1', {
EX: this.expirationTime,
})
}
/**
* Validates and consumes a pre-generated token
* @param token The token to validate
* @returns true if token was valid and consumed, false otherwise
*/
async validateAndConsumePreGeneratedToken(token: string): Promise<boolean> {
const key = `pregenerated-user-secret-token:${token}`
const exists = await this.redisClient.exists(key)
if (exists) {
await this.redisClient.del(key)
return true
}
return false
}
}
export const redisPreGeneratedSecretTokens = await RedisPreGeneratedSecretTokens.createAndConnect({
expirationTime: REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS,
})

View File

@@ -1,78 +0,0 @@
import { randomBytes } from 'crypto'
import { REDIS_USER_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
import { RedisGenericManager } from './redisGenericManager'
class RedisSessions extends RedisGenericManager {
/**
* Generates a random session ID
*/
private generateSessionId(): string {
return randomBytes(32).toString('hex')
}
/**
* Creates a new session for a user
* @param userSecretTokenHash The ID of the user
* @returns The generated session ID
*/
async createSession(userSecretTokenHash: string): Promise<string> {
const sessionId = this.generateSessionId()
// Store the session with user ID
await this.redisClient.set(`session:${sessionId}`, userSecretTokenHash, {
EX: this.expirationTime,
})
// Store session ID in user's sessions set
await this.redisClient.sAdd(`user:${userSecretTokenHash}:sessions`, sessionId)
return sessionId
}
/**
* Gets the user ID associated with a session
* @param sessionId The session ID to look up
* @returns The user ID or null if session not found
*/
async getUserBySessionId(sessionId: string): Promise<string | null> {
const userSecretTokenHash = await this.redisClient.get(`session:${sessionId}`)
return userSecretTokenHash
}
/**
* Deletes all sessions for a user
* @param userSecretTokenHash The ID of the user whose sessions should be deleted
*/
async deleteUserSessions(userSecretTokenHash: string): Promise<void> {
// Get all session IDs for the user
const sessionIds = await this.redisClient.sMembers(`user:${userSecretTokenHash}:sessions`)
if (sessionIds.length > 0) {
// Delete each session
// Delete sessions one by one to avoid type issues with spread operator
for (const sessionId of sessionIds) {
await this.redisClient.del(`session:${sessionId}`)
}
// Delete the set of user's sessions
await this.redisClient.del(`user:${userSecretTokenHash}:sessions`)
}
}
/**
* Deletes a specific session
* @param sessionId The session ID to delete
*/
async deleteSession(sessionId: string): Promise<void> {
const userSecretTokenHash = await this.getUserBySessionId(sessionId)
if (userSecretTokenHash) {
await this.redisClient.del(`session:${sessionId}`)
await this.redisClient.sRem(`user:${userSecretTokenHash}:sessions`, sessionId)
}
}
}
export const redisSessions = await RedisSessions.createAndConnect({
expirationTime: REDIS_USER_SESSION_EXPIRY_SECONDS,
})

View File

@@ -1,10 +0,0 @@
import { SITE_URL } from 'astro:env/client'
import type { Organization } from 'schema-dts'
export const KYCNOTME_SCHEMA_MINI = {
'@type': 'Organization',
name: 'KYCnot.me',
sameAs: SITE_URL,
url: SITE_URL,
} as const satisfies Organization

View File

@@ -1,8 +0,0 @@
import { SEARCH_PARAM_CHARACTERS_NO_ESCAPE } from '../constants/characters'
import { getRandom } from '../lib/arrays'
export const makeSortSeed = () => {
const firstChar = getRandom(SEARCH_PARAM_CHARACTERS_NO_ESCAPE)
const secondChar = getRandom([...SEARCH_PARAM_CHARACTERS_NO_ESCAPE, ''] as const)
return `${firstChar}${secondChar}`
}

View File

@@ -1,68 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Normalize a string by removing accents and converting it to lowercase.
*
* @example
* normalize(' Café') // 'cafe'
*/
const normalize = (str: string): string => {
return str
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.trim()
}
/**
* Compare two strings after normalizing them.
*/
export const areSameNormalized = (str1: string, str2: string): boolean => {
return normalize(str1) === normalize(str2)
}
export type TransformCaseType = 'lower' | 'original' | 'sentence' | 'title' | 'upper'
/**
* Transform a string to a different case.
*
* @example
* transformCase('hello WORLD', 'lower') // 'hello world'
* transformCase('hello WORLD', 'upper') // 'HELLO WORLD'
* transformCase('hello WORLD', 'sentence') // 'Hello world'
* transformCase('hello WORLD', 'title') // 'Hello World'
* transformCase('hello WORLD', 'original') // 'hello WORLD'
*/
export const transformCase = <T extends string, C extends TransformCaseType>(
str: T,
caseType: C
): C extends 'lower'
? Lowercase<T>
: C extends 'upper'
? Uppercase<T>
: C extends 'sentence'
? Capitalize<Lowercase<T>>
: C extends 'title'
? Capitalize<Lowercase<T>>
: T => {
switch (caseType) {
case 'lower':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return str.toLowerCase() as any
case 'upper':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return str.toUpperCase() as any
case 'sentence':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return (str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()) as any
case 'title':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return str
.split(' ')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ') as any
case 'original':
default:
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return str as any
}
}

View File

@@ -1,49 +0,0 @@
import { addDays, format, isBefore, isToday, isYesterday } from 'date-fns'
import TimeAgo from 'javascript-time-ago'
import en from 'javascript-time-ago/locale/en'
import { transformCase, type TransformCaseType } from './strings'
TimeAgo.addDefaultLocale(en)
export const timeAgo = new TimeAgo('en-US')
export type FormatDateShortOptions = {
prefix?: boolean
hourPrecision?: boolean
daysUntilDate?: number | null
caseType?: TransformCaseType
hoursShort?: boolean
}
export function formatDateShort(
date: Date,
{
prefix = true,
hourPrecision = true,
daysUntilDate = null,
caseType,
hoursShort = false,
}: FormatDateShortOptions = {}
) {
const text = (() => {
if (isToday(date)) {
if (hourPrecision) return timeAgo.format(date, hoursShort ? 'twitter-minute-now' : 'round-minute')
return 'today'
}
if (isYesterday(date)) return 'yesterday'
if (daysUntilDate && isBefore(date, addDays(new Date(), daysUntilDate))) {
return timeAgo.format(date, 'round-minute')
}
const currentYear = new Date().getFullYear()
const dateYear = date.getFullYear()
const formattedDate = dateYear === currentYear ? format(date, 'MMM d') : format(date, 'MMM d, yyyy')
return prefix ? `on ${formattedDate}` : formattedDate
})()
if (!caseType) return text
return transformCase(text, caseType)
}

View File

@@ -1,6 +0,0 @@
import crypto from 'crypto'
// Generate a 32-byte secret key once when the module is first loaded
const timeTrapSecretKey = crypto.randomBytes(32)
export { timeTrapSecretKey }

View File

@@ -1,115 +0,0 @@
import { escapeRegExp } from 'lodash-es'
export const createPageUrl = (
page: number,
currentUrl: URL | string,
otherParams?: Record<string, string | null | undefined> | URLSearchParams
) => {
const url = new URL(currentUrl)
if (otherParams) {
if (otherParams instanceof URLSearchParams) {
otherParams.forEach((value, key) => {
url.searchParams.set(key, value)
})
} else {
Object.entries(otherParams).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
url.searchParams.set(key, value)
}
})
}
}
url.searchParams.set('page', page.toString())
return url.toString()
}
export function urlParamsToFormData(params: URLSearchParams) {
const formData = new FormData()
params.forEach((value, key) => {
formData.append(key, value)
})
return formData
}
export function urlParamsToObject(params: URLSearchParams) {
return Object.fromEntries(params.entries())
}
export function urlWithParams(
url: URL | string,
params: Record<string, number[] | string[] | number | string | null | undefined>,
{ clearExisting }: { clearExisting?: boolean } = { clearExisting: false }
) {
const urlObj = new URL(url)
if (clearExisting) {
const keysToDelete = Array.from(urlObj.searchParams.keys())
keysToDelete.forEach((key) => {
urlObj.searchParams.delete(key)
})
}
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => {
urlObj.searchParams.append(key, String(v))
})
} else if (value === null || value === undefined) {
urlObj.searchParams.delete(key)
} else {
urlObj.searchParams.set(key, String(value))
}
})
return urlObj.toString()
}
export function makeObjectSearchParamKeyRegex(key: string) {
return new RegExp(`^${escapeRegExp(key)}-(.*)$`)
}
/**
* Parses the value of an object from a URL with zod. Assuming this format: `key[subkey]=value`
*
* Returns an object with the keys as the subkeys and the values as the values.
* Or `undefined` if there are no subkeys.
*
* If there is no subkey (`key=value`), the subkey is set to an empty string.
*
* @example
* ```ts
* const searchParams = new URLSearchParams('tag-en=include&tag-fr=exclude&tag-es=')
* const value = getObjectSearchParam(searchParams, 'tag')
* // value: { en: 'include', fr: 'exclude'}
* ```
*/
export function getObjectSearchParam(
params: URLSearchParams,
key: string,
{
ignoreEmptyValues = true,
emptyObjectBecomesUndefined = true,
}: {
ignoreEmptyValues?: boolean
emptyObjectBecomesUndefined?: boolean
} = {}
) {
const keyPattern = makeObjectSearchParamKeyRegex(key)
const entries = Array.from(params.entries()).flatMap(([paramKey, paramValue]) => {
if (ignoreEmptyValues && paramValue === '') return []
if (paramKey === key) return [['', paramValue]] as const
const subKey = paramKey.match(keyPattern)?.[1]
if (subKey === undefined) return []
return [[subKey, paramValue]] as const
})
if (entries.length === 0) return emptyObjectBecomesUndefined ? undefined : {}
return Object.fromEntries(entries)
}
export function urlDomain(url: URL | string) {
if (typeof url === 'string') {
return url.replace(/^(https?:\/\/)?(www\.)?/, '').replace(/\/(index\.html)?$/, '')
}
return url.origin
}

View File

@@ -1,80 +0,0 @@
import { stopImpersonating } from './impersonation'
import { prisma } from './prisma'
import { redisSessions } from './redis/redisSessions'
import type { APIContext, AstroCookies, AstroCookieSetOptions } from 'astro'
const COOKIE_NAME = 'user_session_id'
const COOKIE_MAX_AGE = 60 * 60 * 24 * 7 // 1 week
const defaultCookieOptions = {
path: '/',
secure: true,
httpOnly: true,
sameSite: 'strict',
maxAge: COOKIE_MAX_AGE,
} as const satisfies AstroCookieSetOptions
export function getUserSessionIdCookie(cookies: AstroCookies) {
return cookies.get(COOKIE_NAME)?.value
}
export async function getUserFromCookies(cookies: AstroCookies) {
const userSessionId = getUserSessionIdCookie(cookies)
if (!userSessionId) return null
const userSecretTokenHash = await redisSessions.getUserBySessionId(userSessionId)
if (!userSecretTokenHash) return null
return prisma.user.findFirst({
where: {
secretTokenHash: userSecretTokenHash,
},
})
}
export async function setUserSessionIdCookie(
cookies: AstroCookies,
userSecretTokenHash: string,
options: AstroCookieSetOptions = {}
) {
const sessId = await redisSessions.createSession(userSecretTokenHash)
cookies.set(COOKIE_NAME, sessId, {
...defaultCookieOptions,
...options,
})
}
export async function removeUserSessionIdCookie(cookies: AstroCookies) {
const sessionId = cookies.get(COOKIE_NAME)?.value
if (sessionId) {
await redisSessions.deleteSession(sessionId)
}
cookies.delete(COOKIE_NAME, { path: '/' })
}
export async function logout(context: Pick<APIContext, 'cookies' | 'locals'>) {
await stopImpersonating(context)
await removeUserSessionIdCookie(context.cookies)
context.locals.user = null
context.locals.actualUser = null
}
export async function login(
context: Pick<APIContext, 'cookies' | 'locals'>,
user: NonNullable<APIContext['locals']['user']>
) {
await stopImpersonating(context)
await setUserSessionIdCookie(context.cookies, user.secretTokenHash)
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() },
})
context.locals.user = user
context.locals.actualUser = null
}

View File

@@ -1,149 +0,0 @@
import crypto from 'crypto'
import { z } from 'astro/zod'
import { escapeRegExp } from 'lodash-es'
import {
DIGIT_CHARACTERS,
LOWERCASE_CONSONANT_CHARACTERS,
LOWERCASE_VOWEL_CHARACTERS,
} from '../constants/characters'
import { getRandom, typedJoin } from './arrays'
import { DEPLOYMENT_MODE } from './envVariables'
import { transformCase } from './strings'
const DIGEST = 'sha512'
const USER_SECRET_TOKEN_LETTERS_SEGMENT_REGEX =
`(?:(?:[${typedJoin(LOWERCASE_VOWEL_CHARACTERS)}${transformCase(typedJoin(LOWERCASE_VOWEL_CHARACTERS), 'upper')}][${typedJoin(LOWERCASE_CONSONANT_CHARACTERS)}${transformCase(typedJoin(LOWERCASE_CONSONANT_CHARACTERS), 'upper')}]){2})` as const
const USER_SECRET_TOKEN_DIGITS_SEGMENT_REGEX = `(?:[${typedJoin(DIGIT_CHARACTERS)}]{4})` as const
const USER_SECRET_TOKEN_SEPARATOR_REGEX = '(?:(-| )+)'
export const includeDevUsers = DEPLOYMENT_MODE !== 'production'
const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => {
const specialUsersData = [
{
envToken: 'DEV_ADMIN_USER_SECRET_TOKEN',
defaultToken: 'admin',
},
{
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN',
defaultToken: 'verifier',
},
{
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
defaultToken: 'verified',
},
{
envToken: 'DEV_NORMAL_USER_SECRET_TOKEN',
defaultToken: 'normal',
},
{
envToken: 'DEV_SPAM_USER_SECRET_TOKEN',
defaultToken: 'spam',
},
] as const satisfies {
envToken: string
defaultToken: string
}[]
const env =
// This file can also be called from faker.ts, where import.meta.env is not available
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(import.meta.env
? Object.fromEntries(specialUsersData.map(({ envToken }) => [envToken, import.meta.env[envToken]]))
: undefined) ?? process.env
return `(?:${typedJoin(
specialUsersData.map(({ envToken, defaultToken }) =>
typedJoin(
((env[envToken] as string | undefined) ?? defaultToken).match(/(.{4}|.{1,3}$)/g)?.map(
(segment) =>
`${segment
.split('')
.map((char) => `(?:${escapeRegExp(char.toUpperCase())}|${escapeRegExp(char.toLowerCase())})`)
.join('')}${USER_SECRET_TOKEN_SEPARATOR_REGEX}?`
) ?? []
)
),
'|'
)})` as const
})()
const USER_SECRET_TOKEN_FULL_REGEX_STRING =
`(?:(?:${USER_SECRET_TOKEN_LETTERS_SEGMENT_REGEX}${USER_SECRET_TOKEN_SEPARATOR_REGEX}?){4}${USER_SECRET_TOKEN_DIGITS_SEGMENT_REGEX})` as const
export const USER_SECRET_TOKEN_REGEX_STRING =
`^(?:${USER_SECRET_TOKEN_FULL_REGEX_STRING}${includeDevUsers ? `|${USER_SECRET_TOKEN_DEV_USERS_REGEX}` : ''})$` as const
export const USER_SECRET_TOKEN_REGEX = new RegExp(USER_SECRET_TOKEN_REGEX_STRING)
export const userSecretTokenZodSchema = z
.string()
.regex(USER_SECRET_TOKEN_REGEX)
.transform(parseUserSecretToken)
export function generateUserSecretToken(): string {
const token = [
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(LOWERCASE_VOWEL_CHARACTERS),
getRandom(LOWERCASE_CONSONANT_CHARACTERS),
getRandom(DIGIT_CHARACTERS),
getRandom(DIGIT_CHARACTERS),
getRandom(DIGIT_CHARACTERS),
getRandom(DIGIT_CHARACTERS),
].join('')
return parseUserSecretToken(token)
}
export function hashUserSecretToken(token: string): string {
return crypto.createHash(DIGEST).update(token).digest('hex')
}
export function parseUserSecretToken(token: string): string {
if (!USER_SECRET_TOKEN_REGEX.test(token)) {
throw new Error(
`Invalid user secret token. Token "${token}" does not match regex ${USER_SECRET_TOKEN_REGEX_STRING}`
)
}
return token.toLocaleLowerCase().replace(new RegExp(USER_SECRET_TOKEN_SEPARATOR_REGEX, 'g'), '')
}
export function prettifyUserSecretToken(token: string): string {
const parsedToken = parseUserSecretToken(token)
const groups = parsedToken.toLocaleUpperCase().match(/.{4}/g)
if (!groups || groups.length !== 5) {
throw new Error('Error while prettifying user secret token')
}
return groups.join('-')
}
/**
* Verify a token against a stored hash using a constant-time comparison
*/
export function verifyUserSecretToken(token: string, hash: string): boolean {
const correctHash = hashUserSecretToken(token)
// Use crypto.timingSafeEqual to prevent timing attacks
return crypto.timingSafeEqual(Buffer.from(correctHash, 'hex'), Buffer.from(hash, 'hex'))
}

View File

@@ -1,72 +0,0 @@
import { z, type ZodTypeAny } from 'astro/zod'
import { round } from 'lodash-es'
const addZodPipe = (schema: ZodTypeAny, zodPipe?: ZodTypeAny) => {
return zodPipe ? schema.pipe(zodPipe) : schema
}
/**
* The difference between this and `z.coerce.number()` is that an empty string won't be coerced to 0.
*
* If you don't accept 0, just use `z.coerce.number().int().positive()` instead.
*/
export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
addZodPipe(z.number().or(z.string().nonempty()), zodPipe)
export const zodUrlOptionalProtocol = z.preprocess(
(input) => {
if (typeof input !== 'string') return input
const trimmedVal = input.trim()
return !/^\w+:\/\//i.test(trimmedVal) ? `https://${trimmedVal}` : trimmedVal
},
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
message: 'Invalid URL',
})
)
const stringToArrayFactory = (delimiter: RegExp | string = ',') => {
return <T>(input: T) =>
typeof input !== 'string'
? (input ?? undefined)
: input
.split(delimiter)
.map((item) => item.trim())
.filter((item) => item !== '')
}
export const stringListOfUrlsSchema = z.preprocess(
stringToArrayFactory(/[\s,\n]+/),
z.array(zodUrlOptionalProtocol).default([])
)
export const stringListOfUrlsSchemaRequired = z.preprocess(
stringToArrayFactory(/[\s,\n]+/),
z.array(zodUrlOptionalProtocol).min(1)
)
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5MB
export const ACCEPTED_IMAGE_TYPES = [
'image/svg+xml',
'image/png',
'image/jpeg',
'image/jxl',
'image/avif',
'image/webp',
] as const satisfies string[]
export const imageFileSchema = z
.instanceof(File)
.optional()
.nullable()
.transform((file) => (!file || file.size === 0 || !file.name ? undefined : file))
.refine(
(file) => !file || file.size <= MAX_IMAGE_SIZE,
`Max image size is ${round(MAX_IMAGE_SIZE / 1024 / 1024, 3).toLocaleString()}MB.`
)
.refine(
(file) => !file || ACCEPTED_IMAGE_TYPES.some((type) => file.type === type),
'Only SVG, PNG, JPG, JPEG XL, AVIF, WebP formats are supported.'
)
export const imageFileSchemaRequired = imageFileSchema.refine((file) => !!file, 'Required')