Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added docs/assets/banner-wide.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/banner.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions site/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
data
dist
web/node_modules
*.md
3 changes: 3 additions & 0 deletions site/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
data/
dist/
web/node_modules/
19 changes: 19 additions & 0 deletions site/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# The JoyChromium site: the React page (web/) built into dist/, served with the beta sign-ups by server.mjs.
FROM node:24-alpine AS build
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY web/ ./
RUN npm run build

FROM node:24-alpine
WORKDIR /app
COPY server.mjs ./
COPY --from=build /dist ./dist
ENV PORT=3000 DATA_DIR=/data NODE_ENV=production
RUN mkdir -p /data && chown node:node /data
USER node
VOLUME ["/data"]
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s CMD wget -qO- http://127.0.0.1:3000/healthz || exit 1
CMD ["node", "server.mjs"]
32 changes: 32 additions & 0 deletions site/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Site do JoyChromium

Página de apresentação com inscrição no beta. `web/` é a página em React (Vite, Motion, Lenis), compilada em `dist/`; `server.mjs` (Node, sem dependências) serve `dist/` e guarda os e-mails em `/data/subscribers.jsonl`.

A página também responde a um gamepad: analógico esquerdo move o cursor, D-pad pula entre links e botões, A clica, analógico direito rola.

## Rodar local

```bash
cd site/web && npm ci && npm run build # gera site/dist
node site/server.mjs # http://localhost:3000
node site/test.mjs # autoteste da inscrição
# desenvolvendo: node site/server.mjs + (cd site/web && npm run dev)
```

## Dokploy

1. **Create Application** → provider GitHub, repositório `lippdev/joychromium`, branch `main`.
2. **Build Type: Dockerfile**, *Build Path* `site`, *Dockerfile Path* `Dockerfile`.
3. **Environment:** `ADMIN_TOKEN=<uma senha longa>` (só ela baixa a lista).
4. **Advanced → Volumes:** volume (ou bind mount) com *Mount Path* `/data`. Sem isso os e-mails somem a cada deploy.
5. **Domains:** seu domínio, *Container Port* `3000`, HTTPS ligado.

## Baixar a lista de inscritos

```bash
curl -H "Authorization: Bearer $ADMIN_TOKEN" https://SEU-DOMINIO/api/subscribers.csv
```

## Imagens

`web/public/img/watch.png` (imagem de compartilhamento) sai do motion em `docs/assets/motion/`; o app na TV é recriado ao vivo em `web/src/components/BrowserMock.tsx`.
137 changes: 137 additions & 0 deletions site/server.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
// The JoyChromium site: serves dist/ (the React build) and keeps the beta sign-ups in one JSON-lines file on a volume.
// PORT port to listen on (default 3000)
// DATA_DIR where subscribers.jsonl lives (default ./data; mount a volume here in Dokploy)
// ADMIN_TOKEN required to download the list: GET /api/subscribers.csv with "Authorization: Bearer <token>" or ?token=
import { createServer } from "node:http";
import { appendFile, mkdir, readFile, stat } from "node:fs/promises";
import { createReadStream } from "node:fs";
import { extname, join, normalize } from "node:path";
import { timingSafeEqual } from "node:crypto";
import { fileURLToPath } from "node:url";

// web/ builds into dist/ (npm run build in site/web).
const root = fileURLToPath(new URL("./dist/", import.meta.url));
const dataDir = process.env.DATA_DIR ?? fileURLToPath(new URL("./data/", import.meta.url));
const listFile = join(dataDir, "subscribers.jsonl");
const adminToken = process.env.ADMIN_TOKEN ?? "";
const port = Number(process.env.PORT ?? 3000);

const TYPES = {
".html": "text/html; charset=utf-8", ".css": "text/css; charset=utf-8", ".js": "text/javascript; charset=utf-8",
".png": "image/png", ".jpg": "image/jpeg", ".svg": "image/svg+xml", ".mp4": "video/mp4", ".woff2": "font/woff2",
".ico": "image/x-icon", ".webmanifest": "application/manifest+json", ".txt": "text/plain; charset=utf-8",
};
const SECURITY = {
"X-Content-Type-Options": "nosniff",
"Referrer-Policy": "strict-origin-when-cross-origin",
"Content-Security-Policy": "default-src 'self'; img-src 'self' data:; media-src 'self'; style-src 'self'; script-src 'self'; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
};

