UNPKG

@appwarden/middleware

Version:

Instantly disable all user interaction with your app deployed on Cloudflare or Vercel

419 lines (411 loc) 12.8 kB
import { isCacheUrl, isValidCacheUrl } from "./chunk-QEFORWCW.js"; import { toNextResponse } from "./chunk-HUWGPM4M.js"; import { MemoryCache, TEMPORARY_REDIRECT_STATUS, buildLockPageUrl, debug, handleHeartbeatRequest, isHTMLRequest, isHeartbeatRequest, isOnLockPage, makeCSPHeader, parseMergedConfig, printMessage, sanitizeConfigErrors } from "./chunk-Q7ZBW3WZ.js"; import { APPWARDEN_CACHE_KEY, APPWARDEN_MIDDLEWARE_USER_AGENT, AppwardenApiHostnameSchema, AppwardenApiTokenSchema, AppwardenConfigErrorMessages, CSPDirectivesSchema, CSPModeSchema, HEARTBEAT_SERVICES, LockValue, ValidLockPageSlugSchema, errors, getErrors, globalErrors } from "./chunk-3OXAKJPA.js"; // src/runners/appwarden-on-vercel.ts import { waitUntil } from "@vercel/functions"; import { NextResponse } from "next/server"; // src/schemas/vercel.ts import { z } from "zod"; // src/utils/vercel/get-lock-value.ts var getLockValue = async (context) => { try { let shouldDeleteEdgeValue = false; let serializedValue, lockValue = { isLocked: 0, isLockedTest: 0, lastCheck: 0 }; switch (context.provider) { case "edge-config": { const { createClient } = await import("@vercel/edge-config"); const edgeConfig = createClient(context.cacheUrl); serializedValue = await edgeConfig.get( context.keyName ); break; } case "upstash": { const { hostname, password } = new URL(context.cacheUrl); const { Redis } = await import("@upstash/redis"); const redis = new Redis({ url: `https://${hostname}`, token: password }); const redisValue = await redis.get( context.keyName ); serializedValue = redisValue === null ? void 0 : redisValue; break; } default: throw new Error(`Unsupported provider: ${context.provider}`); } if (!serializedValue) { return { lockValue: void 0 }; } try { lockValue = LockValue.parse( typeof serializedValue === "string" ? JSON.parse(serializedValue) : serializedValue ); } catch (error) { console.error( printMessage( `Failed to parse ${context.keyName} from edge cache - ${error}` ) ); shouldDeleteEdgeValue = true; } return { lockValue, shouldDeleteEdgeValue }; } catch (e) { const message = "Failed to retrieve edge value"; console.error( printMessage(e instanceof Error ? `${message} - ${e.message}` : message) ); if (e instanceof Error) { if (e.message.includes("Invalid connection string provided")) { throw new Error(errors.badCacheConnection); } } return { lockValue: void 0 }; } }; // src/utils/vercel/sync-edge-value.ts var APIError = class extends Error { constructor(message) { super(message); this.name = "APIError"; } }; var API_TIMEOUT_MS = 1e4; function resolveApiHostname(hostname) { if (!hostname) { return "https://api.appwarden.io"; } const parsed = AppwardenApiHostnameSchema.safeParse(hostname); if (!parsed.success) { return "https://api.appwarden.io"; } return parsed.data; } var syncEdgeValue = async (context) => { context.debug("syncing with api"); const apiHostname = resolveApiHostname(context.appwardenApiHostname); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), API_TIMEOUT_MS); try { const response = await fetch(new URL("/v1/appwarden/status", apiHostname), { method: "POST", headers: { "content-type": "application/json", "user-agent": APPWARDEN_MIDDLEWARE_USER_AGENT }, body: JSON.stringify({ service: "vercel", cacheUrl: context.cacheUrl, fqdn: context.requestUrl.hostname, vercelApiToken: context.vercelApiToken ?? "", appwardenApiToken: context.appwardenApiToken }), signal: controller.signal }); if (response.status === 403) { console.log( printMessage( "Verifying domain ownership... this will only take a few minutes." ) ); } if (response.status !== 200) { throw new Error(`${response.status} ${response.statusText}`); } if (response.headers.get("content-type")?.includes("application/json")) { const result = await response.json(); if (result.error) { throw new APIError(result.error.message); } } } catch (e) { const message = "Failed to fetch from check endpoint"; console.error( printMessage( e instanceof APIError ? e.message : e instanceof Error ? `${message} - ${e.message}` : message ) ); } finally { clearTimeout(timeout); } }; // src/utils/validate-config.ts function validateConfig(config, schema) { const result = schema.safeParse(config); const hasErrors = !result.success; if (hasErrors) { const mappedErrors = getErrors(result.error); if (mappedErrors.length > 0) { for (const error of mappedErrors) { console.error(printMessage(error)); } } else { console.error(printMessage(result.error.message)); } } return hasErrors; } // src/schemas/vercel.ts var VercelCSPSchema = z.object({ mode: CSPModeSchema, directives: z.lazy(() => CSPDirectivesSchema).refine( (val) => { try { if (typeof val === "string") { JSON.parse(val); } return true; } catch { return false; } }, { message: AppwardenConfigErrorMessages["CSP_DIRECTIVES_BAD_PARSE" /* CspDirectivesBadParse */], params: { appwardenErrorKey: "CSP_DIRECTIVES_BAD_PARSE" /* CspDirectivesBadParse */ } } ).refine( (val) => { const serialized = typeof val === "string" ? val : JSON.stringify(val); return !serialized.includes("{{nonce}}"); }, { message: AppwardenConfigErrorMessages["VERCEL_NONCE_UNSUPPORTED" /* VercelNonceUnsupported */], params: { appwardenErrorKey: "VERCEL_NONCE_UNSUPPORTED" /* VercelNonceUnsupported */ } } ).transform( (val) => typeof val === "string" ? JSON.parse(val) : val ) }); var BaseNextJsConfigSchema = z.object({ cacheUrl: z.string(), appwardenApiToken: AppwardenApiTokenSchema, appwardenApiHostname: AppwardenApiHostnameSchema.optional(), vercelApiToken: z.string().optional(), debug: z.boolean().optional(), lockPageSlug: ValidLockPageSlugSchema.default("").transform( (val) => val.replace(/^\/?/, "/") ), contentSecurityPolicy: VercelCSPSchema.optional() }); var AppwardenConfigSchema = BaseNextJsConfigSchema.refine( (data) => { return isCacheUrl.edgeConfig(data.cacheUrl) || isCacheUrl.upstash(data.cacheUrl); }, { message: AppwardenConfigErrorMessages["CACHE_URL_UNRECOGNIZED" /* CacheUrlUnrecognized */], params: { appwardenErrorKey: "CACHE_URL_UNRECOGNIZED" /* CacheUrlUnrecognized */ }, path: ["cacheUrl"] } ).superRefine((data, ctx) => { if (isCacheUrl.edgeConfig(data.cacheUrl) && !isValidCacheUrl.edgeConfig(data.cacheUrl)) { ctx.addIssue({ code: "custom", message: AppwardenConfigErrorMessages["CACHE_URL_INVALID_EDGE_CONFIG" /* CacheUrlInvalidEdgeConfig */], params: { appwardenErrorKey: "CACHE_URL_INVALID_EDGE_CONFIG" /* CacheUrlInvalidEdgeConfig */ }, path: ["cacheUrl"] }); return false; } if (isCacheUrl.upstash(data.cacheUrl) && !isValidCacheUrl.upstash(data.cacheUrl)) { ctx.addIssue({ code: "custom", message: AppwardenConfigErrorMessages["CACHE_URL_INVALID_UPSTASH" /* CacheUrlInvalidUpstash */], params: { appwardenErrorKey: "CACHE_URL_INVALID_UPSTASH" /* CacheUrlInvalidUpstash */ }, path: ["cacheUrl"] }); return false; } return true; }).refine( (data) => isCacheUrl.edgeConfig(data.cacheUrl) ? !!data.vercelApiToken : true, { message: AppwardenConfigErrorMessages["VERCEL_API_TOKEN_REQUIRED" /* VercelApiTokenRequired */], params: { appwardenErrorKey: "VERCEL_API_TOKEN_REQUIRED" /* VercelApiTokenRequired */ }, path: ["vercelApiToken"] } ); // src/runners/appwarden-on-vercel.ts function getAppwardenConfiguration(generatedConfig, config) { return parseMergedConfig( generatedConfig, config, AppwardenConfigSchema.parse ); } var memoryCache = new MemoryCache({ maxSize: 1 }); function safeWaitUntil(promise) { try { waitUntil(promise); } catch { promise.catch(console.error); } } function createAppwardenMiddleware(config) { return async (request) => { const requestUrl = new URL(request.url); if (isHeartbeatRequest(request, requestUrl)) { const validationResult = AppwardenConfigSchema.safeParse(config); const configErrors = validationResult.success ? [] : sanitizeConfigErrors(validationResult.error); const response = handleHeartbeatRequest( request, HEARTBEAT_SERVICES.VERCEL, configErrors ); return toNextResponse(response); } if (validateConfig(config, AppwardenConfigSchema)) { return NextResponse.next(); } const parsedConfig = AppwardenConfigSchema.parse(config); const debugFn = debug(parsedConfig.debug ?? false); const applyCspHeaders = (response) => { const cspConfig = parsedConfig.contentSecurityPolicy; if (cspConfig && ["enforced", "report-only"].includes(cspConfig.mode)) { const [headerName, headerValue] = makeCSPHeader( "", cspConfig.directives, cspConfig.mode ); response.headers.set(headerName, headerValue); } return response; }; const createMutableRedirectResponse = (location) => { return new Response(null, { status: TEMPORARY_REDIRECT_STATUS, headers: { Location: location } }); }; try { const isHTML = isHTMLRequest(request); debugFn( `Appwarden middleware invoked for ${requestUrl.pathname}`, `isHTML: ${isHTML}` ); if (!isHTML) { debugFn("Non-HTML request detected - passing through"); return NextResponse.next(); } if (!parsedConfig.lockPageSlug) { debugFn("No lockPageSlug configured - passing through"); return NextResponse.next(); } if (isOnLockPage(parsedConfig.lockPageSlug, request.url)) { debugFn("Already on lock page - passing through"); return NextResponse.next(); } const provider = isCacheUrl.edgeConfig(parsedConfig.cacheUrl) ? "edge-config" : "upstash"; debugFn(`Using provider: ${provider}`); const cacheValue = memoryCache.get(APPWARDEN_CACHE_KEY); const shouldRecheck = MemoryCache.isExpired(cacheValue); if (!cacheValue || shouldRecheck) { debugFn( "Memory cache miss or expired - syncing edge value in background", `shouldRecheck=${shouldRecheck}` ); safeWaitUntil( syncEdgeValue({ requestUrl, cacheUrl: parsedConfig.cacheUrl, appwardenApiToken: parsedConfig.appwardenApiToken, appwardenApiHostname: parsedConfig.appwardenApiHostname, vercelApiToken: parsedConfig.vercelApiToken, debug: debugFn }) ); } const lockValue = cacheValue ?? (await getLockValue({ cacheUrl: parsedConfig.cacheUrl, keyName: APPWARDEN_CACHE_KEY, provider })).lockValue; if (lockValue?.isLocked) { debugFn( `Website is locked - redirecting to ${parsedConfig.lockPageSlug}` ); const lockPageUrl = buildLockPageUrl( parsedConfig.lockPageSlug, request.url ); const redirectResponse = createMutableRedirectResponse( lockPageUrl.toString() ); return applyCspHeaders(redirectResponse); } const response = NextResponse.next(); debugFn("Site is not locked - passing through"); return applyCspHeaders(response); } catch (e) { debugFn( "Error in Appwarden Vercel middleware", e instanceof Error ? e.message : String(e) ); const message = "Appwarden encountered an unknown error. Please contact Appwarden support at https://appwarden.io/join-community."; if (e instanceof Error) { if (!globalErrors.includes(e.message)) { console.error(printMessage(`${message} - ${e.message}`)); } } else { console.error(printMessage(message)); } return NextResponse.next(); } }; } export { createAppwardenMiddleware, getAppwardenConfiguration };