Skip to content

Commit 14a19f7

Browse files
committed
Hosted mode: deploy-to-Vercel one-click + zero-install path
Why this matters: "users only need an LLM key — no other paid service" is strongest when the install step itself disappears. Hosted mode adds a path where the user opens a URL and is using OpenAdKit in 30 seconds — no Node, no Terminal, no SmartScreen warnings, no Tauri code-signing fees. Same privacy guarantees as local install (everything is browser-only, BYOK). What changed: 1. lib/env.ts — new isHostedMode() helper. Returns true when window.location hostname is not loopback / .localhost. Single source of truth for "are we deployed somewhere vs running from the local sidecar". 2. lib/url-ingest.ts — server-proxy reader now picks between the local sidecar (127.0.0.1:3006) and the new same-origin /api/ingest route based on isHostedMode(). Both expose the same response shape so the rest of the ingest pipeline is unchanged. Error message also adapts. 3. lib/local-sync.ts — bootLocalSync() short-circuits in hosted mode. No more wasted fetch + setInterval on every page load. 4. app/api/ingest/route.ts — NEW. Server-side URL reader that mirrors the sidecar's /ingest endpoint: - SSRF guard (loopback, RFC1918, link-local, 169.254.x cloud-metadata) enforced on initial URL AND every redirect hop - 500 KB body cap, 15s timeout, max 5 redirects - Extracts <title>, <meta description>, OG/Twitter cards, favicon, social anchor hrefs, JSON-LD organization schemas - Strips HTML to plain text, caps at 40 KB - Force-dynamic, no caching, runs on Node runtime (not Edge — need redirect-by-hand control) - No persistence, no telemetry, no logs of which URLs were ingested. Only the URL the user typed is seen by our server briefly. 5. app/settings/page.tsx — "include API keys in folder sync" toggle is now hidden in hosted mode (it has no sidecar to act on). Added a hosted-mode info banner in the Data section explaining IndexedDB-only storage and pointing at the Export button + link to local install for users who want auto-disk-backup. 6. public/sw.js — /api/* responses are never cached. Without this, a hosted /api/ingest response would be served stale on re-ingest. 7. README.md — replaced single install section with two paths: "Hosted (zero install, 30 seconds)" with Deploy-to-Vercel + Deploy-to-Cloudflare buttons, and "Install locally (offline, auto-disk-backup)" with the existing one-line / .bat / .command flows. Tauri NOT shipped: it solves the same problem (zero-install for non-devs) but at much higher cost — Rust toolchain, cross-platform CI, $300/yr Windows code-signing cert, $99/yr Mac cert, ~30 MB binary download per OS. Hosted mode beats it on every axis: less code, zero ongoing cost, works on any device including mobile/tablet, no install warnings. Vercel AI SDK NOT shipped: pure dev-convenience refactor with zero user-visible change. Skipping is the right call. Verified: tsc clean, 53/53 unit tests, next build clean (76 routes + the new /api/ingest function route), 47/47 Playwright smoke pass, /api/ingest sanity-tested against example.com locally with valid JSON response.
1 parent cd9d0c5 commit 14a19f7

7 files changed

Lines changed: 391 additions & 19 deletions

File tree