export const isEmail = (value) => typeof value === "string" && value.length <= 254 && /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(value);

// shortcut: in-memory rate limit, resets on restart and is per instance; fine for one Dokploy container.
const hits = new Map();
const limited = (ip) => {
const now = Date.now();
const recent = (hits.get(ip) ?? []).filter((t) => now - t < 60_000);
recent.push(now);
hits.set(ip, recent);
return recent.length > 5;
};

async function subscribers() {
try {
return (await readFile(listFile, "utf8")).split("\n").filter(Boolean).map((line) => JSON.parse(line));
} catch (error) {
if (error.code === "ENOENT") return [];
throw error;
}
}

// Appends are serialized so two sign-ups at once cannot both pass the duplicate check.
let queue = Promise.resolve();
const subscribe = (email) => (queue = queue.then(async () => {
const known = await subscribers();
const at = known.findIndex((s) => s.email === email);
if (at >= 0) return { added: false, position: at + 1 };
await mkdir(dataDir, { recursive: true });
await appendFile(listFile, JSON.stringify({ email, at: new Date().toISOString() }) + "\n");
return { added: true, position: known.length + 1 };
}));

function readBody(req, limit = 2048) {
return new Promise((resolve, reject) => {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > limit) { reject(new Error("too large")); req.destroy(); }
});
req.on("end", () => resolve(body));
req.on("error", reject);
});
}

const send = (res, status, body, type = "application/json; charset=utf-8") => {
res.writeHead(status, { ...SECURITY, "Content-Type": type, "Cache-Control": "no-store" });
res.end(typeof body === "string" ? body : JSON.stringify(body));
};

function authorized(req, url) {
if (!adminToken) return false;
const given = (req.headers.authorization ?? "").replace(/^Bearer\s+/i, "") || url.searchParams.get("token") || "";
const a = Buffer.from(given), b = Buffer.from(adminToken);
return a.length === b.length && timingSafeEqual(a, b);
}

const server = createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost");
try {
if (url.pathname === "/api/subscribe" && req.method === "POST") {
const ip = (req.headers["x-forwarded-for"]?.toString().split(",")[0] ?? req.socket.remoteAddress ?? "").trim();
if (limited(ip)) return send(res, 429, { error: "Muitas tentativas. Tente de novo em um minuto." });
let data;
try { data = JSON.parse(await readBody(req)); } catch { return send(res, 400, { error: "Pedido inválido." }); }
if (data.website) return send(res, 200, { ok: true, already: false, position: 0 }); // honeypot: bots fill the hidden field
const email = String(data.email ?? "").trim().toLowerCase();
if (!isEmail(email)) return send(res, 422, { error: "Esse e-mail não parece válido." });
const { added, position } = await subscribe(email);
return send(res, 200, { ok: true, already: !added, position });
}
if (url.pathname === "/api/subscribers.csv" && req.method === "GET") {
if (!authorized(req, url)) return send(res, 401, { error: "unauthorized" });
const csv = "email,inscrito_em\n" + (await subscribers()).map((s) => `${s.email},${s.at}`).join("\n") + "\n";
return send(res, 200, csv, "text/csv; charset=utf-8");
}
if (url.pathname === "/healthz") return send(res, 200, "ok", "text/plain");
if (req.method !== "GET" && req.method !== "HEAD") return send(res, 405, { error: "method not allowed" });

const path = normalize(join(root, decodeURIComponent(url.pathname === "/" ? "/index.html" : url.pathname)));
if (!path.startsWith(root)) return send(res, 403, "forbidden", "text/plain");
const info = await stat(path).catch(() => undefined);
if (!info?.isFile()) return send(res, 404, "Página não encontrada", "text/plain; charset=utf-8");
const type = TYPES[extname(path)] ?? "application/octet-stream";
// Vite fingerprints everything under /assets: those never change; the rest is revalidated daily.
const cache = extname(path) === ".html" ? "no-cache" : url.pathname.startsWith("/assets/") ? "public, max-age=31536000, immutable" : "public, max-age=86400";
// Safari only plays a video served in byte ranges.
const range = /^bytes=(\d*)-(\d*)$/.exec(req.headers.range ?? "");
if (range && (range[1] || range[2])) {
const start = range[1] ? Number(range[1]) : Math.max(0, info.size - Number(range[2]));
const end = range[1] && range[2] ? Math.min(Number(range[2]), info.size - 1) : info.size - 1;
if (start > end || start >= info.size) return send(res, 416, "", "text/plain");
res.writeHead(206, { ...SECURITY, "Content-Type": type, "Content-Length": end - start + 1, "Content-Range": `bytes ${start}-${end}/${info.size}`, "Accept-Ranges": "bytes", "Cache-Control": cache });
if (req.method === "HEAD") return res.end();
return createReadStream(path, { start, end }).pipe(res);
}
res.writeHead(200, { ...SECURITY, "Content-Type": type, "Content-Length": info.size, "Cache-Control": cache, "Accept-Ranges": "bytes" });
if (req.method === "HEAD") return res.end();
createReadStream(path).pipe(res);
} catch (error) {
console.error(error);
send(res, 500, { error: "Erro no servidor. Tente de novo." });
}
});

