feat(media): add mvp1 upload pipeline baseline
This commit is contained in:
185
apps/admin/src/app/api/media/upload/route.ts
Normal file
185
apps/admin/src/app/api/media/upload/route.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
import { hasPermission } from "@cms/content/rbac"
|
||||
import { createMediaAsset } from "@cms/db"
|
||||
|
||||
import { auth, resolveRoleFromAuthSession } from "@/lib/auth/server"
|
||||
import { storeUploadLocally } from "@/lib/media/local-storage"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
|
||||
const MAX_UPLOAD_BYTES = Number(process.env.CMS_MEDIA_UPLOAD_MAX_BYTES ?? 25 * 1024 * 1024)
|
||||
|
||||
type AllowedRule = {
|
||||
mimePrefix?: string
|
||||
mimeExact?: string[]
|
||||
}
|
||||
|
||||
const ALLOWED_MIME_BY_TYPE: Record<string, AllowedRule> = {
|
||||
artwork: {
|
||||
mimePrefix: "image/",
|
||||
},
|
||||
banner: {
|
||||
mimePrefix: "image/",
|
||||
},
|
||||
promotion: {
|
||||
mimePrefix: "image/",
|
||||
},
|
||||
video: {
|
||||
mimePrefix: "video/",
|
||||
},
|
||||
gif: {
|
||||
mimeExact: ["image/gif"],
|
||||
},
|
||||
generic: {
|
||||
mimePrefix: "",
|
||||
},
|
||||
}
|
||||
|
||||
function parseTextField(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function parseOptionalField(formData: FormData, field: string): string | undefined {
|
||||
const value = parseTextField(formData, field)
|
||||
return value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function parseTags(formData: FormData): string[] {
|
||||
const value = parseTextField(formData, "tags")
|
||||
|
||||
if (!value) {
|
||||
return []
|
||||
}
|
||||
|
||||
return value
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
function isMimeAllowed(mediaType: string, mimeType: string): boolean {
|
||||
const rule = ALLOWED_MIME_BY_TYPE[mediaType]
|
||||
|
||||
if (!rule) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (rule.mimeExact?.includes(mimeType)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (rule.mimePrefix === "") {
|
||||
return true
|
||||
}
|
||||
|
||||
return rule.mimePrefix ? mimeType.startsWith(rule.mimePrefix) : false
|
||||
}
|
||||
|
||||
function badRequest(message: string): Response {
|
||||
return Response.json(
|
||||
{
|
||||
message,
|
||||
},
|
||||
{ status: 400 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const session = await auth.api
|
||||
.getSession({
|
||||
headers: request.headers,
|
||||
})
|
||||
.catch(() => null)
|
||||
const role = resolveRoleFromAuthSession(session)
|
||||
|
||||
if (!role) {
|
||||
return Response.json(
|
||||
{
|
||||
message: "Unauthorized",
|
||||
},
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
if (!hasPermission(role, "media:write", "team")) {
|
||||
return Response.json(
|
||||
{
|
||||
message: "Missing permission: media:write",
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
const formData = await request.formData().catch(() => null)
|
||||
|
||||
if (!formData) {
|
||||
return badRequest("Invalid form payload.")
|
||||
}
|
||||
|
||||
const title = parseTextField(formData, "title")
|
||||
const type = parseTextField(formData, "type")
|
||||
const fileEntry = formData.get("file")
|
||||
|
||||
if (!title) {
|
||||
return badRequest("Title is required.")
|
||||
}
|
||||
|
||||
if (!type) {
|
||||
return badRequest("Type is required.")
|
||||
}
|
||||
|
||||
if (!(fileEntry instanceof File)) {
|
||||
return badRequest("File is required.")
|
||||
}
|
||||
|
||||
if (fileEntry.size === 0) {
|
||||
return badRequest("File is empty.")
|
||||
}
|
||||
|
||||
if (fileEntry.size > MAX_UPLOAD_BYTES) {
|
||||
return badRequest(
|
||||
`File is too large. Maximum upload is ${Math.floor(MAX_UPLOAD_BYTES / 1024 / 1024)} MB.`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!isMimeAllowed(type, fileEntry.type)) {
|
||||
return badRequest(`File type ${fileEntry.type || "unknown"} is not allowed for ${type}.`)
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = await storeUploadLocally({
|
||||
file: fileEntry,
|
||||
mediaType: type,
|
||||
})
|
||||
|
||||
const created = await createMediaAsset({
|
||||
title,
|
||||
type,
|
||||
description: parseOptionalField(formData, "description"),
|
||||
altText: parseOptionalField(formData, "altText"),
|
||||
source: parseOptionalField(formData, "source"),
|
||||
copyright: parseOptionalField(formData, "copyright"),
|
||||
author: parseOptionalField(formData, "author"),
|
||||
tags: parseTags(formData),
|
||||
storageKey: stored.storageKey,
|
||||
mimeType: fileEntry.type || undefined,
|
||||
sizeBytes: fileEntry.size,
|
||||
isPublished: parseTextField(formData, "isPublished") === "true",
|
||||
})
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
id: created.id,
|
||||
notice: "Media uploaded successfully.",
|
||||
},
|
||||
{ status: 201 },
|
||||
)
|
||||
} catch {
|
||||
return Response.json(
|
||||
{
|
||||
message: "Upload failed. Please try again.",
|
||||
},
|
||||
{ status: 500 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { createMediaAsset, getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { revalidatePath } from "next/cache"
|
||||
import { redirect } from "next/navigation"
|
||||
import { getMediaFoundationSummary, listMediaAssets } from "@cms/db"
|
||||
|
||||
import { AdminShell } from "@/components/admin-shell"
|
||||
import { MediaUploadForm } from "@/components/media/media-upload-form"
|
||||
import { requirePermissionForRoute } from "@/lib/route-guards"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -18,75 +16,6 @@ function readFirstValue(value: string | string[] | undefined): string | null {
|
||||
return value ?? null
|
||||
}
|
||||
|
||||
function readField(formData: FormData, field: string): string {
|
||||
const value = formData.get(field)
|
||||
return typeof value === "string" ? value.trim() : ""
|
||||
}
|
||||
|
||||
function readOptionalField(formData: FormData, field: string): string | undefined {
|
||||
const value = readField(formData, field)
|
||||
return value.length > 0 ? value : undefined
|
||||
}
|
||||
|
||||
function readTags(formData: FormData, field: string): string[] {
|
||||
const raw = readField(formData, field)
|
||||
|
||||
if (!raw) {
|
||||
return []
|
||||
}
|
||||
|
||||
return raw
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter((item) => item.length > 0)
|
||||
}
|
||||
|
||||
function redirectWithState(params: { notice?: string; error?: string }) {
|
||||
const query = new URLSearchParams()
|
||||
|
||||
if (params.notice) {
|
||||
query.set("notice", params.notice)
|
||||
}
|
||||
|
||||
if (params.error) {
|
||||
query.set("error", params.error)
|
||||
}
|
||||
|
||||
const value = query.toString()
|
||||
redirect(value ? `/media?${value}` : "/media")
|
||||
}
|
||||
|
||||
async function createMediaAssetAction(formData: FormData) {
|
||||
"use server"
|
||||
|
||||
await requirePermissionForRoute({
|
||||
nextPath: "/media",
|
||||
permission: "media:write",
|
||||
scope: "team",
|
||||
})
|
||||
|
||||
try {
|
||||
await createMediaAsset({
|
||||
title: readField(formData, "title"),
|
||||
type: readField(formData, "type"),
|
||||
description: readOptionalField(formData, "description"),
|
||||
altText: readOptionalField(formData, "altText"),
|
||||
source: readOptionalField(formData, "source"),
|
||||
copyright: readOptionalField(formData, "copyright"),
|
||||
author: readOptionalField(formData, "author"),
|
||||
tags: readTags(formData, "tags"),
|
||||
})
|
||||
} catch {
|
||||
redirectWithState({
|
||||
error: "Failed to create media asset. Validate required fields and try again.",
|
||||
})
|
||||
}
|
||||
|
||||
revalidatePath("/media")
|
||||
revalidatePath("/portfolio")
|
||||
redirectWithState({ notice: "Media asset created." })
|
||||
}
|
||||
|
||||
export default async function MediaManagementPage({
|
||||
searchParams,
|
||||
}: {
|
||||
@@ -141,97 +70,25 @@ export default async function MediaManagementPage({
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{summary.galleries} galleries · {summary.albums} albums · {summary.categories}{" "}
|
||||
categories{" · "}
|
||||
{summary.tags} tags
|
||||
categories · {summary.tags} tags
|
||||
</p>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<h2 className="text-xl font-medium">Create Media Asset</h2>
|
||||
<form action={createMediaAssetAction} className="mt-4 space-y-3">
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
minLength={1}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Type</span>
|
||||
<select
|
||||
name="type"
|
||||
defaultValue="artwork"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="artwork">artwork</option>
|
||||
<option value="banner">banner</option>
|
||||
<option value="promotion">promotion</option>
|
||||
<option value="video">video</option>
|
||||
<option value="gif">gif</option>
|
||||
<option value="generic">generic</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Description</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Alt text</span>
|
||||
<input
|
||||
name="altText"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Author</span>
|
||||
<input
|
||||
name="author"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Source</span>
|
||||
<input
|
||||
name="source"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Copyright</span>
|
||||
<input
|
||||
name="copyright"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Tags (comma-separated)</span>
|
||||
<input
|
||||
name="tags"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<Button type="submit">Create media asset</Button>
|
||||
</form>
|
||||
<h2 className="text-xl font-medium">Upload Media Asset</h2>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
Files are currently stored via local adapter. S3/object storage is the next incremental
|
||||
step.
|
||||
</p>
|
||||
<MediaUploadForm />
|
||||
</section>
|
||||
|
||||
<section className="rounded-xl border border-neutral-200 p-6">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-xl font-medium">Recent Media Assets</h2>
|
||||
<span className="text-xs uppercase tracking-[0.2em] text-neutral-500">
|
||||
MVP1 Foundation
|
||||
MVP1 Upload Pipeline
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
@@ -240,6 +97,8 @@ export default async function MediaManagementPage({
|
||||
<tr>
|
||||
<th className="py-2 pr-4">Title</th>
|
||||
<th className="py-2 pr-4">Type</th>
|
||||
<th className="py-2 pr-4">MIME</th>
|
||||
<th className="py-2 pr-4">Size</th>
|
||||
<th className="py-2 pr-4">Published</th>
|
||||
<th className="py-2 pr-4">Updated</th>
|
||||
</tr>
|
||||
@@ -247,8 +106,8 @@ export default async function MediaManagementPage({
|
||||
<tbody>
|
||||
{assets.length === 0 ? (
|
||||
<tr>
|
||||
<td className="py-3 text-neutral-500" colSpan={4}>
|
||||
No media assets yet. Upload workflows land in `todo/mvp1-media-upload-pipeline`.
|
||||
<td className="py-3 text-neutral-500" colSpan={6}>
|
||||
No media assets yet. Upload your first asset above.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
@@ -256,6 +115,12 @@ export default async function MediaManagementPage({
|
||||
<tr key={asset.id} className="border-t border-neutral-200">
|
||||
<td className="py-3 pr-4">{asset.title}</td>
|
||||
<td className="py-3 pr-4">{asset.type}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">{asset.mimeType ?? "-"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
{typeof asset.sizeBytes === "number"
|
||||
? `${Math.max(1, Math.round(asset.sizeBytes / 1024))} KB`
|
||||
: "-"}
|
||||
</td>
|
||||
<td className="py-3 pr-4">{asset.isPublished ? "yes" : "no"}</td>
|
||||
<td className="py-3 pr-4 text-neutral-600">
|
||||
{asset.updatedAt.toLocaleDateString("en-US")}
|
||||
|
||||
163
apps/admin/src/components/media/media-upload-form.tsx
Normal file
163
apps/admin/src/components/media/media-upload-form.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
"use client"
|
||||
|
||||
import { Button } from "@cms/ui/button"
|
||||
import { type FormEvent, useState } from "react"
|
||||
|
||||
type MediaType = "artwork" | "banner" | "promotion" | "video" | "gif" | "generic"
|
||||
|
||||
const ACCEPT_BY_TYPE: Record<MediaType, string> = {
|
||||
artwork: "image/jpeg,image/png,image/webp,image/avif,image/gif",
|
||||
banner: "image/jpeg,image/png,image/webp,image/avif",
|
||||
promotion: "image/jpeg,image/png,image/webp,image/avif,image/gif,video/mp4,video/webm",
|
||||
video: "video/mp4,video/webm,video/quicktime",
|
||||
gif: "image/gif",
|
||||
generic: "image/*,video/*",
|
||||
}
|
||||
|
||||
export function MediaUploadForm() {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [mediaType, setMediaType] = useState<MediaType>("artwork")
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault()
|
||||
const form = event.currentTarget
|
||||
const formData = new FormData(form)
|
||||
|
||||
setError(null)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/media/upload", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
message?: string
|
||||
} | null
|
||||
|
||||
setError(payload?.message ?? "Upload failed. Please verify file and metadata.")
|
||||
return
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
notice?: string
|
||||
} | null
|
||||
|
||||
const notice = payload?.notice ?? "Media uploaded."
|
||||
window.location.href = `/media?notice=${encodeURIComponent(notice)}`
|
||||
} catch {
|
||||
setError("Upload request failed. Please retry.")
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
|
||||
{error ? (
|
||||
<p className="rounded border border-red-300 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Title</span>
|
||||
<input
|
||||
name="title"
|
||||
required
|
||||
minLength={1}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Type</span>
|
||||
<select
|
||||
name="type"
|
||||
value={mediaType}
|
||||
onChange={(event) => setMediaType(event.target.value as MediaType)}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="artwork">artwork</option>
|
||||
<option value="banner">banner</option>
|
||||
<option value="promotion">promotion</option>
|
||||
<option value="video">video</option>
|
||||
<option value="gif">gif</option>
|
||||
<option value="generic">generic</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">File</span>
|
||||
<input
|
||||
name="file"
|
||||
type="file"
|
||||
required
|
||||
accept={ACCEPT_BY_TYPE[mediaType]}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Description</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={3}
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Alt text</span>
|
||||
<input
|
||||
name="altText"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Author</span>
|
||||
<input
|
||||
name="author"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Source</span>
|
||||
<input
|
||||
name="source"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Copyright</span>
|
||||
<input
|
||||
name="copyright"
|
||||
className="w-full rounded border border-neutral-300 px-3 py-2 text-sm"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label className="space-y-1">
|
||||
<span className="text-xs text-neutral-600">Tags (comma-separated)</span>
|
||||
<input name="tags" className="w-full rounded border border-neutral-300 px-3 py-2 text-sm" />
|
||||
</label>
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm text-neutral-700">
|
||||
<input name="isPublished" type="checkbox" value="true" className="size-4" />
|
||||
Publish immediately
|
||||
</label>
|
||||
|
||||
<Button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Uploading..." : "Upload media"}
|
||||
</Button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
66
apps/admin/src/lib/media/local-storage.ts
Normal file
66
apps/admin/src/lib/media/local-storage.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import path from "node:path"
|
||||
|
||||
type StoreUploadParams = {
|
||||
file: File
|
||||
mediaType: string
|
||||
}
|
||||
|
||||
type StoredUpload = {
|
||||
storageKey: string
|
||||
}
|
||||
|
||||
const FALLBACK_EXTENSION = "bin"
|
||||
|
||||
function resolveBaseDirectory(): string {
|
||||
const configured = process.env.CMS_MEDIA_LOCAL_STORAGE_DIR?.trim()
|
||||
|
||||
if (configured) {
|
||||
return path.resolve(configured)
|
||||
}
|
||||
|
||||
return path.resolve(process.cwd(), ".data", "media")
|
||||
}
|
||||
|
||||
function normalizeSegment(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
}
|
||||
|
||||
function extensionFromFilename(fileName: string): string {
|
||||
const extension = path.extname(fileName).slice(1)
|
||||
|
||||
if (!extension) {
|
||||
return FALLBACK_EXTENSION
|
||||
}
|
||||
|
||||
const normalized = normalizeSegment(extension)
|
||||
|
||||
return normalized.length > 0 ? normalized : FALLBACK_EXTENSION
|
||||
}
|
||||
|
||||
function buildStorageKey(mediaType: string, fileName: string): string {
|
||||
const now = new Date()
|
||||
const year = String(now.getUTCFullYear())
|
||||
const month = String(now.getUTCMonth() + 1).padStart(2, "0")
|
||||
const normalizedType = normalizeSegment(mediaType) || "generic"
|
||||
const extension = extensionFromFilename(fileName)
|
||||
|
||||
return [normalizedType, year, month, `${randomUUID()}.${extension}`].join("/")
|
||||
}
|
||||
|
||||
export async function storeUploadLocally(params: StoreUploadParams): Promise<StoredUpload> {
|
||||
const storageKey = buildStorageKey(params.mediaType, params.file.name)
|
||||
const baseDirectory = resolveBaseDirectory()
|
||||
const outputPath = path.join(baseDirectory, storageKey)
|
||||
|
||||
await mkdir(path.dirname(outputPath), { recursive: true })
|
||||
|
||||
const bytes = new Uint8Array(await params.file.arrayBuffer())
|
||||
await writeFile(outputPath, bytes)
|
||||
|
||||
return { storageKey }
|
||||
}
|
||||
Reference in New Issue
Block a user