Release 2025-05-19
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>,
|
||||
})
|
||||
}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
/** A string containing Markdown. */
|
||||
export type MarkdownString = string
|
||||
|
||||
/** A string containing HTML. */
|
||||
export type HtmlString = string
|
||||
@@ -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 }))
|
||||
)
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
@@ -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
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user