if (process.argv[1] === fileURLToPath(import.meta.url)) {
server.listen(port, () => console.log(`JoyChromium site on :${port}`));
}
export { server };
26 changes: 26 additions & 0 deletions site/test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
// node site/test.mjs — sign-up end to end against a throwaway data dir.
import assert from "node:assert/strict";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "joy-site-"));
process.env.ADMIN_TOKEN = "segredo";
const { server, isEmail } = await import("./server.mjs");
assert.ok(isEmail("a@b.co") && !isEmail("nope") && !isEmail("a@b"));
await new Promise((r) => server.listen(0, r));
const base = `http://127.0.0.1:${server.address().port}`;
const post = (body) => fetch(`${base}/api/subscribe`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
assert.equal((await post({ email: "x" })).status, 422);
assert.deepEqual(await (await post({ email: "Ana@Exemplo.com" })).json(), { ok: true, already: false, position: 1 });
assert.deepEqual(await (await post({ email: "ana@exemplo.com" })).json(), { ok: true, already: true, position: 1 });
await post({ email: "bot@spam.com", website: "x" });
assert.equal((await fetch(`${base}/api/subscribers.csv`)).status, 401);
const csv = await (await fetch(`${base}/api/subscribers.csv`, { headers: { Authorization: "Bearer segredo" } })).text();
assert.match(csv, /^email,inscrito_em\nana@exemplo\.com,/);
assert.ok(!csv.includes("bot@spam.com"));
assert.equal((await fetch(`${base}/../server.mjs`)).status, 404);
assert.equal((await fetch(`${base}/`)).status, 200);
const range = await fetch(`${base}/img/demo.mp4`, { headers: { Range: "bytes=0-99" } });
assert.equal(range.status, 206); assert.equal((await range.arrayBuffer()).byteLength, 100);
server.close();
console.log("site ok");
22 changes: 22 additions & 0 deletions site/web/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>JoyChromium · A internet inteira, do sofá</title>
<meta name="description" content="Um navegador para a TV desenhado do zero para o controle. Entre no beta para Windows.">
<meta name="theme-color" content="#eef1f5" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#111827" media="(prefers-color-scheme: dark)">
<meta property="og:title" content="JoyChromium · A internet inteira, do sofá">
<meta property="og:description" content="Cursor no analógico, foco no D-pad, teclado de console. Entre no beta para Windows.">
<meta property="og:image" content="/img/watch.png">
<meta name="twitter:card" content="summary_large_image">
<link rel="icon" href="/img/mark.png">
<script src="/theme.js"></script>
</head>
<body>
<div id="root"></div>
<noscript><p style="padding:2rem;font-family:system-ui">O JoyChromium é um navegador para a TV feito para o controle. Ative o JavaScript para ver a página e entrar no beta.</p></noscript>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
Loading
Loading