README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,20 @@ You **bring your own AI key** — free-tier (Groq · Gemini · Cerebras · OpenR
7474

7575
---
7676

77-
## Install in 60 seconds
77+
## Use it now — two paths
78+
79+
Pick the one that fits you. Both are 100% free, BYOK, and store your work in your browser only — no accounts, no servers we operate, no telemetry.
80+
81+
### Path A · Hosted (zero install, 30 seconds)
82+
83+
Deploy your own private instance to Vercel or Cloudflare in one click. Free tier covers it easily.
84+
85+
[![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FIamRamgarhia%2FAdForge)
86+
[![Deploy to Cloudflare Pages](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https%3A%2F%2Fgithub.com%2FIamRamgarhia%2FAdForge)
87+
88+
What you get: your own URL (`yourname.vercel.app`), nothing to install, works on any device with a browser including mobile + tablet. The app auto-detects hosted mode and uses a built-in `/api/ingest` route (server-side fetch for the URL reader) instead of the local sidecar. **Storage is browser-only IndexedDB** — click *Settings → Export* periodically to back up to a JSON file.
89+
90+
### Path B · Install locally (offline, auto-disk-backup)
7891

7992
You need **Node.js 20+** ([download here](https://nodejs.org/en/download)). That's the only prerequisite — the one-liner below installs everything else.
8093

app/api/ingest/route.ts

Lines changed: 286 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,286 @@
1+
/**
2+
* Server-side URL ingest — the hosted-mode equivalent of the local sidecar's
3+
* /ingest endpoint. Called by lib/url-ingest.ts when running on a non-loopback
4+
* host (i.e. deployed to Vercel/Cloudflare/Netlify).
5+
*
6+
* What it does: server-side HTTP GET of a user-supplied URL, strip HTML to
7+
* plain text, extract OG/title/JSON-LD metadata, return as JSON. Bypasses
8+
* browser CORS so the same URL ingest flow works without the local sidecar.
9+
*
10+
* What it does NOT do:
11+
* - No LLM calls. The user's BYOK key never touches the server.
12+
* - No persistence. We don't store the URL, the content, or anything else.
13+
* - No telemetry. No logs of which URLs were ingested.
14+
*
15+
* SSRF guarded: blocks loopback, RFC1918 private, link-local, IPv6 unique-
16+
* local, and 169.254.169.254 (cloud metadata) on both the initial URL and
17+
* every redirect hop. Body capped at 500 KB. 15s timeout per hop, max 5
18+
* redirects.
19+
*
20+
* Mirrors the response shape of the sidecar (lib/url-ingest.ts uses both
21+
* interchangeably via serverProxyUrl()).
22+
*/
23+
import { NextResponse } from "next/server";
24+
25+
// Force Node runtime (not Edge) — we need redirect-by-hand control + the
26+
// 15s timeout per hop, which Edge fetch doesn't expose granularly.
27+
export const runtime = "nodejs";
28+
// No caching — every ingest is a fresh fetch. We don't want stale brand data.
29+
export const dynamic = "force-dynamic";
30+
31+
const MAX_REMOTE_BYTES = 500_000;
32+
const MAX_REDIRECTS = 5;
33+
const TIMEOUT_MS = 15_000;
34+
const OUTPUT_CAP = 40_000;
35+
const USER_AGENT =
36+
"Mozilla/5.0 (compatible; OpenAdKit/1.0; +https://github.com/IamRamgarhia/AdForge)";
37+
38+
function isPrivateOrLoopbackHost(hostname: string): boolean {
39+
if (!hostname) return true;
40+
const h = hostname.toLowerCase();
41+
if (h === "localhost" || h === "ip6-localhost" || h === "ip6-loopback") return true;
42+
if (/^127\./.test(h)) return true;
43+
if (/^10\./.test(h)) return true;
44+
if (/^192\.168\./.test(h)) return true;
45+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true;
46+
if (/^169\.254\./.test(h)) return true;
47+
if (/^0\./.test(h)) return true;
48+
if (h === "::1" || h === "::") return true;
49+
if (/^fe[89ab][0-9a-f]:/i.test(h)) return true;
50+
if (/^f[cd][0-9a-f]{2}:/i.test(h)) return true;
51+
return false;
52+
}
53+
54+
interface Metadata {
55+
title: string;
56+
description: string;
57+
og: Record<string, string>;
58+
favicon: string;
59+
social_links: Record<string, string>;
60+
json_ld: unknown[];
61+
}
62+
63+
function extractMetadata(html: string, baseUrl: string): Metadata {
64+
const meta: Metadata = {
65+
title: "",
66+
description: "",
67+
og: {},
68+
favicon: "",
69+
social_links: {},
70+
json_ld: [],
71+
};
72+
73+
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
74+
if (titleMatch) meta.title = titleMatch[1].trim();
75+
76+
const metaTagRe = /<meta\b[^>]*>/gi;
77+
const attrRe = (name: string) =>
78+
new RegExp(`(?:${name})\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s>]+))`, "i");
79+
let m: RegExpExecArray | null;
80+
while ((m = metaTagRe.exec(html)) !== null) {
81+
const tag = m[0];
82+
const nameMatch = tag.match(attrRe("name|property"));
83+
const contentMatch = tag.match(attrRe("content"));
84+
if (!nameMatch || !contentMatch) continue;
85+
const key = (nameMatch[1] || nameMatch[2] || nameMatch[3] || "").toLowerCase();
86+
const val = contentMatch[1] || contentMatch[2] || contentMatch[3] || "";
87+
if (key === "description" && !meta.description) meta.description = val;
88+
if (key.startsWith("og:")) meta.og[key.slice(3)] = val;
89+
if (key === "twitter:title" && !meta.og.title) meta.og.title = val;
90+
if (key === "twitter:description" && !meta.og.description) meta.og.description = val;
91+
if (key === "twitter:image" && !meta.og.image) meta.og.image = val;
92+
}
93+
94+
const linkRe = /<link\b[^>]*>/gi;
95+
while ((m = linkRe.exec(html)) !== null) {
96+
const tag = m[0];
97+
const relMatch = tag.match(/rel\s*=\s*["']([^"']+)["']/i);
98+
const hrefMatch = tag.match(/href\s*=\s*["']([^"']+)["']/i);
99+
if (!relMatch || !hrefMatch) continue;
100+
if (/icon/i.test(relMatch[1])) {
101+
try {
102+
meta.favicon = new URL(hrefMatch[1], baseUrl).toString();
103+
if (!/apple-touch/i.test(relMatch[1])) break;
104+
} catch {
105+
// ignore
106+
}
107+
}
108+
}
109+
if (!meta.favicon) {
110+
try {
111+
meta.favicon = new URL("/favicon.ico", baseUrl).toString();
112+
} catch {
113+
// ignore
114+
}
115+
}
116+
117+
const anchorRe = /<a\b[^>]*href\s*=\s*["']([^"']+)["'][^>]*>/gi;
118+
while ((m = anchorRe.exec(html)) !== null) {
119+
let u: URL;
120+
try {
121+
u = new URL(m[1], baseUrl);
122+
} catch {
123+
continue;
124+
}
125+
const host = u.hostname.replace(/^www\./i, "").toLowerCase();
126+
const url = u.toString();
127+
if (!meta.social_links.facebook && /(^|\.)facebook\.com$/.test(host) && !/\/sharer/i.test(u.pathname))
128+
meta.social_links.facebook = url;
129+
if (!meta.social_links.instagram && /(^|\.)instagram\.com$/.test(host))
130+
meta.social_links.instagram = url;
131+
if (
132+
!meta.social_links.twitter &&
133+
(/(^|\.)twitter\.com$/.test(host) || /(^|\.)x\.com$/.test(host)) &&
134+
!/\/intent\//i.test(u.pathname)
135+
)
136+
meta.social_links.twitter = url;
137+
if (!meta.social_links.linkedin && /(^|\.)linkedin\.com$/.test(host))
138+
meta.social_links.linkedin = url;
139+
if (!meta.social_links.youtube && /(^|\.)youtube\.com$/.test(host))
140+
meta.social_links.youtube = url;
141+
if (!meta.social_links.tiktok && /(^|\.)tiktok\.com$/.test(host))
142+
meta.social_links.tiktok = url;
143+
if (!meta.social_links.pinterest && /(^|\.)pinterest\.com$/.test(host))
144+
meta.social_links.pinterest = url;
145+
if (!meta.social_links.threads && /(^|\.)threads\.net$/.test(host))
146+
meta.social_links.threads = url;
147+
}
148+
149+
const jsonLdRe = /<script\b[^>]*type\s*=\s*["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
150+
while ((m = jsonLdRe.exec(html)) !== null) {
151+
try {
152+
meta.json_ld.push(JSON.parse(m[1].trim()));
153+
} catch {
154+
// skip malformed JSON-LD
155+
}
156+
}
157+
158+
return meta;
159+
}
160+
161+
async function fetchWithRedirects(initialUrl: string): Promise<string> {
162+
let current = initialUrl;
163+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
164+
const u = new URL(current);
165+
if (u.protocol !== "http:" && u.protocol !== "https:") {
166+
throw new Error("Non-http(s) URL blocked");
167+
}
168+
if (isPrivateOrLoopbackHost(u.hostname)) {
169+
throw new Error("Private / loopback / link-local host blocked");
170+
}
171+
const ac = new AbortController();
172+
const timer = setTimeout(() => ac.abort(), TIMEOUT_MS);
173+
let res: Response;
174+
try {
175+
res = await fetch(current, {
176+
method: "GET",
177+
redirect: "manual",
178+
signal: ac.signal,
179+
headers: {
180+
"User-Agent": USER_AGENT,
181+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
182+
"Accept-Language": "en-US,en;q=0.5",
183+
},
184+
});
185+
} finally {
186+
clearTimeout(timer);
187+
}
188+
if (res.status >= 300 && res.status < 400) {
189+
const loc = res.headers.get("location");
190+
if (!loc) throw new Error(`Redirect ${res.status} without Location header`);
191+
current = new URL(loc, current).toString();
192+
continue;
193+
}
194+
if (res.status < 200 || res.status >= 400) {
195+
throw new Error(`HTTP ${res.status} from target`);
196+
}
197+
// Read body with cap. Reader to enforce the byte budget without
198+
// accumulating an unbounded string in memory.
199+
if (!res.body) return await res.text();
200+
const reader = res.body.getReader();
201+
const decoder = new TextDecoder("utf-8", { fatal: false });
202+
let out = "";
203+
let total = 0;
204+
// eslint-disable-next-line no-constant-condition
205+
while (true) {
206+
const { value, done } = await reader.read();
207+
if (done) break;
208+
total += value.byteLength;
209+
if (total > MAX_REMOTE_BYTES) {
210+
try {
211+
await reader.cancel();
212+
} catch {
213+
// ignore
214+
}
215+
throw new Error(`Remote body exceeded ${MAX_REMOTE_BYTES} bytes`);
216+
}
217+
out += decoder.decode(value, { stream: true });
218+
}
219+
out += decoder.decode();
220+
return out;
221+
}
222+
throw new Error("Too many redirects");
223+
}
224+
225+
function stripHtml(html: string): string {
226+
return html
227+
.replace(/<script[\s\S]*?<\/script>/gi, "")
228+
.replace(/<style[\s\S]*?<\/style>/gi, "")
229+
.replace(/<noscript[\s\S]*?<\/noscript>/gi, "")
230+
.replace(/<head[\s\S]*?<\/head>/gi, "")
231+
.replace(/<[^>]+>/g, " ")
232+
.replace(/&nbsp;/g, " ")
233+
.replace(/&amp;/g, "&")
234+
.replace(/&lt;/g, "<")
235+
.replace(/&gt;/g, ">")
236+
.replace(/&quot;/g, '"')
237+
.replace(/&#39;/g, "'")
238+
.replace(/\s+/g, " ")
239+
.trim();
240+
}
241+
242+
export async function GET(req: Request) {
243+
const u = new URL(req.url);
244+
const target = u.searchParams.get("url");
245+
if (!target) {
246+
return NextResponse.json({ ok: false, error: "Missing url param." }, { status: 400 });
247+
}
248+
let parsed: URL;
249+
try {
250+
parsed = new URL(target);
251+
} catch {
252+
return NextResponse.json({ ok: false, error: "Invalid url." }, { status: 400 });
253+
}
254+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
255+
return NextResponse.json(
256+
{ ok: false, error: "Only http/https URLs supported." },
257+
{ status: 400 }
258+
);
259+
}
260+
if (isPrivateOrLoopbackHost(parsed.hostname)) {
261+
return NextResponse.json(
262+
{ ok: false, error: "Private / loopback / link-local hosts are not allowed." },
263+
{ status: 400 }
264+
);
265+
}
266+
267+
let body: string;
268+
try {
269+
body = await fetchWithRedirects(target);
270+
} catch (e: unknown) {
271+
const msg = e instanceof Error ? e.message : "Fetch failed";
272+
return NextResponse.json({ ok: false, error: msg }, { status: 502 });
273+
}
274+
275+
const metadata = extractMetadata(body, target);
276+
const text = stripHtml(body);
277+
const truncated = text.length > OUTPUT_CAP;
278+
return NextResponse.json({
279+
ok: true,
280+
url: target,
281+
content: truncated ? text.slice(0, OUTPUT_CAP) : text,
282+
truncated,
283+
source: "hosted-api",
284+
metadata,
285+
});
286+
}

app/settings/page.tsx

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { testApiKey } from "@/lib/llm";
1818
import { exportAll, importAll, wipeAll } from "@/lib/storage";
1919
import { formatCost, formatTokens } from "@/lib/utils";
2020
import { CURRENCIES, getCurrencyCode, setCurrencyCode } from "@/lib/currency";
21+
import { isHostedMode } from "@/lib/env";
2122
import { getProviderLimits } from "@/lib/provider-limits";
2223

2324
export default function SettingsPage() {
@@ -335,12 +336,16 @@ function SettingsInner() {
335336
</div>
336337
<ToggleRow label="character-count warnings" desc="badges when output exceeds platform limits" v={charWarn} on={persistCharWarn} />
337338
<ToggleRow label="auto-save to history" desc="every generation goes into /history automatically" v={autoSave} on={persistAutoSave} />
338-
<ToggleRow
339-
label="include API keys in folder sync"
340-
desc="off by default · turn ON to make the data/ folder fully portable across machines (security tradeoff)"
341-
v={syncKeys}
342-
on={persistSyncKeys}
343-
/>
339+
{/* Folder-sync only exists when the local sidecar is running.
340+
In hosted mode this toggle has nothing to act on. */}
341+
{!isHostedMode() ? (
342+
<ToggleRow
343+
label="include API keys in folder sync"
344+
desc="off by default · turn ON to make the data/ folder fully portable across machines (security tradeoff)"
345+
v={syncKeys}
346+
on={persistSyncKeys}
347+
/>
348+
) : null}
344349
<div className="pt-3 border-t border-base-700">
345350
<label className="label">jina reader api key (optional)</label>
346351
<input
@@ -374,6 +379,16 @@ function SettingsInner() {
374379

375380
<section className="border border-base-600 bg-base-900/40 p-5 space-y-3">
376381
<h2 className="text-[10px] font-mono uppercase tracking-ui-mega text-ink-muted">data</h2>
382+
{isHostedMode() ? (
383+
<div className="border border-info/40 bg-info/[0.06] px-3 py-2 text-[11px] text-info leading-relaxed">
384+
<strong>Hosted mode:</strong> brand brains + history live only in this browser's
385+
IndexedDB. Clearing site data wipes everything. Export below and re-import on
386+
a new browser / device to move your work. Want auto-backup to disk?{" "}
387+
<a href="https://github.com/IamRamgarhia/AdForge#install-in-60-seconds" target="_blank" rel="noreferrer" className="underline">
388+
install locally
389+
</a>.
390+
</div>
391+
) : null}
377392
<p className="text-[11px] font-mono uppercase tracking-ui-wide text-ink-subtle">
378393
backups include brand brains + history. api keys NOT included.
379394
</p>

lib/env.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* Runtime environment helpers.
3+
*
4+
* OpenAdKit ships two deployment modes:
5+
*
6+
* 1. **Local** — user installs and runs the Next dev/start server +
7+
* `scripts/local-sync.cjs` sidecar on 127.0.0.1. Sidecar provides
8+
* `/ingest` (CORS-free URL reader), `/snapshot` (disk persistence),
9+
* `/diagnostics`, etc.
10+
*
11+
* 2. **Hosted** — same Next app deployed to Vercel/Cloudflare/Netlify
12+
* with no sidecar. URL is something like openadkit.example.com.
13+
* Storage is IndexedDB-only. URL ingest falls back to the Next API
14+
* route /api/ingest (server-side fetch) plus jina + allorigins.
15+
*
16+
* `isHostedMode()` returns true when running from a non-loopback host.
17+
* Every sidecar-dependent code path checks this so the hosted version
18+
* never makes pointless 127.0.0.1 fetches (which would slow down every
19+
* URL ingest and leak "is the launcher running?" errors into the UI).
20+
*
21+
* The `.localhost` suffix check matches the custom-domain pattern
22+
* documented in docs/CUSTOM_DOMAIN.md so dev users on e.g.
23+
* `openadkit.localhost` still get sidecar features.
24+
*/
25+
26+
export function isHostedMode(): boolean {
27+
if (typeof window === "undefined") return false;
28+
const h = window.location.hostname;
29+
return h !== "localhost" && h !== "127.0.0.1" && !h.endsWith(".localhost");
30+
}
31+
32+
export function isLocalMode(): boolean {
33+
return !isHostedMode();
34+
}

0 commit comments

Comments
 (0)