Audit fixes: VIP self-grant, draft leak, onboarding flag, hero personalization, gated downloads
Deploy GitHub Pages / deploy (push) Canceled after 0s

This commit is contained in:
AHMED
2026-10-09 16:20:55 +03:00
parent c34611dc36
commit 5ee706ebd6
150 changed files with 24054 additions and 0 deletions
+68
View File
@@ -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
}
+91
View File
@@ -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 }
}
+29
View File
@@ -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 ? 'دقائق' : 'دقيقة'}`
}
+46
View File
@@ -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 }
}
}
+6
View File
@@ -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
}
+38
View File
@@ -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',
}
+24
View File
@@ -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
}
}