4f04f6bf75
Render engine - Add Remotion (code-based) as a 2nd render engine alongside After Effects. node-agent dispatches on Job.Engine; RunRemotion maps bindings -> --props, renders native then ffmpeg-scales to the quality tier (aspect-preserving). - content.projects.render_engine + render_remotion_comp (migration 32); render-svc claim resolves engine and routes (skips .aep for Remotion). - Admin TemplatesAdmin gains an engine picker + Remotion composition id field. Template pack (services/remotion) - 16 branded, Persian (Vazirmatn), color- and text-editable templates, each in 3 aspects (16:9 / 1:1 / 9:16): LogoMotion, Opener, InstaPromo, YouTubeIntro, Slideshow, HappyBirthday, SalePromo, QuoteCard, EventInvite, Countdown, GlitterReveal (editable logo image), NowruzGreeting (animated characters), and 4 cinematic 3D templates via @remotion/three (Hero3D, Nowruz3D, Birthday3D, Promo3D) with reflections + bloom/DOF/vignette. - scripts/seed_remotion_templates.py seeds containers/projects/scenes/colors. Pricing - Rewrite /pricing to the seconds-based model (charge = length x resolution), data-driven from /v1/plans, Toman, broker checkout. Coming-soon - Persian experimental-build overlay on all pages (launch date + countdown). Fixes - middleware matcher bypasses all static asset paths; catalog mapping passes cover image + preview video so real thumbnails render. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
140 lines
4.3 KiB
TypeScript
140 lines
4.3 KiB
TypeScript
import { type NextRequest, NextResponse } from "next/server";
|
|
import createIntlMiddleware from "next-intl/middleware";
|
|
|
|
import { routing } from "@/i18n/routing";
|
|
import {
|
|
ACCESS_TOKEN_COOKIE,
|
|
REFRESH_TOKEN_COOKIE,
|
|
} from "@/lib/auth/constants";
|
|
import { decodeJwt, isJwtExpired } from "@/lib/auth/jwt";
|
|
|
|
const handleI18n = createIntlMiddleware(routing);
|
|
|
|
// Routes that require an authenticated Identity session.
|
|
const PROTECTED = /^\/(?:en\/)?(?:dashboard|studio|admin)(?:\/|$)/;
|
|
// Admin-only routes.
|
|
const ADMIN_ONLY = /^\/(?:en\/)?admin(?:\/|$)/;
|
|
|
|
// Proactively refresh the access token when fewer than 120 s remain.
|
|
const REFRESH_BEFORE_EXPIRY_S = 120;
|
|
|
|
async function tryRefreshToken(
|
|
request: NextRequest
|
|
): Promise<{ accessToken: string; refreshToken: string; expiresIn: number } | null> {
|
|
const refreshToken = request.cookies.get(REFRESH_TOKEN_COOKIE)?.value;
|
|
if (!refreshToken) return null;
|
|
|
|
const gatewayUrl = (
|
|
process.env.API_GATEWAY_URL ?? "http://localhost:8088"
|
|
).replace(/\/$/, "");
|
|
|
|
try {
|
|
const res = await fetch(`${gatewayUrl}/v1/auth/refresh`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ refresh_token: refreshToken }),
|
|
// Never cache refresh calls.
|
|
cache: "no-store",
|
|
});
|
|
if (!res.ok) return null;
|
|
const data = await res.json().catch(() => null);
|
|
if (!data?.access_token || !data?.refresh_token) return null;
|
|
return {
|
|
accessToken: data.access_token as string,
|
|
refreshToken: data.refresh_token as string,
|
|
expiresIn: (data.expires_in as number) ?? 900,
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function applyNewTokens(
|
|
response: NextResponse,
|
|
accessToken: string,
|
|
refreshToken: string,
|
|
expiresIn: number
|
|
): NextResponse {
|
|
const secure = process.env.AUTH_COOKIE_SECURE === "true";
|
|
const base = { httpOnly: true, sameSite: "lax" as const, secure, path: "/" };
|
|
response.cookies.set(ACCESS_TOKEN_COOKIE, accessToken, {
|
|
...base,
|
|
maxAge: expiresIn,
|
|
});
|
|
response.cookies.set(REFRESH_TOKEN_COOKIE, refreshToken, {
|
|
...base,
|
|
maxAge: 60 * 60 * 24 * 30,
|
|
});
|
|
return response;
|
|
}
|
|
|
|
export async function middleware(request: NextRequest) {
|
|
// 1. Locale detection / redirect (next-intl)
|
|
const i18nResponse = handleI18n(request);
|
|
if (i18nResponse.status !== 200 || i18nResponse.headers.has("location")) {
|
|
return i18nResponse;
|
|
}
|
|
|
|
const { pathname } = request.nextUrl;
|
|
if (!PROTECTED.test(pathname)) return i18nResponse;
|
|
|
|
// 2. Read the current access token
|
|
let accessToken = request.cookies.get(ACCESS_TOKEN_COOKIE)?.value ?? null;
|
|
let claims = decodeJwt(accessToken ?? "");
|
|
let newTokens: Awaited<ReturnType<typeof tryRefreshToken>> = null;
|
|
|
|
// 3. Proactively refresh when token is about to expire (< 120 s left)
|
|
if (
|
|
accessToken &&
|
|
claims?.exp &&
|
|
claims.exp - Date.now() / 1000 < REFRESH_BEFORE_EXPIRY_S
|
|
) {
|
|
newTokens = await tryRefreshToken(request);
|
|
if (newTokens) {
|
|
accessToken = newTokens.accessToken;
|
|
claims = decodeJwt(accessToken);
|
|
}
|
|
}
|
|
|
|
// 4. If token is missing or expired (and refresh failed), redirect to login
|
|
if (!accessToken || isJwtExpired(claims)) {
|
|
const url = request.nextUrl.clone();
|
|
url.pathname = pathname.startsWith("/en") ? "/en/auth" : "/auth";
|
|
url.searchParams.set("next", pathname);
|
|
return NextResponse.redirect(url);
|
|
}
|
|
|
|
// 5. Admin guard — is_admin must be truthy
|
|
if (ADMIN_ONLY.test(pathname)) {
|
|
const isAdmin =
|
|
String(claims?.is_admin) === "true" ||
|
|
claims?.is_admin === true ||
|
|
String(claims?.is_tenant_admin) === "true";
|
|
if (!isAdmin) {
|
|
const url = request.nextUrl.clone();
|
|
url.pathname = pathname.startsWith("/en") ? "/en/dashboard" : "/dashboard";
|
|
return NextResponse.redirect(url);
|
|
}
|
|
}
|
|
|
|
// 6. Stamp fresh cookies onto the response if we refreshed
|
|
if (newTokens) {
|
|
return applyNewTokens(
|
|
i18nResponse,
|
|
newTokens.accessToken,
|
|
newTokens.refreshToken,
|
|
newTokens.expiresIn
|
|
);
|
|
}
|
|
|
|
return i18nResponse;
|
|
}
|
|
|
|
export const config = {
|
|
matcher: [
|
|
// Skip API, Next internals, and any path with a file extension (static
|
|
// assets: images, video, fonts, etc. served from public/).
|
|
"/((?!api|_next/static|_next/image|favicon.ico|.*\\.[a-zA-Z0-9]+$).*)",
|
|
],
|
|
};
|