feat: add AWS media resource management

This commit is contained in:
Codex
2026-05-08 23:40:42 +08:00
parent bf28e37ae5
commit 4e226e7eda
51 changed files with 6796 additions and 199 deletions
@@ -0,0 +1,75 @@
import { NextResponse, type NextRequest } from 'next/server'
import { getPayloadClient } from '@/lib/payload'
import { requireMediaUploadAdmin } from '@/lib/aws-media-upload'
function getRelationshipId(value: unknown) {
if (typeof value === 'number' || typeof value === 'string') return value
if (value && typeof value === 'object' && 'id' in value) {
const id = (value as { id?: unknown }).id
return typeof id === 'number' || typeof id === 'string' ? id : null
}
return null
}
function getName(value: unknown) {
return value && typeof value === 'object' && 'name' in value && typeof (value as { name?: unknown }).name === 'string'
? (value as { name: string }).name
: null
}
function normalizeLocale(value: unknown) {
return value === 'en' || value === 'zh' ? value : undefined
}
export async function GET(request: NextRequest) {
const auth = await requireMediaUploadAdmin(request)
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const payload = await getPayloadClient()
const locale = normalizeLocale(request.nextUrl.searchParams.get('locale'))
const result = await payload.find({
collection: 'mediaAssetCategories',
depth: 1,
limit: 300,
locale,
sort: 'name',
where: {
and: [
{
parent: {
exists: true,
},
},
...(locale
? [
{
docLocale: {
equals: locale,
},
},
]
: []),
],
},
})
return NextResponse.json({
items: result.docs.map((category) => {
const parentName = getName(category.parent)
return {
id: category.id,
label: parentName ? `${parentName} / ${category.name}` : category.name,
parentId: getRelationshipId(category.parent),
parentName,
slug: category.slug,
}
}),
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list AWS media categories.'
return NextResponse.json({ error: message }, { status: 500 })
}
}
@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireMediaUploadAdmin } from '@/lib/aws-media-upload'
import {
buildAwsMediaAssetDefaults,
findAwsMediaAssetByUrl,
getAwsMediaResource,
resolveAwsMediaResourceCategory,
} from '@/lib/aws-media-resources'
import { isAwsMediaImageFormat } from '@/lib/aws-media-metadata'
import { createAwsMediaImageThumbnail, createAwsMediaPlaceholderThumbnail } from '@/lib/aws-media-thumbnail'
import { getPayloadClient } from '@/lib/payload'
export const runtime = 'nodejs'
function normalizeLocale(value: unknown) {
return value === 'en' || value === 'zh' ? value : null
}
function getEditUrl(id: number | string, locale: string | null) {
return `/admin/collections/mediaAssets/${id}${locale ? `?locale=${locale}` : ''}`
}
export async function POST(request: NextRequest) {
const auth = await requireMediaUploadAdmin(request)
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const body = await request.json()
const key = typeof body?.key === 'string' ? body.key : ''
const locale = normalizeLocale(body?.locale)
const item = await getAwsMediaResource(key)
const existing = await findAwsMediaAssetByUrl(item.publicUrl, locale)
if (existing) {
return NextResponse.json({
asset: {
editUrl: getEditUrl(existing.id, locale),
id: existing.id,
title: existing.title,
},
created: false,
})
}
const category = await resolveAwsMediaResourceCategory(key, locale)
if (!category) {
throw new Error('Could not infer a secondary media asset category from the S3 key.')
}
const defaults = buildAwsMediaAssetDefaults(item)
const thumbnail = isAwsMediaImageFormat(item.fileFormat)
? await createAwsMediaImageThumbnail({
alt: defaults.title,
filename: item.filename,
url: item.publicUrl,
})
: await createAwsMediaPlaceholderThumbnail({
alt: defaults.title,
filename: item.filename,
format: item.fileFormat,
url: item.publicUrl,
})
if (!thumbnail?.id) {
throw new Error('Failed to create a thumbnail for this AWS resource.')
}
const docLocale = locale || (category.docLocale === 'en' || category.docLocale === 'zh' ? category.docLocale : 'en')
const payload = await getPayloadClient()
const created = await payload.create({
collection: 'mediaAssets',
data: {
category: category.id,
docLocale,
externalUrl: defaults.url,
fileFormat: defaults.fileFormat,
fileSize: defaults.fileSize,
openOriginalDownload: true,
status: 'draft',
thumbnail: thumbnail.id,
title: defaults.title,
type: defaults.type,
},
locale: docLocale,
})
return NextResponse.json({
asset: {
editUrl: getEditUrl(created.id, docLocale),
id: created.id,
title: created.title,
},
created: true,
})
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create media asset from AWS resource.'
return NextResponse.json({ error: message }, { status: 400 })
}
}
@@ -0,0 +1,19 @@
import { NextResponse, type NextRequest } from 'next/server'
import { refreshAwsMediaResourceCache } from '@/lib/aws-media-resources'
import { requireMediaUploadAdmin } from '@/lib/aws-media-upload'
export async function POST(request: NextRequest) {
const auth = await requireMediaUploadAdmin(request)
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const body = await request.json().catch(() => null)
const key = typeof body?.key === 'string' ? body.key : ''
const result = await refreshAwsMediaResourceCache(key)
return NextResponse.json(result)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to refresh AWS media resource cache.'
return NextResponse.json({ error: message }, { status: 400 })
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse, type NextRequest } from 'next/server'
import { deleteAwsMediaResource, listAwsMediaResources } from '@/lib/aws-media-resources'
import { requireMediaUploadAdmin } from '@/lib/aws-media-upload'
export async function GET(request: NextRequest) {
const auth = await requireMediaUploadAdmin(request)
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const params = request.nextUrl.searchParams
const result = await listAwsMediaResources({
cursor: params.get('cursor'),
limit: Number(params.get('limit') || 20),
locale: params.get('locale'),
query: params.get('query'),
})
return NextResponse.json(result)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to list AWS media resources.'
return NextResponse.json({ error: message }, { status: 500 })
}
}
export async function DELETE(request: NextRequest) {
const auth = await requireMediaUploadAdmin(request)
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const body = await request.json().catch(() => null)
const key = typeof body?.key === 'string' ? body.key : ''
const result = await deleteAwsMediaResource(key)
return NextResponse.json(result)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to delete AWS media resource.'
return NextResponse.json({ error: message }, { status: 400 })
}
}
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { abortAwsMediaUpload, requireMediaUploadAdmin } from '@/lib/aws-media-upload'
export const runtime = 'nodejs'
export async function POST(request: NextRequest) {
const auth = await requireMediaUploadAdmin(request)
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const body = await request.json()
await abortAwsMediaUpload({
key: body.key,
uploadId: body.uploadId,
})
return NextResponse.json({ ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to abort upload.'
return NextResponse.json({ error: message }, { status: 400 })
}
}
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from 'next/server'
import { completeAwsMediaUpload, requireMediaUploadAdmin } from '@/lib/aws-media-upload'
export const runtime = 'nodejs'
export async function POST(request: NextRequest) {
const auth = await requireMediaUploadAdmin(request)
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const body = await request.json()
const result = await completeAwsMediaUpload({
key: body.key,
parts: body.parts,
publicUrl: body.publicUrl,
uploadId: body.uploadId,
})
return NextResponse.json(result)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to complete upload.'
return NextResponse.json({ error: message }, { status: 400 })
}
}
@@ -0,0 +1,25 @@
import { NextRequest, NextResponse } from 'next/server'
import { createAwsMediaUpload, requireMediaUploadAdmin } from '@/lib/aws-media-upload'
export const runtime = 'nodejs'
export async function POST(request: NextRequest) {
const auth = await requireMediaUploadAdmin(request)
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const body = await request.json()
const result = await createAwsMediaUpload({
categoryId: body.categoryId,
contentType: body.contentType,
filename: body.filename,
size: body.size,
target: body.target,
})
return NextResponse.json(result)
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create upload URL.'
return NextResponse.json({ error: message }, { status: 400 })
}
}
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server'
import {
getCloudFrontPathForKey,
getKeyFromAwsMediaUrl,
invalidateAwsMediaCache,
requireMediaUploadAdmin,
} from '@/lib/aws-media-upload'
export const runtime = 'nodejs'
export async function POST(request: NextRequest) {
const auth = await requireMediaUploadAdmin(request)
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const body = await request.json()
const url = typeof body.url === 'string' ? body.url : ''
const key = getKeyFromAwsMediaUrl(url)
if (!key) {
return NextResponse.json({ error: 'Only configured AWS CDN URLs can be refreshed.' }, { status: 400 })
}
await invalidateAwsMediaCache([getCloudFrontPathForKey(key)])
return NextResponse.json({ ok: true })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to refresh CDN cache.'
return NextResponse.json({ error: message }, { status: 400 })
}
}
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
import { requireMediaUploadAdmin } from '@/lib/aws-media-upload'
import { createAwsMediaImageThumbnail } from '@/lib/aws-media-thumbnail'
export const runtime = 'nodejs'
export async function POST(request: NextRequest) {
const auth = await requireMediaUploadAdmin(request)
if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status })
try {
const body = await request.json()
const url = typeof body?.url === 'string' ? body.url : ''
const filename = typeof body?.filename === 'string' ? body.filename : 'aws-media-resource'
const alt = typeof body?.alt === 'string' && body.alt.trim() ? body.alt.trim() : filename
const thumbnail = await createAwsMediaImageThumbnail({ alt, filename, url })
return NextResponse.json({ thumbnail })
} catch (error) {
const message = error instanceof Error ? error.message : 'Failed to create AWS media thumbnail.'
return NextResponse.json({ error: message }, { status: 400 })
}
}
@@ -0,0 +1,103 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload'
function cleanFilenamePart(value: string | null | undefined, fallback: string) {
const cleaned = value
?.trim()
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return cleaned || fallback
}
function buildFilename(name: string | null | undefined, format: string | null | undefined) {
const filename = cleanFilenamePart(name, 'media-assets')
const extension = format?.trim().replace(/^\./, '').toLowerCase()
return `${filename}${extension ? `.${extension}` : '.zip'}`
}
function getOriginalFilenameFromUrl(value: string) {
try {
const parsed = new URL(value, 'http://localhost')
const filename = decodeURIComponent(parsed.pathname.split('/').filter(Boolean).pop() || '')
const cleaned = filename
.trim()
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/^-|-$/g, '')
return cleaned && cleaned.includes('.') ? cleaned : null
} catch {
return null
}
}
function contentDisposition(filename: string) {
const ascii = filename.replace(/[^\x20-\x7E]/g, '_')
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`
}
function resolveDownloadUrl(value: string, request: NextRequest) {
const trimmed = value.trim()
if (trimmed.startsWith('/')) return new URL(trimmed, request.url).toString()
const parsed = new URL(trimmed)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Unsupported download URL protocol.')
}
return parsed.toString()
}
async function proxyAttachment(url: string, filename: string, request: NextRequest) {
const resolvedUrl = resolveDownloadUrl(url, request)
const upstream = await fetch(resolvedUrl, {
cache: 'no-store',
headers: {
Accept: '*/*',
},
})
if (!upstream.ok || !upstream.body) {
return NextResponse.json({ error: 'Download source is not available.' }, { status: 502 })
}
const headers = new Headers()
headers.set('Content-Disposition', contentDisposition(getOriginalFilenameFromUrl(resolvedUrl) || filename))
headers.set('Content-Type', upstream.headers.get('content-type') || 'application/octet-stream')
headers.set('Cache-Control', 'private, no-store')
const contentLength = upstream.headers.get('content-length')
if (contentLength) headers.set('Content-Length', contentLength)
return new NextResponse(upstream.body, {
headers,
status: 200,
})
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const payload = await getPayloadClient()
const category = await payload.findByID({
collection: 'mediaAssetCategories',
depth: 0,
id,
})
if (!category.archiveDownloadUrl) {
return NextResponse.json({ error: 'Archive download not found.' }, { status: 404 })
}
return proxyAttachment(
category.archiveDownloadUrl,
buildFilename(category.name, category.archiveFormat),
request,
)
}
@@ -0,0 +1,99 @@
import { NextRequest, NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload'
function cleanFilenamePart(value: string | null | undefined, fallback: string) {
const cleaned = value
?.trim()
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
return cleaned || fallback
}
function buildFilename(title: string | null | undefined, format: string | null | undefined) {
const name = cleanFilenamePart(title, 'media-asset')
const extension = format?.trim().replace(/^\./, '').toLowerCase()
return `${name}${extension ? `.${extension}` : ''}`
}
function getOriginalFilenameFromUrl(value: string) {
try {
const parsed = new URL(value, 'http://localhost')
const filename = decodeURIComponent(parsed.pathname.split('/').filter(Boolean).pop() || '')
const cleaned = filename
.trim()
.replace(/[/\\?%*:|"<>]/g, '-')
.replace(/^-|-$/g, '')
return cleaned && cleaned.includes('.') ? cleaned : null
} catch {
return null
}
}
function contentDisposition(filename: string) {
const ascii = filename.replace(/[^\x20-\x7E]/g, '_')
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`
}
function resolveDownloadUrl(value: string, request: NextRequest) {
const trimmed = value.trim()
if (trimmed.startsWith('/')) return new URL(trimmed, request.url).toString()
const parsed = new URL(trimmed)
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
throw new Error('Unsupported download URL protocol.')
}
return parsed.toString()
}
async function proxyAttachment(url: string, filename: string, request: NextRequest) {
const resolvedUrl = resolveDownloadUrl(url, request)
const upstream = await fetch(resolvedUrl, {
cache: 'no-store',
headers: {
Accept: '*/*',
},
})
if (!upstream.ok || !upstream.body) {
return NextResponse.json({ error: 'Download source is not available.' }, { status: 502 })
}
const headers = new Headers()
headers.set('Content-Disposition', contentDisposition(getOriginalFilenameFromUrl(resolvedUrl) || filename))
headers.set('Content-Type', upstream.headers.get('content-type') || 'application/octet-stream')
headers.set('Cache-Control', 'private, no-store')
const contentLength = upstream.headers.get('content-length')
if (contentLength) headers.set('Content-Length', contentLength)
return new NextResponse(upstream.body, {
headers,
status: 200,
})
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id } = await params
const payload = await getPayloadClient()
const asset = await payload.findByID({
collection: 'mediaAssets',
depth: 0,
id,
})
if (!asset.externalUrl || asset.status !== 'published') {
return NextResponse.json({ error: 'Download not found.' }, { status: 404 })
}
return proxyAttachment(asset.externalUrl, buildFilename(asset.title, asset.fileFormat), request)
}