Prepare production and Coolify deployment with original exports and datetime pickers

This commit is contained in:
2026-09-09 17:59:20 -04:00
parent 574f29a68e
commit 64d7acc9c0
38 changed files with 1296 additions and 8 deletions
+9
View File
@@ -0,0 +1,9 @@
**/node_modules
**/.next
**/.turbo
.git
**/.env*
**/*.log
**/*.tsbuildinfo
.secrets
coverage
+21
View File
@@ -0,0 +1,21 @@
# Copy to .env.production; never commit the filled-in file.
# Use a long random URL-safe password (e.g. hexadecimal).
POSTGRES_PASSWORD=
NEXT_PUBLIC_APP_URL=https://photos.example.com
BETTER_AUTH_URL=https://photos.example.com
BETTER_AUTH_SECRET=
EMAIL_PROVIDER=resend
EMAIL_FROM=Manyangles <photos@example.com>
RESEND_API_KEY=
RESEND_WEBHOOK_SECRET=
S3_ENDPOINT=https://s3.example.com
S3_PUBLIC_ENDPOINT=https://s3.example.com
S3_REGION=us-east-1
S3_BUCKET=manyangles
S3_ACCESS_KEY=
S3_SECRET_KEY=
S3_FORCE_PATH_STYLE=true
AUTHENTIK_ISSUER=
AUTHENTIK_CLIENT_ID=
AUTHENTIK_CLIENT_SECRET=
WORKER_CONCURRENCY=1
+1
View File
@@ -4,6 +4,7 @@ node_modules
dist
.env
.env.local
.env.production
*.log
*.tsbuildinfo
coverage
+37
View File
@@ -0,0 +1,37 @@
FROM oven/bun:1.4.0 AS source
WORKDIR /app
COPY package.json bun.lock ./
COPY apps/web/package.json apps/web/package.json
COPY apps/worker/package.json apps/worker/package.json
COPY packages/contracts/package.json packages/contracts/package.json
COPY packages/database/package.json packages/database/package.json
COPY packages/email/package.json packages/email/package.json
COPY packages/storage/package.json packages/storage/package.json
RUN bun install --frozen-lockfile --network-concurrency 8
COPY . .
FROM source AS builder
ARG NEXT_PUBLIC_APP_URL
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL NEXT_TELEMETRY_DISABLED=1
# Build-only placeholders; never copy production secrets into image layers.
RUN DATABASE_URL=postgres://build:build@127.0.0.1:5432/build BETTER_AUTH_SECRET=build-only-placeholder-secret-not-for-runtime BETTER_AUTH_URL=$NEXT_PUBLIC_APP_URL bun run --filter @album/web build
FROM oven/bun:1.4.0 AS web
WORKDIR /app
ENV NODE_ENV=production HOSTNAME=0.0.0.0 PORT=3000 NEXT_TELEMETRY_DISABLED=1
COPY --from=builder --chown=bun:bun /app/apps/web/.next/standalone ./
COPY --from=builder --chown=bun:bun /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=bun:bun /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=bun:bun /app/scripts/check-production-env.ts ./scripts/check-production-env.ts
USER bun
EXPOSE 3000
CMD ["sh", "-c", "bun scripts/check-production-env.ts && exec bun apps/web/server.js"]
FROM source AS worker
ENV NODE_ENV=production
USER bun
CMD ["sh", "-c", "bun scripts/check-production-env.ts && exec bun apps/worker/src/index.ts"]
FROM worker AS migrate
WORKDIR /app/packages/database
CMD ["bun", "src/migrate.ts"]
+4 -1
View File
@@ -22,7 +22,7 @@ packages/
## Quick start
Requirements: Bun 1.3+ and Docker.
Requirements: Bun 1.4+ and Docker.
```bash
cp .env.example .env
@@ -49,6 +49,9 @@ Example accounts (password `host`, admin password `admin`):
## Authentik sign-in
Production Docker/Compose setup and original-quality exports are documented in
[the deployment guide](docs/deployment.md).
Manyangles can use an Authentik OAuth2/OpenID Connect provider alongside email and
password authentication. In Authentik, create a confidential OAuth2/OpenID
provider and application with the `openid`, `profile`, and `email` scopes.
+3
View File
@@ -15,6 +15,7 @@
"@album/database": "workspace:*",
"@album/email": "workspace:*",
"@album/storage": "workspace:*",
"@radix-ui/react-popover": "^1.1.23",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.4.3",
"@trpc/react-query": "^11.4.3",
@@ -23,6 +24,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.2.6",
"date-fns": "^4.4.0",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.45.2",
"lucide-react": "^0.468.0",
@@ -30,6 +32,7 @@
"next-themes": "^0.4.6",
"radix-ui": "^1.6.7",
"react": "19.2.8",
"react-day-picker": "^9",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"shadcn": "^4.21.0",
+1
View File
@@ -0,0 +1 @@
+3
View File
@@ -0,0 +1,3 @@
export function GET() {
return Response.json({ status: "ok" });
}
@@ -0,0 +1,30 @@
"use client";
import { useState } from "react";
import { Download } from "lucide-react";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { toast } from "sonner";
export function EventExports({eventId}:{eventId:string}) {
const [filter,setFilter] = useState<"approved"|"all">("approved");
const history = api.manager.exports.useQuery({eventId},{refetchInterval:3000});
const request = api.manager.requestExport.useMutation({onSuccess:()=>{toast.success("Originals export queued");void history.refetch();},onError:e=>toast.error(e.message)});
const download = api.manager.downloadExport.useMutation({onSuccess:result=>{window.location.assign(result.url);},onError:e=>toast.error(e.message)});
return <section className="flex flex-col gap-3" aria-label="Export originals">
<h2 className="text-lg font-semibold">Export originals</h2>
<p className="text-sm text-muted-foreground">Full-quality files, unchangedincluding original metadata. ZIPs expire after 24 hours. Includes processed gallery photos, not standalone banners. Maximum 10,000 photos / 20 GB per export.</p>
<div className="flex flex-wrap items-center gap-2">
<Select value={filter} onValueChange={value=>setFilter(value as "approved"|"all")}>
<SelectTrigger aria-label="Photos to export"><SelectValue /></SelectTrigger>
<SelectContent><SelectGroup><SelectItem value="approved">Approved photos</SelectItem><SelectItem value="all">All photos, including private</SelectItem></SelectGroup></SelectContent>
</Select>
<Button variant="outline" disabled={request.isPending || history.data?.some(job=>job.status==="pending"||job.status==="processing")} onClick={()=>request.mutate({eventId,filter})}><Download data-icon="inline-start" />Export originals</Button>
</div>
{history.isError ? <p role="alert">Could not load exports.</p> : null}
<ul className="flex flex-col gap-2" aria-live="polite">{history.data?.map(job=><li key={job.id} className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm">{job.filter==="all"?"All photos":"Approved photos"} · {job.status.charAt(0).toUpperCase()+job.status.slice(1)}{job.total>0?` · ${job.processed}/${job.total}`:""}{job.status==="failed"?" — empty album, size limit, or processing error; try a new export.":""}</span>
{job.status==="ready" ? <Button variant="outline" disabled={download.isPending} onClick={()=>download.mutate({eventId,exportId:job.id})}><Download data-icon="inline-start" />Download ZIP</Button> : null}
</li>)}</ul>
</section>;
}
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { api } from "@/trpc/react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { DateTimePicker } from "@/components/ui/date-time-picker";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
import { Card, CardHeader, CardTitle, CardDescription, CardContent } from "@/components/ui/card";
@@ -36,7 +36,7 @@ export function EventSchedule({ eventId }: { eventId: string }) {
}}>
<FieldGroup className="grid gap-4 sm:grid-cols-2">
{dateFields.map(([key, label]) => <Field key={key}><FieldLabel htmlFor={`schedule-${key}`}>{label}</FieldLabel>
<Input id={`schedule-${key}`} type="datetime-local" value={dates[key] ?? localDate(event.data![key])} onChange={(e) => setDates({ ...dates, [key]: e.target.value })} />
<DateTimePicker id={`schedule-${key}`} ariaLabel={label} disabled={update.isPending} value={dates[key] ?? localDate(event.data![key])} onValueChange={(value) => setDates(previous => ({ ...previous, [key]: value }))} />
</Field>)}
</FieldGroup>
<p className="text-sm text-muted-foreground">Setting a future public date hides the guest page until then. Gallery and note schedules never approve existing private content. Submission closing ends new contributions; it does not hide the gallery. Dates alone do not send email.</p>
@@ -11,6 +11,7 @@ import { EventAudit } from "./event-audit";
import { EventSchedule } from "./event-schedule";
import { eventStatusLabel } from "@/lib/event-status";
import { effectiveEvent } from "@/lib/event-lifecycle";
import { EventExports } from "./event-exports";
export default async function EventDashboardPage({
params,
@@ -37,12 +38,15 @@ export default async function EventDashboardPage({
id: "photos",
label: "Photos",
content: (
<div className="flex flex-col gap-6">
{canSettings && event.permissions.includes("photos.private.read") ? <EventExports eventId={event.id} /> : null}
<ModerationGrid
eventId={event.id}
canModerate={canModerate}
canDelete={event.permissions.includes("photos.delete")}
canPrivate={event.permissions.includes("photos.private.read")}
/>
</div>
),
},
...(canSettings
+176
View File
@@ -0,0 +1,176 @@
"use client";
import * as React from "react";
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react";
import { DayButton, DayPicker, getDefaultClassNames } from "react-day-picker";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"];
}) {
const defaults = getDefaultClassNames();
return (
<DayPicker
showOutsideDays={showOutsideDays}
captionLayout={captionLayout}
className={cn(
"group/calendar p-3",
className,
)}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaults.root),
months: cn("relative flex flex-col gap-4 md:flex-row", defaults.months),
month: cn("flex w-[15.75rem] flex-col gap-4", defaults.month),
nav: cn(
"absolute inset-x-0 top-0 flex items-center justify-between",
defaults.nav,
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-9 p-0",
defaults.button_previous,
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-9 p-0",
defaults.button_next,
),
month_caption: cn(
"flex h-9 items-center justify-center px-9",
defaults.month_caption,
),
caption_label: cn(
"select-none text-sm font-bold",
captionLayout !== "label" &&
"flex h-8 items-center gap-1 rounded-md pl-2 pr-1",
defaults.caption_label,
),
dropdowns: cn(
"flex h-9 items-center justify-center gap-1.5 text-sm font-medium",
defaults.dropdowns,
),
dropdown_root: cn(
"relative rounded-md border border-input focus-within:ring-2 focus-within:ring-ring",
defaults.dropdown_root,
),
dropdown: cn(
"absolute inset-0 bg-popover opacity-0",
defaults.dropdown,
),
month_grid: cn("w-full border-collapse", defaults.month_grid),
weekdays: cn("grid grid-cols-7", defaults.weekdays),
weekday: cn(
"flex size-9 select-none items-center justify-center rounded-md text-center text-xs font-semibold text-muted-foreground",
defaults.weekday,
),
week: cn("mt-2 grid grid-cols-7", defaults.week),
day: cn(
"relative size-9 p-0 text-center",
defaults.day,
),
today: cn(
"rounded-md bg-accent text-accent-foreground",
defaults.today,
),
outside: cn(
"text-muted-foreground opacity-50",
defaults.outside,
),
disabled: cn(
"pointer-events-none text-muted-foreground opacity-40",
defaults.disabled,
),
hidden: cn("invisible", defaults.hidden),
range_start: cn("rounded-l-md bg-accent", defaults.range_start),
range_middle: cn("rounded-none bg-accent", defaults.range_middle),
range_end: cn("rounded-r-md bg-accent", defaults.range_end),
...classNames,
}}
components={{
Chevron: ({ className: iconClassName, orientation, ...iconProps }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon
className={cn("size-4", iconClassName)}
{...iconProps}
/>
);
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", iconClassName)}
{...iconProps}
/>
);
}
return (
<ChevronDownIcon
className={cn("size-4", iconClassName)}
{...iconProps}
/>
);
},
DayButton: CalendarDayButton,
...components,
}}
{...props}
/>
);
}
export function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const ref = React.useRef<HTMLButtonElement>(null);
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus();
}, [modifiers.focused]);
return (
<button
ref={ref}
type="button"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
buttonVariants({ variant: "ghost", size: "icon" }),
"size-9 rounded-md p-0 font-medium data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-middle=true]:rounded-none data-[range-middle=true]:bg-accent data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground",
className,
)}
{...props}
/>
);
}
+100
View File
@@ -0,0 +1,100 @@
"use client";
import * as React from "react";
import { CalendarIcon, X } from "lucide-react";
import { format, isValid, parse } from "date-fns";
import { Button } from "@/components/ui/button";
import { Calendar } from "@/components/ui/calendar";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
function parseFormDate(value?: string) {
if (!value) return undefined;
const date = parse(value, "yyyy-MM-dd", new Date());
return isValid(date) ? date : undefined;
}
export function DatePicker({
name,
id,
defaultValue,
value: controlledValue,
onValueChange,
placeholder = "Pick a date",
"aria-label": ariaLabel = "Select date",
disabled,
clearable = true,
className,
}: {
name?: string;
id?: string;
defaultValue?: string;
value?: string;
onValueChange?: (value: string) => void;
placeholder?: string;
"aria-label"?: string;
disabled?: boolean;
clearable?: boolean;
className?: string;
}) {
const [internalValue, setInternalValue] = React.useState(defaultValue ?? "");
const [open, setOpen] = React.useState(false);
const value = controlledValue ?? internalValue;
const date = parseFormDate(value);
function update(next: Date | undefined) {
const nextValue = next ? format(next, "yyyy-MM-dd") : "";
if (controlledValue === undefined) setInternalValue(nextValue);
onValueChange?.(nextValue);
setOpen(false);
}
return (
<div className={cn("flex min-w-0 gap-1", className)}>
{name ? <input type="hidden" name={name} value={value} /> : null}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
id={id}
variant="outline"
disabled={disabled}
aria-label={ariaLabel}
data-empty={!date}
className="min-w-0 flex-1 justify-start px-3 text-left font-medium data-[empty=true]:text-muted-foreground"
>
<CalendarIcon data-icon="inline-start" />
<span className="truncate">
{date ? format(date, "PPP") : placeholder}
</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-0">
<Calendar
mode="single"
selected={date}
onSelect={update}
defaultMonth={date}
autoFocus
/>
</PopoverContent>
</Popover>
{clearable && value ? (
<Button
type="button"
variant="outline"
size="icon"
disabled={disabled}
aria-label={`Clear ${ariaLabel.toLowerCase()}`}
onClick={() => update(undefined)}
>
<X data-icon="inline-start" />
</Button>
) : null}
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
import { format, isValid, parse } from "date-fns";
import { CalendarIcon, ChevronDown } from "lucide-react";
import { Calendar } from "@/components/ui/calendar";
import { TimeFields, formatTimeValue } from "@/components/ui/time-picker";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
function parseDate(value: string) {
const date = parse(value, "yyyy-MM-dd", new Date(2000, 0, 1));
return isValid(date) ? date : undefined;
}
// One popup with a draft: dismissing never modifies the controlled field.
export function DateTimePicker({id,value,onValueChange,disabled,ariaLabel="Date and time",className}:{
id:string;
value:string;
onValueChange:(value:string)=>void;
disabled?:boolean;
ariaLabel?:string;
className?:string;
}) {
const [open, setOpen] = useState(false);
const [draftDate, setDraftDate] = useState("");
const [draftTime, setDraftTime] = useState("19:00");
const selected = parseDate(value.slice(0,10));
const draft = parseDate(draftDate);
return <Popover open={open} onOpenChange={next => {
if (next) { setDraftDate(value.slice(0,10)); setDraftTime(value.slice(11,16)||"19:00"); }
setOpen(next);
}}>
<div className={cn("flex min-w-0 items-center gap-2", className)}>
<PopoverTrigger asChild>
<Button id={id} type="button" variant="outline" disabled={disabled} aria-label={ariaLabel} className="min-w-0 flex-1 justify-between">
<CalendarIcon data-icon="inline-start" />
<span className="flex-1 truncate text-left">{selected ? `${format(selected,"PP")} · ${formatTimeValue(value.slice(11,16))}` : "Pick date & time"}</span>
<ChevronDown data-icon="inline-end" />
</Button>
</PopoverTrigger>
<Button type="button" variant="ghost" disabled={disabled || !value} aria-label={`Clear ${ariaLabel.toLowerCase()}`} onClick={() => { onValueChange(""); setOpen(false); }}>Clear</Button>
</div>
<PopoverContent align="start" collisionPadding={12} aria-label={`${ariaLabel}: choose date and time`} className="flex w-[18rem] max-w-[calc(100vw-2rem)] flex-col gap-3 overflow-y-auto p-3 max-h-[var(--radix-popover-content-available-height)]">
<Calendar mode="single" selected={draft} defaultMonth={draft} onSelect={date=>setDraftDate(date?format(date,"yyyy-MM-dd"):"")} className="self-center p-0" classNames={{ month: "flex w-[15.75rem] flex-col gap-1", week: "grid grid-cols-7" }} />
<Separator />
<TimeFields id={`${id}-time`} value={draftTime} onValueChange={setDraftTime} />
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">Device timezone</p>
<Button type="button" disabled={!draft} onClick={()=>{onValueChange(`${draftDate}T${draftTime}`);setOpen(false);}}>Apply</Button>
</div>
</PopoverContent>
</Popover>;
}
+34
View File
@@ -0,0 +1,34 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverAnchor = PopoverPrimitive.Anchor
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-lg border bg-card p-4 text-card-foreground shadow-xl outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-popover-content-transform-origin]",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
@@ -0,0 +1,13 @@
import {expect,test} from "bun:test";
import {formatTimeValue,parseTimeValue,toTimeValue} from "./time-picker";
test("RaceTix time picker preserves midnight, noon and non-five-minute values",()=>{
expect(formatTimeValue("00:00")).toBe("12:00 AM");
expect(formatTimeValue("12:00")).toBe("12:00 PM");
expect(formatTimeValue("23:57")).toBe("11:57 PM");
for(let h=0;h<24;h++) for(const m of ["00","07","30","59"]) {
const value=`${String(h).padStart(2,"0")}:${m}`;
const parsed=parseTimeValue(value);
expect(toTimeValue(parsed.hour,parsed.minute,parsed.period)).toBe(value);
}
});
+130
View File
@@ -0,0 +1,130 @@
"use client";
import * as React from "react";
import { Clock3, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
type SelectFieldOption = {value:string;label:string};
function SelectField({id,ariaLabel,value,options,onValueChange}:{id:string;ariaLabel:string;value:string;options:SelectFieldOption[];onValueChange:(value:string)=>void}) {
return <Select value={value} onValueChange={onValueChange}><SelectTrigger id={id} aria-label={ariaLabel} className="w-full"><SelectValue /></SelectTrigger><SelectContent><SelectGroup>{options.map(option=><SelectItem key={option.value} value={option.value}>{option.label}</SelectItem>)}</SelectGroup></SelectContent></Select>;
}
import { cn } from "@/lib/utils";
type Period = "AM" | "PM";
export function parseTimeValue(value: string) {
const match = /^(\d{2}):(\d{2})$/.exec(value);
if (!match) return { hour: "7", minute: "00", period: "PM" as Period };
const hours = Number(match[1]);
const minutes = Number(match[2]);
if (hours > 23 || minutes > 59) {
return { hour: "7", minute: "00", period: "PM" as Period };
}
return {
hour: String(hours % 12 || 12),
minute: String(minutes).padStart(2, "0"),
period: (hours >= 12 ? "PM" : "AM") as Period,
};
}
export function toTimeValue(hour: string, minute: string, period: Period) {
const hourNumber = Number(hour) % 12;
const hours = period === "PM" ? hourNumber + 12 : hourNumber;
return `${String(hours).padStart(2, "0")}:${minute.padStart(2, "0")}`;
}
export function formatTimeValue(value: string) {
const { hour, minute, period } = parseTimeValue(value);
return `${hour}:${minute} ${period}`;
}
const hourOptions = Array.from({ length: 12 }, (_, index) => ({ value: String(index + 1), label: String(index + 1) }));
const standardMinutes = Array.from({ length: 12 }, (_, index) => String(index * 5).padStart(2, "0"));
export function TimeFields({ id, value, onValueChange }: {
id: string;
value: string;
onValueChange: (value: string) => void;
}) {
const { hour, minute, period } = parseTimeValue(value);
const minutes = standardMinutes.includes(minute) ? standardMinutes : [...standardMinutes, minute].sort();
return (
<FieldGroup className="flex-row gap-2">
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor={`${id}-hour`}>Hour</FieldLabel>
<SelectField id={`${id}-hour`} ariaLabel="Hour" value={hour} options={hourOptions} onValueChange={next => onValueChange(toTimeValue(next, minute, period))} />
</Field>
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor={`${id}-minute`}>Minute</FieldLabel>
<SelectField id={`${id}-minute`} ariaLabel="Minute" value={minute} options={minutes.map(value => ({ value, label: value }))} onValueChange={next => onValueChange(toTimeValue(hour, next, period))} />
</Field>
<Field className="min-w-0 flex-1">
<FieldLabel htmlFor={`${id}-period`}>Period</FieldLabel>
<SelectField id={`${id}-period`} ariaLabel="AM or PM" value={period} options={[{ value: "AM", label: "AM" }, { value: "PM", label: "PM" }]} onValueChange={next => onValueChange(toTimeValue(hour, minute, next as Period))} />
</Field>
</FieldGroup>
);
}
export function TimePicker({ id, name, value, defaultValue = "19:00", onValueChange, disabled, required, ariaLabel = "Time", className }: {
id?: string;
name?: string;
value?: string;
defaultValue?: string;
onValueChange?: (value: string) => void;
disabled?: boolean;
required?: boolean;
ariaLabel?: string;
className?: string;
}) {
const generatedId = React.useId();
const pickerId = id ?? generatedId;
const [open, setOpen] = React.useState(false);
const [internalValue, setInternalValue] = React.useState(defaultValue);
const isControlled = value !== undefined;
const currentValue = value ?? internalValue;
return (
<Popover open={open} onOpenChange={setOpen}>
{name ? <input type="hidden" name={name} value={currentValue} /> : null}
<PopoverTrigger asChild>
<Button
type="button"
id={pickerId}
variant="outline"
disabled={disabled}
aria-label={ariaLabel}
aria-required={required}
className={cn(
"min-w-[8.75rem] justify-between gap-2 px-3 font-semibold",
className,
)}
>
<span className="flex items-center gap-2">
<Clock3 data-icon="inline-start" />
{formatTimeValue(currentValue)}
</span>
<ChevronDown data-icon="inline-start" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="flex w-[18rem] max-w-[calc(100vw-2rem)] flex-col gap-4 p-4">
<div>
<p className="text-sm font-bold">Choose a time</p>
<p className="text-xs text-muted-foreground">Times use your devices timezone.</p>
</div>
<TimeFields id={pickerId} value={currentValue} onValueChange={next => { if (!isControlled) setInternalValue(next); onValueChange?.(next); }} />
<Button type="button" className="w-full" onClick={() => setOpen(false)}>
Done
</Button>
</PopoverContent>
</Popover>
);
}
@@ -16,11 +16,13 @@ import {
groupMemberships,
guests,
photos,
photoExports,
submissions,
user,
} from "@album/database";
import {
createEventInputSchema,
exportPhotosInputSchema,
checkEventSlugInputSchema,
generateEventSlugInputSchema,
locationSearchInputSchema,
@@ -79,6 +81,30 @@ async function signedPhotoUrls(photo: {
}
export const managerRouter = createTRPCRouter({
requestExport: protectedProcedure.input(exportPhotosInputSchema).mutation(async ({ctx,input}) => {
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
const [job] = await getDb().insert(photoExports).values({eventId:input.eventId,requestedBy:ctx.session.user.id,filter:input.filter,expiresAt:new Date(Date.now()+86400000)}).onConflictDoNothing().returning({id:photoExports.id});
if (!job) throw new TRPCError({code:"CONFLICT",message:"An export is already queued or processing."});
await writeAudit({eventId:input.eventId,actorUserId:ctx.session.user.id,action:"photo.export.requested",subjectType:"export",subjectId:job.id,metadata:{filter:input.filter}});
return job;
}),
exports: protectedProcedure.input(z.object({eventId:z.string().uuid()})).query(async ({ctx,input}) => {
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
return getDb().select({id:photoExports.id,status:photoExports.status,filter:photoExports.filter,total:photoExports.total,processed:photoExports.processed,expiresAt:photoExports.expiresAt}).from(photoExports)
.where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.requestedBy,ctx.session.user.id))).orderBy(desc(photoExports.createdAt)).limit(10);
}),
downloadExport: protectedProcedure.input(z.object({eventId:z.string().uuid(),exportId:z.string().uuid()})).mutation(async ({ctx,input}) => {
const {access} = await loadEventAccess(ctx.session.user.id,input.eventId,await getPlatformRole(ctx.session.user.id));
requireEventPermission(access.permissions,EVENT_PERMISSIONS.SETTINGS_MANAGE);
requireEventPermission(access.permissions,EVENT_PERMISSIONS.PHOTOS_PRIVATE_READ);
const [job] = await getDb().select().from(photoExports).where(and(eq(photoExports.eventId,input.eventId),eq(photoExports.id,input.exportId),eq(photoExports.requestedBy,ctx.session.user.id)));
if (!job || job.status !== "ready" || job.expiresAt <= new Date()) throw new TRPCError({code:"NOT_FOUND",message:"Export is unavailable or expired."});
return {url:await createPresignedGetUrl(`exports/${input.eventId}/${job.id}.zip`,Math.max(1,Math.min(300,Math.floor((job.expiresAt.getTime()-Date.now())/1000))))};
}),
searchLocations: protectedProcedure.input(locationSearchInputSchema).query(async ({ ctx, input }) => {
const platformRole = await getPlatformRole(ctx.session.user.id);
const { access } = await loadEventAccess(ctx.session.user.id, input.eventId, platformRole);
@@ -0,0 +1,39 @@
import {test,expect} from "bun:test";
import {and,eq,ne} from "drizzle-orm";
import {events,eventMemberships,getDb,groups,photoExports,user} from "@album/database";
import {managerRouter} from "./api/routers/manager";
import type {TrpcContext} from "./api/trpc";
test.skipIf(process.env.EXPORT_PERMISSIONS_INTEGRATION!=="1")("export authorization, requester scoping, revoked access and expiry",async()=>{
if (!["localhost","127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname)) throw new Error("Local database required");
const db=getDb();
const id=crypto.randomUUID();
const [person]=await db.insert(user).values({id,name:"Export test",email:`${id}@manyangles.test`,emailVerified:true}).returning();
const [group]=await db.select({id:groups.id}).from(groups).limit(1);
const [event]=await db.insert(events).values({groupId:group!.id,title:"Permission test",slug:`export-access-${id}`}).returning();
const ctx:TrpcContext={session:{user:person!,session:{}} as TrpcContext["session"],cookies:new Map(),activeGroupId:null,requestOrigin:"http://localhost:3000",clientIdentifier:id,guestTokenForEvent:()=>null,setCookies:[],appendSetCookie:()=>{}};
const caller=managerRouter.createCaller(ctx);
try {
await expect(caller.requestExport({eventId:event!.id,filter:"all"})).rejects.toThrow();
await db.insert(eventMemberships).values({eventId:event!.id,userId:id,role:"viewer"});
await expect(caller.requestExport({eventId:event!.id,filter:"all"})).rejects.toThrow();
await db.update(eventMemberships).set({role:"owner"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
const job=await caller.requestExport({eventId:event!.id,filter:"all"});
const scope=and(eq(photoExports.eventId,event!.id),eq(photoExports.id,job.id));
await db.update(photoExports).set({status:"ready"}).where(scope);
expect((await caller.downloadExport({eventId:event!.id,exportId:job.id})).url).toContain("X-Amz-Expires=300");
const [other]=await db.select({id:user.id}).from(user).where(ne(user.id,id)).limit(1);
await db.update(photoExports).set({requestedBy:other!.id}).where(scope);
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow();
await db.update(photoExports).set({requestedBy:id}).where(scope);
await expect(caller.downloadExport({eventId:crypto.randomUUID(),exportId:job.id})).rejects.toThrow();
await db.update(eventMemberships).set({role:"viewer"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow();
await db.update(eventMemberships).set({role:"owner"}).where(and(eq(eventMemberships.eventId,event!.id),eq(eventMemberships.userId,id)));
await db.update(photoExports).set({expiresAt:new Date(0)}).where(scope);
await expect(caller.downloadExport({eventId:event!.id,exportId:job.id})).rejects.toThrow("expired");
} finally {
await db.delete(events).where(eq(events.id,event!.id));
await db.delete(user).where(eq(user.id,id));
}
});
+5 -1
View File
@@ -11,14 +11,18 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@album/email": "workspace:*",
"@album/database": "workspace:*",
"@album/email": "workspace:*",
"@album/storage": "workspace:*",
"@aws-sdk/lib-storage": "3.1127.0",
"archiver": "^8.0.0",
"drizzle-orm": "^0.45.2",
"heic-convert": "^2.1.0",
"sharp": "0.35.3"
},
"devDependencies": {
"@types/archiver": "^8.0.0",
"fflate": "^0.8.3",
"typescript": "^5.8.3"
}
}
@@ -0,0 +1,42 @@
import {test,expect} from "bun:test";
import {unzipSync} from "fflate";
import {and,eq} from "drizzle-orm";
import {events,getDb,photos,photoExports,user,guests,submissions} from "@album/database";
import {getObjectBuffer,deleteObject,putObject,headObject} from "@album/storage";
import {processPhotoExport} from "./exports";
test.skipIf(process.env.EXPORT_INTEGRATION!=="1")("original ZIP bytes, approved filtering, duplicate requests and expiry",async()=>{
if (!["localhost","127.0.0.1"].includes(new URL(process.env.DATABASE_URL!).hostname) || !["localhost","127.0.0.1"].includes(new URL(process.env.S3_ENDPOINT!).hostname)) throw new Error("Local storage/database required");
const db=getDb();
const [demo]=await db.select().from(events).where(eq(events.slug,"demo"));
const [owner]=await db.select({id:user.id}).from(user).limit(1);
const [event]=await db.insert(events).values({groupId:demo!.groupId,title:"Export test",slug:`export-${crypto.randomUUID()}`}).returning();
const [guest]=await db.insert(guests).values({eventId:event!.id,tokenHash:crypto.randomUUID()}).returning();
const [submission]=await db.insert(submissions).values({eventId:event!.id,guestId:guest!.id}).returning();
const bytes=Buffer.from("original bytes preserved exactly");
const sourceKey=`events/${event!.id}/export-test/original`;
const jobIds:string[]=[];
try {
await putObject({key:sourceKey,body:bytes,contentType:"image/jpeg"});
const [photo]=await db.insert(photos).values({eventId:event!.id,submissionId:submission!.id,originalKey:sourceKey,contentType:"image/jpeg",byteSize:bytes.length,processingStatus:"ready",visibility:"public"}).returning();
await db.insert(photos).values({eventId:event!.id,submissionId:submission!.id,originalKey:sourceKey,contentType:"image/jpeg",byteSize:bytes.length,processingStatus:"ready",visibility:"private"});
for(const filter of ["approved","all"]) {
const [job]=await db.insert(photoExports).values({eventId:event!.id,requestedBy:owner!.id,filter,expiresAt:new Date(Date.now()+86400000)}).returning();
jobIds.push(job!.id);
expect(await db.insert(photoExports).values({eventId:event!.id,requestedBy:owner!.id,filter,expiresAt:new Date(Date.now()+86400000)}).onConflictDoNothing().returning()).toHaveLength(0);
await processPhotoExport();
const [finished]=await db.select().from(photoExports).where(and(eq(photoExports.id,job!.id),eq(photoExports.eventId,event!.id)));
expect(finished?.status).toBe("ready");
const zip=unzipSync(await getObjectBuffer(`exports/${event!.id}/${job!.id}.zip`));
expect(Object.keys(zip)).toHaveLength(filter==="approved"?1:2);
expect(Buffer.from(zip[`${photo!.id}.jpg`]!)).toEqual(bytes);
await db.update(photoExports).set({expiresAt:new Date(0)}).where(and(eq(photoExports.id,job!.id),eq(photoExports.eventId,event!.id)));
await processPhotoExport();
expect(await headObject(`exports/${event!.id}/${job!.id}.zip`)).toBeNull();
}
} finally {
for(const id of jobIds) await deleteObject(`exports/${event!.id}/${id}.zip`);
await deleteObject(sourceKey);
await db.delete(events).where(eq(events.id,event!.id));
}
},30000);
+66
View File
@@ -0,0 +1,66 @@
import { ZipArchive } from "archiver";
import { Upload } from "@aws-sdk/lib-storage";
import { once } from "node:events";
import { PassThrough } from "node:stream";
import { and, eq, sql } from "drizzle-orm";
import { getDb, photoExports, photos } from "@album/database";
import { deleteObject, getObjectBuffer, getSigningClient, storageConfig } from "@album/storage";
export async function processPhotoExport() {
const db = getDb();
// Interrupted jobs are failed, never reclaimed while an old worker may write.
await db.execute(sql`UPDATE photo_exports SET status = 'failed', updated_at = now()
WHERE status = 'processing' AND updated_at < now() - interval '1 hour'`);
const expired = await db.execute<{id:string;event_id:string}>(sql`SELECT id,event_id FROM photo_exports WHERE expires_at < now() AND status <> 'expired' LIMIT 1`);
if (expired[0]) {
const item = expired[0];
await deleteObject(`exports/${item.event_id}/${item.id}.zip`);
await db.update(photoExports).set({status:"expired",updatedAt:new Date()}).where(and(eq(photoExports.id,item.id),eq(photoExports.eventId,item.event_id)));
}
const rows = await db.execute<{id:string;event_id:string;filter:string}>(sql`UPDATE photo_exports SET status = 'processing', updated_at = now()
WHERE id = (SELECT id FROM photo_exports WHERE status = 'pending' AND expires_at > now() ORDER BY created_at FOR UPDATE SKIP LOCKED LIMIT 1)
RETURNING id,event_id,filter`);
const job = rows[0];
if (!job) return false;
const scope = and(eq(photoExports.id,job.id),eq(photoExports.eventId,job.event_id),eq(photoExports.status,"processing"));
const key = `exports/${job.event_id}/${job.id}.zip`;
const archive = new ZipArchive({ store:true, forceZip64:true });
const body = new PassThrough();
archive.pipe(body);
const upload = new Upload({ client:getSigningClient(), params:{Bucket:storageConfig().bucket,Key:key,Body:body,ContentType:"application/zip",ContentDisposition:'attachment; filename="manyangles-originals.zip"'}, queueSize:2, partSize:8*1024*1024 });
const uploaded = upload.done();
// Attach immediately: a storage failure must not become an unhandled rejection.
void uploaded.catch(() => archive.destroy(new Error("Export storage failed")));
archive.on("error", error => body.destroy(error));
body.on("error", () => {});
try {
const items = await db.select({id:photos.id,key:photos.originalKey,type:photos.contentType,bytes:photos.byteSize}).from(photos)
.where(and(eq(photos.eventId,job.event_id),eq(photos.processingStatus,"ready"),job.filter === "approved" ? eq(photos.visibility,"public") : undefined)).orderBy(photos.id).limit(10001);
if (!items.length || items.length > 10000 || items.reduce((sum,p)=>sum+p.bytes,0)>20*1024**3) throw new Error("Export size limit");
await db.update(photoExports).set({total:items.length,updatedAt:new Date()}).where(scope);
let processed = 0;
for (const item of items) {
const buffer = await getObjectBuffer(item.key);
if (archive.destroyed) { await uploaded; throw new Error("Export stream closed"); }
const extension = ({"image/jpeg":"jpg","image/png":"png","image/webp":"webp","image/heic":"heic","image/heif":"heif"} as Record<string,string>)[item.type] ?? "bin";
const consumed = once(archive,"entry");
archive.append(buffer,{name:`${item.id}.${extension}`});
await consumed;
const changed = await db.update(photoExports).set({processed:++processed,updatedAt:new Date()}).where(scope).returning({id:photoExports.id});
if (!changed.length) throw new Error("Export no longer active");
}
await archive.finalize();
await uploaded;
const changed = await db.update(photoExports).set({status:"ready",updatedAt:new Date()}).where(scope).returning({id:photoExports.id});
if (!changed.length) await deleteObject(key);
} catch {
archive.abort();
body.destroy();
await upload.abort().catch(()=>{});
await uploaded.catch(()=>{});
await deleteObject(key).catch(()=>{});
await db.update(photoExports).set({status:"failed",updatedAt:new Date()}).where(scope);
console.error(`Export failed for event ${job.event_id}`);
}
return true;
}
+2
View File
@@ -1,6 +1,7 @@
import { sql } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import sharp from "sharp";
import { processPhotoExport } from "./exports";
import { processEmailDelivery } from "@album/email/queue";
import convert from "heic-convert";
import { eventBanners, getDb, photoJobs, photos } from "@album/database";
@@ -208,6 +209,7 @@ async function emailLoop() {
}
await Promise.all([
(async () => { while (true) { try { await processPhotoExport(); } catch { console.error("Export queue check failed"); } await Bun.sleep(1000); } })(),
emailLoop(),
...Array.from({ length: CONCURRENCY }, (_, index) => workerLoop(index + 1)),
]);
+115
View File
@@ -18,6 +18,7 @@
"@album/database": "workspace:*",
"@album/email": "workspace:*",
"@album/storage": "workspace:*",
"@radix-ui/react-popover": "^1.1.23",
"@tanstack/react-query": "^5.90.2",
"@trpc/client": "^11.4.3",
"@trpc/react-query": "^11.4.3",
@@ -26,6 +27,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cn": "^0.2.6",
"date-fns": "^4.4.0",
"dotenv": "^16.5.0",
"drizzle-orm": "^0.45.2",
"lucide-react": "^0.468.0",
@@ -33,6 +35,7 @@
"next-themes": "^0.4.6",
"radix-ui": "^1.6.7",
"react": "19.2.8",
"react-day-picker": "^9",
"react-dom": "19.2.8",
"server-only": "^0.0.1",
"shadcn": "^4.21.0",
@@ -60,11 +63,15 @@
"@album/database": "workspace:*",
"@album/email": "workspace:*",
"@album/storage": "workspace:*",
"@aws-sdk/lib-storage": "3.1127.0",
"archiver": "^8.0.0",
"drizzle-orm": "^0.45.2",
"heic-convert": "^2.1.0",
"sharp": "0.35.3",
},
"devDependencies": {
"@types/archiver": "^8.0.0",
"fflate": "^0.8.3",
"typescript": "^5.8.3",
},
},
@@ -157,6 +164,8 @@
"@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="],
"@aws-sdk/lib-storage": ["@aws-sdk/lib-storage@3.1127.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "buffer": "5.6.0", "events": "3.3.0", "stream-browserify": "3.0.0", "tslib": "^2.6.2" }, "peerDependencies": { "@aws-sdk/client-s3": "^3.1127.0" } }, "sha512-em87TJQOrdE3sAqlMnhg3HUAVj1hcoGdDjpekzDdKQcyQSKKbFfulnV57j41h/dHJxVqXRGzTqS8QOdOPtNFtw=="],
"@aws-sdk/middleware-sdk-s3": ["@aws-sdk/middleware-sdk-s3@3.972.75", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wMIsNumRVKaNMKhvU/s9VrdEwE8S6gSzXp4RygFG5BEMnGkkXf8cjh8zf7cKJBpUDpqTWqwbz5isEgp9rH6Lng=="],
"@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="],
@@ -247,6 +256,8 @@
"@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="],
"@date-fns/tz": ["@date-fns/tz@1.5.0", "", {}, "sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg=="],
"@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.75.1", "", { "dependencies": { "@dotenvx/primitives": "^0.8.0", "commander": "^11.1.0", "conf": "^10.2.0", "dotenv": "^17.2.1", "enquirer": "^2.4.1", "env-paths": "^2.2.1", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "open": "^8.4.2", "picomatch": "^4.0.4", "systeminformation": "^5.22.11", "undici": "^7.11.0", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-/BITOC9dmS/edY2zQwZNicQ059O6RKabtQfyEafV0nGtfYRNHYy1DIPiYVcov40+tob9hfmBnbR963dS+EQ1DQ=="],
"@dotenvx/primitives": ["@dotenvx/primitives@0.8.0", "", {}, "sha512-VYJy0uhFm9zTJ1TxBaW/pA8bjbOM/OttaNMwZ1RHG4JKyRG7DhSdiqD1ipQoAyoD22olUtxbP78W9xY3Wd11bg=="],
@@ -559,6 +570,8 @@
"@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="],
"@tabby_ai/hijri-converter": ["@tabby_ai/hijri-converter@1.0.5", "", {}, "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ=="],
"@tailwindcss/node": ["@tailwindcss/node@4.3.3", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.24.1", "jiti": "^2.7.0", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.3.3" } }, "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg=="],
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.3.3", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.3.3", "@tailwindcss/oxide-darwin-arm64": "4.3.3", "@tailwindcss/oxide-darwin-x64": "4.3.3", "@tailwindcss/oxide-freebsd-x64": "4.3.3", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", "@tailwindcss/oxide-linux-x64-musl": "4.3.3", "@tailwindcss/oxide-wasm32-wasi": "4.3.3", "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" } }, "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA=="],
@@ -613,6 +626,8 @@
"@turbo/windows-arm64": ["@turbo/windows-arm64@2.10.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-0i0mVUa4kKk+/B3RwEwPMf9CB+T7ul56hn5FFHNA4VUNTOoLBEd6aNf3FaKfCatDNZ6cicCEf6if9QUTVyzzcA=="],
"@types/archiver": ["@types/archiver@8.0.0", "", { "dependencies": { "@types/node": "*", "@types/readdir-glob": "*" } }, "sha512-YpXPbEuv9+eUIPPQWUPahj3cvs9isWRuF+J4z+KbdYVDO3rWorWQFxUVHnwPu2AgKwvgpki5F2VMX0Xx+mX45A=="],
"@types/bun": ["@types/bun@1.4.1", "", { "dependencies": { "bun-types": "1.4.1" } }, "sha512-0AVGiTXGajf1rgKom3N+c5L7CBxuoyyv1i44M0nX4UDK0G/fnRAMiri93nHuVPIb429KKtAgj7HatVmmOjeQLA=="],
"@types/node": ["@types/node@22.20.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q=="],
@@ -623,8 +638,12 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/readdir-glob": ["@types/readdir-glob@1.1.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg=="],
"@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="],
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
@@ -635,16 +654,34 @@
"ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="],
"archiver": ["archiver@8.0.0", "", { "dependencies": { "async": "^3.2.4", "buffer-crc32": "^1.0.0", "is-stream": "^4.0.0", "lazystream": "^1.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^3.0.0", "tar-stream": "^3.0.0", "zip-stream": "^7.0.2" } }, "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="],
"ast-types": ["ast-types@0.16.3", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-FvWoWYfSCM6kRxCSH+MGLHIKKGRL6A6AW7Zek2O32REPQRdg131428uRTKMBYAeRd3XXAaHDS60Wpri7CdKDrA=="],
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
"atomically": ["atomically@1.7.0", "", {}, "sha512-Xcz9l0z7y9yQ9rdDaxlmaI4uJHf/T8g9hOEzJcsEqX2SjCj4J20uK7+ldkDHMbpJDK76wF7xEIgxc/vSlsfw5w=="],
"b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"bare-events": ["bare-events@2.9.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-AIPKioV7/Y/8KfZ3AAhjPJxLLbY49S64Ym5DakZlUg75qQiTgUq9hEJoEwa4eUezPUlXRy/i5NpsKvo9jgKmoA=="],
"bare-fs": ["bare-fs@4.8.1", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-N1nnXdHZAOSstz0XiHikGS4HGMH4CnSwhqWdGQQMqqdvp4Jybm9sE3R1WVnpWVd4SFkc8ryPDBLViNLwiEqECg=="],
"bare-path": ["bare-path@3.1.2", "", {}, "sha512-ZyKbsuuqK6Ag0K8pX6V5Txq6XeJRvY+wXucnFGRjiyVYP9YWDpIQugk/b+enRYrEYBJaqLzghRQpXPMR7341Nw=="],
"bare-stream": ["bare-stream@2.13.4", "", { "dependencies": { "b4a": "^1.8.1", "streamx": "^2.25.0", "teex": "^1.0.1" }, "peerDependencies": { "bare-abort-controller": "*", "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-abort-controller", "bare-buffer", "bare-events"] }, "sha512-PcrQ8lVLbiJscNm1Kez+Yp4Gy4AHGcN1lzwjvf5NybWen7VvEgUfyfnXYJ2zNqWnzOfCb1Abq6lH8ti0syQszA=="],
"bare-url": ["bare-url@2.5.4", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.11.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-uh8vpY/1/YyFkunIDFH/12p7/7VdPKA1hejMVEbdkEaWnUz0Hesvx5EbiU6XxjyHZIOju+ZMbQJkRh+es3/spQ=="],
"better-auth": ["better-auth@1.6.24", "", { "dependencies": { "@better-auth/core": "1.6.24", "@better-auth/drizzle-adapter": "1.6.24", "@better-auth/kysely-adapter": "1.6.24", "@better-auth/memory-adapter": "1.6.24", "@better-auth/mongo-adapter": "1.6.24", "@better-auth/prisma-adapter": "1.6.24", "@better-auth/telemetry": "1.6.24", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.7", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-MtBxUKI2y5hXBBLU1MAXkUA/xTEPJ2lkzWLKCTQyG/IKNQQ8ve3tZK0uvq4AIv9iOLZHKUpBOBsDoqTEHaRb9Q=="],
@@ -661,6 +698,10 @@
"browserslist": ["browserslist@4.28.9", "", { "dependencies": { "baseline-browser-mapping": "^2.11.20", "caniuse-lite": "^1.0.30001810", "electron-to-chromium": "^1.5.420", "node-releases": "^2.0.54", "update-browserslist-db": "^1.3.2" }, "bin": { "browserslist": "cli.js" } }, "sha512-EWazOblFYUvlGZcfGhPUPmYh3nikUxBVb+y9MJun5f3hBi812X+8MSQTujLBtgK3cf51fJWbWfOjyeO954d+Eg=="],
"buffer": ["buffer@5.6.0", "", { "dependencies": { "base64-js": "^1.0.2", "ieee754": "^1.1.4" } }, "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw=="],
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.4.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-loKuVrAFZKfEv+JvWkHRS9GW5IqLuLRjVXN9p+vZvBN86O5hf/pBZQ5hSoyipsrMmWObZBDvWnlmKvjKTM0PdA=="],
@@ -695,6 +736,8 @@
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="],
"compress-commons": ["compress-commons@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^7.0.1", "is-stream": "^4.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ=="],
"conf": ["conf@10.2.0", "", { "dependencies": { "ajv": "^8.6.3", "ajv-formats": "^2.1.1", "atomically": "^1.7.0", "debounce-fn": "^4.0.0", "dot-prop": "^6.0.1", "env-paths": "^2.2.1", "json-schema-typed": "^7.0.3", "onetime": "^5.1.2", "pkg-up": "^3.1.0", "semver": "^7.3.5" } }, "sha512-8fLl9F04EJqjSqH+QjITQfJF8BrOVaYr1jewVgSRAEWePfxT0sku4w2hrGQ60BC/TNLGQ2pgxNlTbWQmMPFvXg=="],
"content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="],
@@ -709,16 +752,26 @@
"copy-anything": ["copy-anything@4.1.0", "", {}, "sha512-ufbM3smX/Jbnpk5wcQjzd1MgBpzmqfNETUAyZNrGwU9foRlyHoGzMMBBCRzEhQLBjZfFDE1W2ufPXX2vdWkV8Q=="],
"core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cosmiconfig": ["cosmiconfig@9.0.2", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-gtTZxTDau1wL7Y7zifc2dd8jHSK/k6BTx/2Xp/BpdlAdnlYWFVt7qhJqgwi7637yRwRQ3qL4ZidbB4I8tA5VOg=="],
"crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="],
"crc32-stream": ["crc32-stream@7.0.1", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"date-fns": ["date-fns@4.4.0", "", {}, "sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w=="],
"date-fns-jalali": ["date-fns-jalali@4.1.0-0", "", {}, "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg=="],
"debounce-fn": ["debounce-fn@4.0.0", "", { "dependencies": { "mimic-fn": "^3.0.0" } }, "sha512-8pYCQiL9Xdcg0UPSD3d+0KMlOjp+KGU5EPwYddgzQ7DATsg4fuUDjQtsYLmWjnk2obnNHgV3vE2Y4jejSOJVBQ=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
@@ -785,6 +838,12 @@
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="],
"events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="],
"events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="],
@@ -797,6 +856,8 @@
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
"fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
@@ -807,6 +868,8 @@
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
@@ -865,6 +928,8 @@
"iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="],
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
@@ -905,6 +970,8 @@
"is-wsl": ["is-wsl@2.2.0", "", { "dependencies": { "is-docker": "^2.0.0" } }, "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww=="],
"isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="],
"isexe": ["isexe@3.1.5", "", {}, "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w=="],
"jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
@@ -933,6 +1000,8 @@
"kysely": ["kysely@0.29.5", "", {}, "sha512-ooa+eSbBNPTo3MycPEuW5jdrxQdQwdtB3LC3h43FiXQbIry5tR0C5lDG7eealK0E4D7XjrnOP5DIUg/LyjRMYQ=="],
"lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="],
"libheif-js": ["libheif-js@1.23.2", "", {}, "sha512-qvHIXtggEsw1lCNCWBYKloL2Z36DJBm0R9ThGiH2JnhKYdeZFLPFkP30Lw4yMskxxhx0bKg1gLrBHX1D2w2pSw=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
@@ -1011,6 +1080,8 @@
"nodemailer": ["nodemailer@9.1.1", "", {}, "sha512-izw9mVKFix6YSnC9eLgV6g1opl9DUlRio9ZNcq+Wu9Ujn2UwF+8Nl0B8nz22kEC+CTZCvinkxwJ0DeFbb6NwcQ=="],
"normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="],
"npm-run-path": ["npm-run-path@6.0.0", "", { "dependencies": { "path-key": "^4.0.0", "unicorn-magic": "^0.3.0" } }, "sha512-9qny7Z9DsQU8Ou39ERsPU4OZQlSTP47ShQzuKZ6PRXpYLtIFgl/DEBYEXKlvcEa+9tHVcK8CF81Y2V72qaZhWA=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
@@ -1073,6 +1144,10 @@
"pretty-ms": ["pretty-ms@9.3.1", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-HzMy3Geq23nVALD/M2LliU+F+M+gVNsvkQWWqeBZ8HDiCgzo6YPJ/Omrmtq24EFrIsk0a3EkQGEd7bDOo+IhGA=="],
"process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="],
"process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="],
"prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
@@ -1089,6 +1164,8 @@
"react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="],
"react-day-picker": ["react-day-picker@9.14.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "@tabby_ai/hijri-converter": "1.0.5", "date-fns": "^4.1.0", "date-fns-jalali": "4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA=="],
"react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="],
"react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="],
@@ -1097,6 +1174,10 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readdir-glob": ["readdir-glob@3.0.0", "", { "dependencies": { "minimatch": "^10.2.2" } }, "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw=="],
"recast": ["recast@0.23.21", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-mFAyJq9vUbSTARLZUvAEf1z3YxlvAwswbmxMx2mPA/MSm4KmpwvwvhsH/NIrZhyOuwD60Lzyw2qh83uCbgTPYw=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
@@ -1119,6 +1200,8 @@
"run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="],
"safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
@@ -1173,8 +1256,14 @@
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
"stream-browserify": ["stream-browserify@3.0.0", "", { "dependencies": { "inherits": "~2.0.4", "readable-stream": "^3.5.0" } }, "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA=="],
"streamx": ["streamx@2.28.1", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-zEzXb0s5Cds7tqMH6rhZ05lcJydCWiQPEwiNngVqzsxCc962vLY4Uw+mW7od8kDH258k2Uz/JrOkdIAAhSh9VA=="],
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"stringify-object": ["stringify-object@5.0.0", "", { "dependencies": { "get-own-enumerable-keys": "^1.0.0", "is-obj": "^3.0.0", "is-regexp": "^3.1.0" } }, "sha512-zaJYxz2FtcMb4f+g60KsRNFOpVMUyuJgA51Zi5Z1DOTC3S59+OQiVOzE9GZt0x72uBGWKsQIuBKeF9iusmKFsg=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
@@ -1195,6 +1284,12 @@
"tapable": ["tapable@2.3.3", "", {}, "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A=="],
"tar-stream": ["tar-stream@3.2.1", "", { "dependencies": { "b4a": "^1.6.4", "bare-fs": "^4.5.5", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-nqsEO8zLZJvrOMdEwkA0QdCLFbetHMn95Zqu4fKwX+hkaTWJPZZOrxx/PwtxoK0MMGQmBQNRW3CPs8IFYQz4cQ=="],
"teex": ["teex@1.0.1", "", { "dependencies": { "streamx": "^2.12.5" } }, "sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg=="],
"text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
"to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="],
@@ -1251,6 +1346,8 @@
"yoctocolors": ["yoctocolors@2.2.0", "", {}, "sha512-xYqdZFUK/VYazNl/oCDYN+3WloWQwMfZxBoiNt6qNyk+xfOdi598muWE42rNZFp1kNOiqW936q5RhUdnpqElSg=="],
"zip-stream": ["zip-stream@7.0.5", "", { "dependencies": { "compress-commons": "^7.0.0", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w=="],
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
@@ -1291,8 +1388,12 @@
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@types/archiver/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="],
"@types/nodemailer/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="],
"@types/readdir-glob/@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="],
"better-auth/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="],
"body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
@@ -1311,6 +1412,8 @@
"is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="],
"lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="],
"log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="],
"micromatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
@@ -1327,8 +1430,14 @@
"prompts/kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="],
"readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
"restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="],
"stream-browserify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
"tsx/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="],
"type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="],
@@ -1395,14 +1504,20 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@types/archiver/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"@types/nodemailer/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"@types/readdir-glob/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"bun-types/@types/node/undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"enquirer/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"next/sharp/@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="],
"next/sharp/@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="],
+105
View File
@@ -0,0 +1,105 @@
# Use the Docker Compose build pack, with this file as its compose location.
# Coolify owns generated URLs and secrets; never commit a rendered .env.
x-environment: &environment
NODE_ENV: production
DATABASE_URL: postgres://manyangles:${SERVICE_PASSWORD_64_POSTGRES}@postgres:5432/manyangles
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_WEB}
BETTER_AUTH_URL: ${SERVICE_URL_WEB}
BETTER_AUTH_SECRET: ${SERVICE_PASSWORD_64_AUTH}
S3_ENDPOINT: http://garage:3900
S3_PUBLIC_ENDPOINT: ${SERVICE_URL_GARAGE}
S3_REGION: garage
S3_BUCKET: manyangles
S3_ACCESS_KEY: GK${SERVICE_HEX_24_S3KEY}
S3_SECRET_KEY: ${SERVICE_HEX_64_S3SECRET}
S3_FORCE_PATH_STYLE: "true"
EMAIL_PROVIDER: resend
EMAIL_FROM: ${EMAIL_FROM}
RESEND_API_KEY: ${RESEND_API_KEY}
RESEND_WEBHOOK_SECRET: ${RESEND_WEBHOOK_SECRET}
WORKER_CONCURRENCY: "1"
x-runtime: &runtime
restart: unless-stopped
init: true
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: manyangles
POSTGRES_USER: manyangles
POSTGRES_PASSWORD: ${SERVICE_PASSWORD_64_POSTGRES}
volumes: ["postgres-data:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U manyangles -d manyangles"]
interval: 5s
timeout: 3s
retries: 20
garage:
image: dxflrs/garage:v2.3.0
restart: unless-stopped
command: ["/garage", "-c", "/etc/garage.toml", "server", "--single-node", "--default-bucket"]
environment:
SERVICE_URL_GARAGE_3900: ${SERVICE_URL_GARAGE_3900}
GARAGE_RPC_SECRET: ${SERVICE_HEX_64_RPC}
GARAGE_ADMIN_TOKEN: ${SERVICE_PASSWORD_64_GARAGEADMIN}
GARAGE_DEFAULT_ACCESS_KEY: GK${SERVICE_HEX_24_S3KEY}
GARAGE_DEFAULT_SECRET_KEY: ${SERVICE_HEX_64_S3SECRET}
GARAGE_DEFAULT_BUCKET: manyangles
expose: ["3900"]
volumes:
- ./docker/garage.production.toml:/etc/garage.toml:ro
- garage-meta:/var/lib/garage/meta
- garage-data:/var/lib/garage/data
healthcheck:
test: ["CMD", "/garage", "-c", "/etc/garage.toml", "status"]
interval: 10s
timeout: 5s
retries: 10
migrate:
build: {context: ., target: migrate}
environment:
DATABASE_URL: postgres://manyangles:${SERVICE_PASSWORD_64_POSTGRES}@postgres:5432/manyangles
restart: "no"
depends_on:
postgres: {condition: service_healthy}
storage-init:
build: {context: ., target: worker}
command: ["bun", "packages/storage/src/configure.ts"]
environment: *environment
restart: "no"
depends_on:
garage: {condition: service_healthy}
web:
<<: *runtime
build:
context: .
target: web
args:
NEXT_PUBLIC_APP_URL: ${SERVICE_URL_WEB}
environment:
<<: *environment
SERVICE_URL_WEB_3000: ${SERVICE_URL_WEB_3000}
expose: ["3000"]
depends_on:
migrate: {condition: service_completed_successfully}
storage-init: {condition: service_completed_successfully}
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
worker:
<<: *runtime
build: {context: ., target: worker}
environment: *environment
depends_on:
migrate: {condition: service_completed_successfully}
storage-init: {condition: service_completed_successfully}
volumes:
postgres-data:
garage-meta:
garage-data:
+53
View File
@@ -0,0 +1,53 @@
name: manyangles
x-runtime: &runtime
env_file: ${RUNTIME_ENV_FILE:-.env.production}
environment:
NODE_ENV: production
DATABASE_URL: postgres://manyangles:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}@postgres:5432/manyangles
restart: unless-stopped
init: true
security_opt: ["no-new-privileges:true"]
cap_drop: [ALL]
services:
postgres:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: manyangles
POSTGRES_USER: manyangles
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD}
volumes: ["postgres-data:/var/lib/postgresql/data"]
healthcheck:
test: ["CMD-SHELL", "pg_isready -U manyangles -d manyangles"]
interval: 5s
timeout: 3s
retries: 20
migrate:
<<: *runtime
restart: "no"
build: {context: ., target: migrate}
depends_on:
postgres: {condition: service_healthy}
web:
<<: *runtime
build:
context: .
target: web
args:
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:?Set public HTTPS origin}
ports: ["127.0.0.1:3000:3000"]
depends_on:
migrate: {condition: service_completed_successfully}
healthcheck:
test: ["CMD", "bun", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
interval: 30s
timeout: 5s
start_period: 20s
retries: 3
worker:
<<: *runtime
build: {context: ., target: worker}
depends_on:
migrate: {condition: service_completed_successfully}
volumes:
postgres-data:
+16
View File
@@ -0,0 +1,16 @@
metadata_dir = "/var/lib/garage/meta"
data_dir = "/var/lib/garage/data"
db_engine = "sqlite"
replication_factor = 1
rpc_bind_addr = "[::]:3901"
rpc_public_addr = "127.0.0.1:3901"
rpc_secret_file = "env:GARAGE_RPC_SECRET"
[s3_api]
s3_region = "garage"
api_bind_addr = "[::]:3900"
root_domain = ".s3.garage.localhost"
[admin]
api_bind_addr = "[::]:3903"
admin_token_file = "env:GARAGE_ADMIN_TOKEN"
+105
View File
@@ -0,0 +1,105 @@
# Production deployment
## Coolify on one VM
Use the repository Docker Compose build pack and `/compose.coolify.yml`.
This adds isolated Postgres and Garage volumes, migrations, and a one-shot
storage initializer (exact-origin CORS, one-day export expiry and abandoned
multipart cleanup). It does not import local demo data or create accounts.
Coolify generates `SERVICE_URL_WEB` and `SERVICE_URL_GARAGE`; these are passed
to the app/auth and browser storage endpoint respectively. Route web to port
3000 and Garage to 3900. Both generated domains must use HTTPS. Only these
services are public; do not expose Postgres or Garage's admin/RPC ports.
Set `EMAIL_FROM`, `RESEND_API_KEY`, and `RESEND_WEBHOOK_SECRET` in Coolify before
starting. Generated credentials belong to this stack, not other applications.
Keep worker concurrency at one initially on a shared VM. Back up all three
data volumes off-host and verify recovery before collecting real wedding photos.
## Standalone Compose
Requires Docker Compose, an HTTPS reverse proxy, and a private S3-compatible
bucket. Compose includes persistent Postgres, a one-shot migration service,
the standalone web app, and a persistent Bun image/email/export worker.
Mailpit and demo credentials are not included. Production never auto-creates
the bucket or changes its CORS configuration.
1. Copy `.env.production.example` to `.env.production` and fill every required
secret. Use random values, not development credentials. Keep auth and public
URLs identical to the final HTTPS origin. The public URL is also a build arg;
rebuild the web image when changing it.
2. Provision the private bucket and configure CORS: allow your exact public app
origin, GET/PUT/HEAD, Content-Type and necessary S3 checksum headers; expose
ETag and Content-Length. Guests must reach `S3_PUBLIC_ENDPOINT` over HTTPS.
The worker needs list/get/put/delete and multipart-upload permissions.
Set a one-day lifecycle expiry for the `exports/` prefix and abort incomplete
multipart uploads after one day. Do not expire originals. The worker also
removes expired ZIPs, but lifecycle handles archives belonging to deleted events.
3. Verify the Resend sender domain. Configure `/api/webhooks/resend` as described
in `email-delivery.md`. Set Authentik's callback if using it (see README).
4. Run:
```sh
docker compose --env-file .env.production -f compose.production.yml build
docker compose --env-file .env.production -f compose.production.yml up -d
docker compose --env-file .env.production -f compose.production.yml ps
```
The web service binds only `127.0.0.1:3000`. Configure your host reverse proxy to
forward HTTPS to that address, preserve Host and X-Forwarded-Proto, and support
normal streaming responses. Postgres is not published to the host. If your proxy
runs in Docker, attach it to the Compose network and use `web:3000` instead.
## First administrator
Register your real account in the app and verify its email (or sign in through
Authentik). Obtain its ID from your database administrator, then run:
```sh
docker compose --env-file .env.production -f compose.production.yml exec worker bun packages/database/src/bootstrap-admin.ts USER_ID
```
This promotes only an existing verified account and refuses once any platform
administrator exists. No passwords are created or logged. Never run demo seeds.
## Updates and operations
Back up Postgres and the original-image bucket before updates. Build updated
images, then run `docker compose ... run --rm migrate` before `up -d` (replace
`...` with the same env-file/file arguments above). Do not use `down -v` on a
live installation. Schema rollback requires a tested backup/restore strategy.
Monitor web health at `/api/health` (process liveness only), worker logs, failed
jobs, disk usage, database backups and storage lifecycle rules. Test restores.
Pin image digests in your deployment pipeline after verifying them.
## Originals exports
Event → Photos → Export originals. Owners/managers with settings and private-photo
access can choose approved or all processed photos. Exports are requester-only;
permissions are rechecked before issuing each five-minute signed download URL.
Already-issued URLs remain usable until expiry. ZIP files contain unchanged
original bytes (including EXIF/GPS), named by photo ID to prevent unsafe or
duplicate filenames. Standalone banners and unprocessed uploads are excluded.
The selection is taken when processing starts. No originals pass through Next.js.
ZIP64, streaming multipart uploads and one-at-a-time input reads bound memory.
One active export per requester/event is allowed; limit 10,000 photos / 20 GB.
Archives expire after 24 hours; interrupted jobs fail and may be requested again.
## Verification
Validated locally: web/worker/migration Docker image builds (Linux ARM64), web
container startup and health response, worker native Sharp and ZIP dependencies,
Compose configuration, clean database migrations, typecheck and application build.
The demo's eight exported originals were compared byte-for-byte successfully.
No production services were deployed and no production emails were sent.
```sh
EXPORT_INTEGRATION=1 bun --env-file=.env test apps/worker/src/exports.integration.test.ts
cd apps/web
EXPORT_PERMISSIONS_INTEGRATION=1 bun --env-file=../../.env test src/server/exports.integration.test.ts
```
The local-only integration tests cover archive contents, original bytes,
approved/private filtering, expiry deletion, duplicate jobs, event/requester
scoping, denied roles, revoked permissions, and expired download rejection.
They remove their own temporary fixtures. Validate your actual S3 provider and
target architecture in staging before launch.
+2 -2
View File
@@ -1,13 +1,13 @@
{
"name": "album",
"private": true,
"packageManager": "bun@1.3.14",
"packageManager": "bun@1.4.0",
"workspaces": [
"apps/*",
"packages/*"
],
"engines": {
"bun": ">=1.3.0"
"bun": ">=1.4.0"
},
"scripts": {
"dev": "bun --env-file=.env x turbo dev",
+1
View File
@@ -51,6 +51,7 @@ export const allowedImageTypes = [
export const allowedImageTypeSchema = z.enum(allowedImageTypes);
export const MAX_PHOTO_BYTES = 25 * 1024 * 1024;
export const exportPhotosInputSchema = z.object({ eventId: z.string().uuid(), filter: z.enum(["approved", "all"]).default("approved") });
export const eventSlugSchema = z
.string()
@@ -0,0 +1,15 @@
CREATE TABLE photo_exports (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
event_id uuid NOT NULL REFERENCES events(id) ON DELETE CASCADE,
requested_by text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
filter text NOT NULL CHECK (filter IN ('approved', 'all')),
status text NOT NULL DEFAULT 'pending',
total integer NOT NULL DEFAULT 0,
processed integer NOT NULL DEFAULT 0,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX photo_exports_queue_idx ON photo_exports(status, created_at);
CREATE UNIQUE INDEX photo_exports_active_idx ON photo_exports(event_id, requested_by)
WHERE status IN ('pending', 'processing');
@@ -64,6 +64,13 @@
"when": 1788906000000,
"tag": "0008_email_webhooks",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1788906100000,
"tag": "0009_photo_exports",
"breakpoints": true
}
]
}
+16
View File
@@ -0,0 +1,16 @@
import { eq, sql } from "drizzle-orm";
import { closeDb, getDb, platformAdministrators, user } from "@album/database";
// Promote an existing verified account, never create credentials or bypass email verification.
const userId = process.argv[2];
if (!userId) throw new Error("Usage: bun packages/database/src/bootstrap-admin.ts <verified-user-id>");
try {
await getDb().transaction(async tx => {
await tx.execute(sql`LOCK TABLE platform_administrators IN EXCLUSIVE MODE`);
if ((await tx.select({id:platformAdministrators.id}).from(platformAdministrators).limit(1)).length) throw new Error("An administrator already exists; use the admin UI instead");
const [existing] = await tx.select({id:user.id,verified:user.emailVerified}).from(user).where(eq(user.id,userId));
if (!existing?.verified) throw new Error("Account must exist and have verified email");
await tx.insert(platformAdministrators).values({userId,role:"super_admin"});
});
console.info("Initial administrator promoted");
} finally { await closeDb(); }
+12
View File
@@ -199,6 +199,18 @@ export const emailWebhookEvents = pgTable("email_webhook_events", {
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
}, (table) => [index("email_webhook_events_provider_idx").on(table.providerId)]);
export const photoExports = pgTable("photo_exports", {
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
requestedBy: text("requested_by").notNull().references(() => user.id, { onDelete: "cascade" }),
filter: text("filter").notNull(),
status: text("status").notNull().default("pending"),
total: integer("total").notNull().default(0),
processed: integer("processed").notNull().default(0),
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
...timestamps,
}, (table) => [index("photo_exports_queue_idx").on(table.status, table.createdAt), uniqueIndex("photo_exports_active_idx").on(table.eventId,table.requestedBy).where(sql`${table.status} IN ('pending', 'processing')`), check("photo_exports_filter_check",sql`${table.filter} IN ('approved', 'all')`)]);
export const eventBanners = pgTable("event_banners", {
id: uuid("id").defaultRandom().primaryKey(),
eventId: uuid("event_id").notNull().references(() => events.id, { onDelete: "cascade" }),
+24
View File
@@ -0,0 +1,24 @@
import { HeadBucketCommand, PutBucketCorsCommand, PutBucketLifecycleConfigurationCommand, S3Client } from "@aws-sdk/client-s3";
// Explicit deployment-only initialization, never run implicitly by web requests.
const origin = new URL(process.env.NEXT_PUBLIC_APP_URL!).origin;
if (!origin.startsWith("https://")) throw new Error("Storage CORS requires an HTTPS app origin");
const bucket = process.env.S3_BUCKET!;
if (!bucket || !process.env.S3_ACCESS_KEY || !process.env.S3_SECRET_KEY) throw new Error("Missing storage configuration");
const client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION,
forcePathStyle: true,
credentials: { accessKeyId: process.env.S3_ACCESS_KEY, secretAccessKey: process.env.S3_SECRET_KEY },
requestChecksumCalculation: "WHEN_REQUIRED",
});
await client.send(new HeadBucketCommand({ Bucket: bucket }));
await client.send(new PutBucketCorsCommand({ Bucket: bucket, CORSConfiguration: { CORSRules: [{
AllowedOrigins: [origin], AllowedMethods: ["GET", "PUT", "HEAD"], AllowedHeaders: ["*"],
ExposeHeaders: ["ETag", "Content-Length"], MaxAgeSeconds: 3600,
}] } }));
await client.send(new PutBucketLifecycleConfigurationCommand({ Bucket: bucket, LifecycleConfiguration: { Rules: [
{ ID: "expire-exports", Status: "Enabled", Filter: { Prefix: "exports/" }, Expiration: { Days: 1 } },
{ ID: "abort-incomplete", Status: "Enabled", Filter: { Prefix: "" }, AbortIncompleteMultipartUpload: { DaysAfterInitiation: 1 } },
] } }));
console.log("Storage CORS and lifecycle configured.");
+3
View File
@@ -2,6 +2,9 @@ export {
createPresignedGetUrl,
createPresignedPutUrl,
deletePrefix,
deleteObject,
getSigningClient,
storageConfig,
ensureBucket,
getObjectBuffer,
headObject,
+12 -2
View File
@@ -1,6 +1,7 @@
import {
CreateBucketCommand,
DeleteObjectsCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadBucketCommand,
HeadObjectCommand,
@@ -67,6 +68,11 @@ export async function ensureBucket() {
if (bucketReady) return;
const client = getSigningClient();
const { bucket } = storageConfig();
if (process.env.NODE_ENV === "production") {
await client.send(new HeadBucketCommand({ Bucket: bucket }));
bucketReady = true;
return;
}
try {
await client.send(new HeadBucketCommand({ Bucket: bucket }));
} catch {
@@ -112,7 +118,7 @@ export async function createPresignedPutUrl(input: {
);
}
export async function createPresignedGetUrl(key: string) {
export async function createPresignedGetUrl(key: string, expiresIn = PRESIGN_GET_SECONDS) {
await ensureBucket();
const { bucket } = storageConfig();
return getSignedUrl(
@@ -121,10 +127,14 @@ export async function createPresignedGetUrl(key: string) {
Bucket: bucket,
Key: key,
}),
{ expiresIn: PRESIGN_GET_SECONDS },
{ expiresIn },
);
}
export async function deleteObject(key: string) {
await getSigningClient().send(new DeleteObjectCommand({ Bucket: storageConfig().bucket, Key: key }));
}
export async function headObject(
key: string,
): Promise<HeadObjectCommandOutput | null> {
+6
View File
@@ -0,0 +1,6 @@
const required = ["DATABASE_URL", "NEXT_PUBLIC_APP_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", "S3_ENDPOINT", "S3_PUBLIC_ENDPOINT", "S3_BUCKET", "S3_ACCESS_KEY", "S3_SECRET_KEY", "EMAIL_FROM", "RESEND_API_KEY", "RESEND_WEBHOOK_SECRET"];
for (const name of required) if (!process.env[name]?.trim()) throw new Error(`Missing production setting: ${name}`);
for (const name of ["NEXT_PUBLIC_APP_URL","BETTER_AUTH_URL","S3_PUBLIC_ENDPOINT"]) if (new URL(process.env[name]!).protocol !== "https:") throw new Error(`${name} must use HTTPS`);
if (process.env.NEXT_PUBLIC_APP_URL !== process.env.BETTER_AUTH_URL) throw new Error("Public and auth URLs must match");
if (process.env.BETTER_AUTH_SECRET!.length < 32 || process.env.BETTER_AUTH_SECRET!.includes("development")) throw new Error("Use a strong random production auth secret");
if (process.env.EMAIL_PROVIDER !== "resend") throw new Error("Production Compose requires Resend");