first commit
ci / build (push) Failing after 23s
deploy / deploy (push) Failing after 10m12s

This commit is contained in:
soroush.asadi
2026-05-31 12:47:02 +03:30
commit add78d8460
100 changed files with 15221 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import {
SESSION_COOKIE,
SESSION_MAX_AGE,
createSession,
verifyPassword,
} from '@/lib/auth/session';
export const runtime = 'nodejs';
export async function POST(req: Request) {
let password = '';
try {
const body = await req.json();
password = typeof body?.password === 'string' ? body.password : '';
} catch {
return NextResponse.json({ error: 'bad request' }, { status: 400 });
}
if (!(await verifyPassword(password))) {
// Small constant delay-ish guard; password compare is already constant-time.
return NextResponse.json({ error: 'invalid' }, { status: 401 });
}
const token = await createSession();
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, token, {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: SESSION_MAX_AGE,
});
return res;
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from 'next/server';
import { SESSION_COOKIE } from '@/lib/auth/session';
export const runtime = 'nodejs';
export async function POST() {
const res = NextResponse.json({ ok: true });
res.cookies.set(SESSION_COOKIE, '', {
httpOnly: true,
sameSite: 'lax',
secure: process.env.NODE_ENV === 'production',
path: '/',
maxAge: 0,
});
return res;
}
+97
View File
@@ -0,0 +1,97 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { setSection, resetSection } from '@/lib/db/store';
import {
POSTS_KEY,
loadPost,
loadPostOverrides,
isKnownSlug,
} from '@/lib/content/posts-store';
import type { PostContent } from '@/lib/content/posts';
export const runtime = 'nodejs';
const ACCENTS = ['electric', 'violet', 'magenta', 'emerald', 'cyan'];
/** Minimal structural validation for an incoming PostContent payload. */
function validPost(data: unknown): data is {
date: string;
accent: string;
en: { lead: string; blocks: unknown[] };
fa: { lead: string; blocks: unknown[] };
} {
if (!data || typeof data !== 'object') return false;
const d = data as Record<string, unknown>;
if (typeof d.date !== 'string') return false;
if (typeof d.accent !== 'string' || !ACCENTS.includes(d.accent)) return false;
for (const loc of ['en', 'fa'] as const) {
const art = d[loc] as Record<string, unknown> | undefined;
if (!art || typeof art !== 'object') return false;
if (typeof art.lead !== 'string') return false;
if (!Array.isArray(art.blocks)) return false;
}
return true;
}
// GET ?slug=rag-eval-framework -> live (merged) article + override flag
export async function GET(req: Request) {
const slug = new URL(req.url).searchParams.get('slug') ?? '';
if (!isKnownSlug(slug)) {
return NextResponse.json({ error: 'unknown post' }, { status: 400 });
}
return NextResponse.json({
slug,
post: loadPost(slug),
overridden: slug in loadPostOverrides(),
});
}
// POST { slug, data: PostContent } -> save the article override
export async function POST(req: Request) {
let body: { slug?: string; data?: unknown };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'bad json' }, { status: 400 });
}
const slug = body.slug ?? '';
if (!isKnownSlug(slug)) {
return NextResponse.json({ error: 'unknown post' }, { status: 400 });
}
if (!validPost(body.data)) {
return NextResponse.json(
{ error: 'expected a { date, accent, en:{lead,blocks}, fa:{lead,blocks} } payload' },
{ status: 400 },
);
}
const overrides = loadPostOverrides();
// validPost has confirmed the shape (incl. accent ∈ ACCENTS) above.
overrides[slug] = body.data as unknown as PostContent;
setSection(POSTS_KEY, overrides);
revalidatePath(`/blog/${slug}`);
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
// DELETE ?slug=… -> revert one article to its in-code default
export async function DELETE(req: Request) {
const slug = new URL(req.url).searchParams.get('slug') ?? '';
if (!isKnownSlug(slug)) {
return NextResponse.json({ error: 'unknown post' }, { status: 400 });
}
const overrides = loadPostOverrides();
delete overrides[slug];
if (Object.keys(overrides).length === 0) {
resetSection(POSTS_KEY);
} else {
setSection(POSTS_KEY, overrides);
}
revalidatePath(`/blog/${slug}`);
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
+49
View File
@@ -0,0 +1,49 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { setSection, resetSection, getSection } from '@/lib/db/store';
import { isEditableKey } from '@/lib/content/sections';
export const runtime = 'nodejs';
// GET ?key=hero -> current stored override (or null)
export async function GET(req: Request) {
const key = new URL(req.url).searchParams.get('key') ?? '';
if (!isEditableKey(key)) {
return NextResponse.json({ error: 'unknown section' }, { status: 400 });
}
return NextResponse.json({ key, override: getSection(key) });
}
// POST { key, data: { fa, en } } -> save override
export async function POST(req: Request) {
let body: { key?: string; data?: { fa?: unknown; en?: unknown } };
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'bad json' }, { status: 400 });
}
const key = body.key ?? '';
if (!isEditableKey(key)) {
return NextResponse.json({ error: 'unknown section' }, { status: 400 });
}
if (!body.data || typeof body.data !== 'object' || !('fa' in body.data) || !('en' in body.data)) {
return NextResponse.json({ error: 'expected { fa, en } payload' }, { status: 400 });
}
setSection(key, { fa: body.data.fa, en: body.data.en });
// (site) layout is force-dynamic, but revalidate keeps any cached routes fresh.
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
// DELETE ?key=hero -> revert to in-code default
export async function DELETE(req: Request) {
const key = new URL(req.url).searchParams.get('key') ?? '';
if (!isEditableKey(key)) {
return NextResponse.json({ error: 'unknown section' }, { status: 400 });
}
resetSection(key);
revalidatePath('/', 'layout');
return NextResponse.json({ ok: true });
}
+44
View File
@@ -0,0 +1,44 @@
import { NextResponse } from 'next/server';
import { mkdir, writeFile } from 'node:fs/promises';
import { extname, join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { UPLOADS_DIR } from '@/lib/db/store';
export const runtime = 'nodejs';
const ALLOWED = new Set(['.png', '.jpg', '.jpeg', '.webp', '.gif', '.svg', '.avif']);
const MAX_BYTES = 8 * 1024 * 1024; // 8 MB
export async function POST(req: Request) {
let form: FormData;
try {
form = await req.formData();
} catch {
return NextResponse.json({ error: 'expected multipart form' }, { status: 400 });
}
const file = form.get('file');
if (!(file instanceof File)) {
return NextResponse.json({ error: 'no file' }, { status: 400 });
}
if (file.size > MAX_BYTES) {
return NextResponse.json({ error: 'file too large (max 8MB)' }, { status: 413 });
}
const ext = extname(file.name).toLowerCase();
if (!ALLOWED.has(ext)) {
return NextResponse.json({ error: `unsupported type ${ext}` }, { status: 415 });
}
const name = `${Date.now()}-${randomUUID().slice(0, 8)}${ext}`;
const buffer = Buffer.from(await file.arrayBuffer());
try {
await mkdir(UPLOADS_DIR, { recursive: true });
await writeFile(join(UPLOADS_DIR, name), buffer);
} catch {
return NextResponse.json({ error: 'write failed' }, { status: 500 });
}
return NextResponse.json({ url: `/api/uploads/${name}`, name });
}
+99
View File
@@ -0,0 +1,99 @@
import { NextResponse } from 'next/server';
import { Resend } from 'resend';
export const runtime = 'edge';
type ContactPayload = {
name?: string;
company?: string;
service?: string;
budget?: string;
message?: string;
locale?: 'fa' | 'en';
};
const required = ['name', 'service', 'budget', 'message'] as const;
function escape(str: string) {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
export async function POST(req: Request) {
let body: ContactPayload;
try {
body = (await req.json()) as ContactPayload;
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
for (const k of required) {
if (!body[k] || String(body[k]).trim().length < 2) {
return NextResponse.json(
{ error: `Missing field: ${k}` },
{ status: 422 },
);
}
}
const apiKey = process.env.RESEND_API_KEY;
const inbox = process.env.CONTACT_INBOX;
const from = process.env.CONTACT_FROM;
// Graceful no-op for local dev so the form UX can be validated
// without forcing a Resend key. Production should set these.
if (!apiKey || !inbox || !from) {
if (process.env.NODE_ENV === 'production') {
return NextResponse.json(
{ error: 'Email service is not configured' },
{ status: 500 },
);
}
console.info('[contact] received (no Resend key — logging only):', body);
return NextResponse.json({ ok: true, dev: true });
}
const resend = new Resend(apiKey);
const subject = `New consultation request — ${body.name}`;
const html = `
<div style="font-family: ui-sans-serif, system-ui, sans-serif; line-height: 1.55;">
<h2 style="margin: 0 0 12px;">New consultation request</h2>
<table style="border-collapse: collapse;">
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Name</td><td>${escape(body.name!)}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Company</td><td>${escape(body.company ?? '')}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Service</td><td>${escape(body.service!)}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Budget</td><td>${escape(body.budget!)}</td></tr>
<tr><td style="padding: 4px 12px 4px 0; color:#475569;">Locale</td><td>${escape(body.locale ?? '')}</td></tr>
</table>
<h3 style="margin: 20px 0 6px;">Message</h3>
<p style="white-space: pre-wrap; background:#f8fafc; padding:12px; border-radius:8px;">${escape(body.message!)}</p>
</div>
`;
try {
const { error } = await resend.emails.send({
from,
to: inbox,
subject,
html,
replyTo: body.company ? `${body.name} <${body.company}>` : undefined,
});
if (error) {
console.error('[contact] resend error', error);
return NextResponse.json(
{ error: 'Email service rejected the request' },
{ status: 502 },
);
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error('[contact] send failed', err);
return NextResponse.json(
{ error: 'Email service unreachable' },
{ status: 502 },
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import { NextResponse } from 'next/server';
import { readFile, stat } from 'node:fs/promises';
import { extname, join, normalize } from 'node:path';
import { UPLOADS_DIR } from '@/lib/db/store';
export const runtime = 'nodejs';
const MIME: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.avif': 'image/avif',
};
// Serves admin-uploaded media from the DATA_DIR volume. Public (not gated by
// middleware) so images render on the marketing site.
export async function GET(
_req: Request,
{ params }: { params: { path: string[] } },
) {
const rel = normalize(params.path.join('/'));
// Reject path traversal — the resolved file must stay inside UPLOADS_DIR.
if (rel.includes('..') || rel.startsWith('/') || rel.startsWith('\\')) {
return new NextResponse('bad path', { status: 400 });
}
const filePath = join(UPLOADS_DIR, rel);
try {
const info = await stat(filePath);
if (!info.isFile()) return new NextResponse('not found', { status: 404 });
const buf = await readFile(filePath);
const type = MIME[extname(filePath).toLowerCase()] ?? 'application/octet-stream';
return new NextResponse(buf, {
headers: {
'content-type': type,
'cache-control': 'public, max-age=31536000, immutable',
},
});
} catch {
return new NextResponse('not found', { status: 404 });
}
}