Prepare production and Coolify deployment with original exports and datetime pickers
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/.turbo
|
||||
.git
|
||||
**/.env*
|
||||
**/*.log
|
||||
**/*.tsbuildinfo
|
||||
.secrets
|
||||
coverage
|
||||
@@ -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
|
||||
@@ -4,6 +4,7 @@ node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
coverage
|
||||
|
||||
+37
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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, unchanged—including 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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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 device’s 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));
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)),
|
||||
]);
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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.");
|
||||
@@ -2,6 +2,9 @@ export {
|
||||
createPresignedGetUrl,
|
||||
createPresignedPutUrl,
|
||||
deletePrefix,
|
||||
deleteObject,
|
||||
getSigningClient,
|
||||
storageConfig,
|
||||
ensureBucket,
|
||||
getObjectBuffer,
|
||||
headObject,
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
Reference in New Issue
Block a user