Audit fixes: VIP self-grant, draft leak, onboarding flag, hero personalization, gated downloads
Deploy GitHub Pages / deploy (push) Canceled after 0s
Deploy GitHub Pages / deploy (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
import type { Payload } from 'payload'
|
||||
|
||||
/* Work-file handling for the agent API: accepts inline base64 files, stores
|
||||
them as Payload `files` documents, and returns their ids + access so the
|
||||
agent gets downloadable URLs in the response. Mirrors agent-media.ts. */
|
||||
|
||||
const MAX_FILES = 5
|
||||
const MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
const MIME_BY_EXT: Record<string, string> = {
|
||||
pdf: 'application/pdf',
|
||||
zip: 'application/zip',
|
||||
md: 'text/markdown',
|
||||
csv: 'text/csv',
|
||||
txt: 'text/plain',
|
||||
json: 'application/json',
|
||||
}
|
||||
|
||||
export type FileInput = {
|
||||
/** Destination filename with a known extension, e.g. "agent-state.md". */
|
||||
filename?: string
|
||||
/** Raw base64 of the file bytes. */
|
||||
dataBase64?: string
|
||||
/** Per-file gate; defaults to 'vip'. */
|
||||
access?: string
|
||||
}
|
||||
|
||||
export type ResolvedFile = { id: number; filename: string; access: 'free' | 'vip' }
|
||||
|
||||
/** Best-effort rollback when the post write itself fails — never orphan
|
||||
uploaded files (payload.delete also removes the bytes from disk). */
|
||||
export async function deleteFileDocs(payload: Payload, ids: number[]): Promise<void> {
|
||||
await Promise.all(ids.map(id => payload.delete({ collection: 'files', id, overrideAccess: true }).catch(() => null)))
|
||||
}
|
||||
|
||||
export async function resolveAttachments(payload: Payload, input: unknown): Promise<ResolvedFile[]> {
|
||||
if (!Array.isArray(input) || input.length === 0) {
|
||||
throw new Error('attachments: expected a non-empty array of { filename, dataBase64, access? }')
|
||||
}
|
||||
if (input.length > MAX_FILES) throw new Error(`attachments: max ${MAX_FILES} files per post`)
|
||||
|
||||
const resolved: ResolvedFile[] = []
|
||||
for (const item of input) {
|
||||
const spec = item as FileInput // one-line reason: boundary type for the documented request field
|
||||
const filename = typeof spec?.filename === 'string' ? spec.filename.trim() : ''
|
||||
const dataBase64 = typeof spec?.dataBase64 === 'string' ? spec.dataBase64 : ''
|
||||
if (!filename || !dataBase64) throw new Error('attachments: each entry needs filename and dataBase64')
|
||||
|
||||
const ext = filename.split('.').pop()?.toLowerCase() ?? ''
|
||||
const mime = MIME_BY_EXT[ext]
|
||||
if (!mime) throw new Error(`attachments: unsupported extension "${ext}" on "${filename}" (allowed: pdf, zip, md, csv, txt, json)`)
|
||||
|
||||
const buffer = Buffer.from(dataBase64.replace(/^data:[^;]+;base64,/, ''), 'base64')
|
||||
if (buffer.length === 0) throw new Error(`attachments: "${filename}" is empty`)
|
||||
if (buffer.length > MAX_BYTES) throw new Error(`attachments: "${filename}" exceeds the 5MB limit (${buffer.length} bytes)`)
|
||||
|
||||
const access: ResolvedFile['access'] = spec.access === 'free' ? 'free' : 'vip'
|
||||
const doc = await payload.create({
|
||||
collection: 'files',
|
||||
data: { label: filename, access },
|
||||
file: { data: buffer, mimetype: mime, name: filename, size: buffer.length },
|
||||
draft: false,
|
||||
overrideAccess: true,
|
||||
})
|
||||
resolved.push({ id: doc.id, filename, access })
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { Payload } from 'payload'
|
||||
import type { Media } from '@payload-types'
|
||||
|
||||
/* Cover-image resolution for the agent API: accepts an inline base64 image or
|
||||
a remote URL, stores it as a Payload `media` document, and returns its id +
|
||||
public URL. Keeps AGENT clients from needing a second authenticated surface
|
||||
(media create is admin-only in the REST API). */
|
||||
|
||||
const MAX_BYTES = 5 * 1024 * 1024
|
||||
|
||||
/** Static literal lookup — project rule: Record over Set for static keys. */
|
||||
const ALLOWED_MIME: Record<string, true> = {
|
||||
'image/jpeg': true,
|
||||
'image/jpg': true,
|
||||
'image/png': true,
|
||||
'image/webp': true,
|
||||
}
|
||||
|
||||
const EXT_BY_MIME: Record<string, string> = {
|
||||
'image/jpeg': 'jpg',
|
||||
'image/jpg': 'jpg',
|
||||
'image/png': 'png',
|
||||
'image/webp': 'webp',
|
||||
}
|
||||
|
||||
export type CoverImageInput = {
|
||||
/** Destination filename, e.g. "my-guide-cover.jpg". Extension must match the mime type. */
|
||||
filename?: string
|
||||
/** One of these two required: raw base64 of the image bytes, or a fetchable URL. */
|
||||
dataBase64?: string
|
||||
url?: string
|
||||
/** Optional override when sending via url (else derived from the URL path or Content-Type). */
|
||||
mimeType?: string
|
||||
}
|
||||
|
||||
export type ResolvedCover = { id: number; url: string }
|
||||
|
||||
function pickMime(candidate: string | undefined, filename: string, buffer: Buffer): string {
|
||||
if (candidate) return candidate.toLowerCase()
|
||||
// Magic-byte sniffing fallback (jpg/png/webp)
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg'
|
||||
if (buffer[0] === 0x89 && buffer[1] === 0x50) return 'image/png'
|
||||
if (buffer.length > 12 && buffer.toString('ascii', 0, 4) === 'RIFF' && buffer.toString('ascii', 8, 12) === 'WEBP') return 'image/webp'
|
||||
const ext = filename.split('.').pop()?.toLowerCase()
|
||||
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg'
|
||||
if (ext === 'png') return 'image/png'
|
||||
if (ext === 'webp') return 'image/webp'
|
||||
throw new Error('coverImage: cannot determine mime type — pass mimeType or use a standard extension')
|
||||
}
|
||||
|
||||
export async function resolveCoverImage(payload: Payload, input: unknown): Promise<ResolvedCover> {
|
||||
if (!input || typeof input !== 'object') throw new Error('coverImage: expected object { filename?, mimeType?, dataBase64? | url? }')
|
||||
const spec = input as CoverImageInput // one-line reason: boundary type for the documented request field
|
||||
|
||||
let buffer: Buffer
|
||||
let filename: string
|
||||
let mime: string
|
||||
|
||||
if (typeof spec.dataBase64 === 'string' && spec.dataBase64.length > 0) {
|
||||
buffer = Buffer.from(spec.dataBase64.replace(/^data:[^;]+;base64,/, ''), 'base64')
|
||||
filename = spec.filename?.trim() || `agent-cover.${EXT_BY_MIME[spec.mimeType ?? ''] ?? 'jpg'}`
|
||||
mime = pickMime(spec.mimeType, filename, buffer)
|
||||
} else if (typeof spec.url === 'string' && spec.url.length > 0) {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(spec.url, { redirect: 'follow' })
|
||||
} catch (err) {
|
||||
throw new Error(`coverImage: failed to fetch url — ${(err as Error).message}`)
|
||||
}
|
||||
if (!res.ok) throw new Error(`coverImage: url responded ${res.status}`)
|
||||
buffer = Buffer.from(await res.arrayBuffer())
|
||||
const urlPath = new URL(res.url || spec.url).pathname
|
||||
filename = spec.filename?.trim() || decodeURIComponent(urlPath.split('/').filter(Boolean).pop() || '') || 'agent-cover.jpg'
|
||||
mime = pickMime(spec.mimeType || res.headers.get('content-type')?.split(';')[0] || undefined, filename, buffer)
|
||||
} else {
|
||||
throw new Error('coverImage: provide dataBase64 or url')
|
||||
}
|
||||
|
||||
if (buffer.length === 0) throw new Error('coverImage: empty image')
|
||||
if (buffer.length > MAX_BYTES) throw new Error(`coverImage: image exceeds 5MB limit (${buffer.length} bytes)`)
|
||||
if (!ALLOWED_MIME[mime]) throw new Error(`coverImage: unsupported mime "${mime}" (allowed: jpeg, png, webp)`)
|
||||
|
||||
const doc: Media = await payload.create({
|
||||
collection: 'media',
|
||||
data: {},
|
||||
file: { data: buffer, mimetype: mime, name: filename, size: buffer.length },
|
||||
draft: false,
|
||||
})
|
||||
const url = doc.url ?? `/api/media/file/${doc.filename ?? filename}`
|
||||
return { id: doc.id, url }
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/* Western digits everywhere for consistency with reading times, prices and code. */
|
||||
const dateFormat = new Intl.DateTimeFormat('ar-u-nu-latn', { day: 'numeric', month: 'long', year: 'numeric' })
|
||||
const numberFormat = new Intl.NumberFormat('en-US')
|
||||
|
||||
export function formatDate(value: string | Date | null | undefined): string {
|
||||
if (!value) return ''
|
||||
const date = value instanceof Date ? value : new Date(value)
|
||||
return Number.isNaN(date.getTime()) ? '' : dateFormat.format(date)
|
||||
}
|
||||
|
||||
export const formatNumber = (value: number | null | undefined) => numberFormat.format(value ?? 0)
|
||||
|
||||
/* Single source for the displayed membership price. */
|
||||
export const MEMBERSHIP_PRICE = '$10'
|
||||
|
||||
/* Arabic count agreement for «دليل»: 1 دليل، 2 دليلان، 3–10 أدلة، 11+ دليلاً. */
|
||||
export function guidesLabel(n: number): string {
|
||||
if (n === 0) return 'لا أدلة'
|
||||
if (n === 1) return 'دليل واحد'
|
||||
if (n === 2) return 'دليلان'
|
||||
return `${formatNumber(n)} ${n <= 10 ? 'أدلة' : 'دليلاً'}`
|
||||
}
|
||||
|
||||
export function minutesLabel(n: number | null | undefined): string {
|
||||
const value = n ?? 0
|
||||
if (value === 1) return 'دقيقة واحدة'
|
||||
if (value === 2) return 'دقيقتان'
|
||||
return `${formatNumber(value)} ${value % 100 >= 3 && value % 100 <= 10 ? 'دقائق' : 'دقيقة'}`
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/* Cloudflare Email Sending (REST) — activates only when CLOUDFLARE_API_TOKEN
|
||||
and CLOUDFLARE_ACCOUNT_ID are set. Without them, sendVerificationEmail
|
||||
returns { sent: false } and the UI degrades to a clear status message; the
|
||||
flow itself never breaks. Single-shot: no retry (resend route re-invokes). */
|
||||
export async function sendVerificationEmail(to: string, token: string): Promise<{ sent: boolean }> {
|
||||
const apiToken = process.env.CLOUDFLARE_API_TOKEN
|
||||
const accountId = process.env.CLOUDFLARE_ACCOUNT_ID
|
||||
if (!apiToken || !accountId) return { sent: false }
|
||||
|
||||
const siteUrl = process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000'
|
||||
const link = `${siteUrl}/api/auth/verify-email?token=${encodeURIComponent(token)}`
|
||||
|
||||
try {
|
||||
const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${accountId}/email/sending/send`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiToken}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
to,
|
||||
from: { address: process.env.MAIL_FROM ?? 'noreply@anwarpy.ai', name: 'أنور بي آي' },
|
||||
subject: 'أكد بريدك — أنور بي آي',
|
||||
html: `<p>مرحباً بك في أنور بي آي.</p>
|
||||
<p>أكّد بريدك بفتح هذا الرابط:</p>
|
||||
<p><a href="${link}">${link}</a></p>
|
||||
<p>إن لم تطلب هذا الحساب، تجاهل هذه الرسالة.</p>`,
|
||||
text: `مرحباً بك في أنور بي آي.\n\nأكّد بريدك بفتح هذا الرابط:\n${link}\n\nإن لم تطلب هذا الحساب، تجاهل هذه الرسالة.`,
|
||||
}),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
console.error('cloudflare email sending failed:', res.status, await res.text())
|
||||
return { sent: false }
|
||||
}
|
||||
const json = (await res.json()) as { success?: boolean }
|
||||
if (!json.success) {
|
||||
console.error('cloudflare email sending rejected:', JSON.stringify(json))
|
||||
return { sent: false }
|
||||
}
|
||||
return { sent: true }
|
||||
} catch (err) {
|
||||
console.error('cloudflare email sending threw:', err)
|
||||
return { sent: false }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
/* QA-R02: `next` must be a same-origin relative path. Rejects protocol-relative
|
||||
URLs (//evil.com), backslashes (\// trick), and control chars. */
|
||||
export function safeNext(v: string | null | undefined): string | null {
|
||||
if (!v || !v.startsWith('/') || v.startsWith('//') || v.includes('\\') || v.includes('\n')) return null
|
||||
return v
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* One taxonomy for the home page and the library.
|
||||
CMS categories are fine-grained; readers browse four learning paths.
|
||||
Beginner-level guides also appear under «الأساسيات». */
|
||||
export type TopicKey = 'agents' | 'models' | 'automation' | 'foundations'
|
||||
|
||||
export const TOPICS: { key: TopicKey; label: string; hint: string }[] = [
|
||||
{ key: 'agents', label: 'الوكلاء', hint: 'تخطيط · حلقات · سلامة' },
|
||||
{ key: 'models', label: 'النماذج', hint: 'اختيار · تقييم · استرجاع' },
|
||||
{ key: 'automation', label: 'الأتمتة', hint: 'أدوات · أعمال · إنتاج' },
|
||||
{ key: 'foundations', label: 'الأساسيات', hint: 'أدلة للمبتدئين' },
|
||||
]
|
||||
|
||||
const CATEGORY_TOPIC: Record<string, TopicKey> = {
|
||||
'وكلاء': 'agents',
|
||||
'سلامة': 'agents',
|
||||
'نماذج': 'models',
|
||||
'استرجاع': 'models',
|
||||
'أدوات': 'automation',
|
||||
'أعمال': 'automation',
|
||||
}
|
||||
|
||||
export function topicsFor(post: { category?: string | null; level?: string | null }): TopicKey[] {
|
||||
const topics: TopicKey[] = [CATEGORY_TOPIC[post.category ?? ''] ?? 'agents']
|
||||
if (post.level === 'مبتدئ') topics.push('foundations')
|
||||
return topics
|
||||
}
|
||||
|
||||
export const isTopic = (value: string | null): value is TopicKey =>
|
||||
TOPICS.some(topic => topic.key === value)
|
||||
|
||||
/* Member-onboarding interest → library topic, for personalization boosts
|
||||
(home hero + articles ordering). Keys match the `interest` select in Users. */
|
||||
export const INTEREST_MAP: Record<string, TopicKey> = {
|
||||
'الوكلاء': 'agents',
|
||||
'RAG': 'models',
|
||||
'الأتمتة': 'automation',
|
||||
'النماذج المفتوحة': 'models',
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { getPayload } from 'payload'
|
||||
import { headers } from 'next/headers'
|
||||
import configPromise from '@payload-config'
|
||||
|
||||
export type Viewer = { email: string; role: string; id?: number; interest?: string | null; level?: string | null; emailVerified?: boolean; onboarded?: boolean } | null
|
||||
|
||||
export async function getViewer(): Promise<Viewer> {
|
||||
try {
|
||||
const payload = await getPayload({ config: configPromise })
|
||||
const { user } = await payload.auth({ headers: await headers() })
|
||||
if (!user) return null
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: (user.role as string) ?? 'free',
|
||||
interest: (user.interest as string | null) ?? null,
|
||||
level: (user.level as string | null) ?? null,
|
||||
emailVerified: !!user.emailVerified,
|
||||
onboarded: !!user.onboarded,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user