Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
GITHUB_CLIENT_ID=your_github_app_client_id
GITHUB_CLIENT_SECRET=your_github_app_client_secret
GITHUB_REPOSITORY_ID=your_repository_id
ALLOWED_GITHUB_USER=devguimaraes
OAUTH_REDIRECT_URI=https://www.devguimaraes.com.br/api/auth/callback
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
name: CI

on:
workflow_dispatch:
pull_request:
push:
branches:
Expand Down
104 changes: 103 additions & 1 deletion api/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ async function importHandler() {
vi.stubEnv("GITHUB_CLIENT_ID", "test-client-id");
vi.stubEnv("GITHUB_CLIENT_SECRET", "test-client-secret");
vi.stubEnv("ALLOWED_GITHUB_USER", "devguimaraes");
vi.stubEnv("GITHUB_REPOSITORY_ID", "123456789");
const mod = await import("./auth");
return mod.default;
}
Expand All @@ -78,11 +79,60 @@ describe("api/auth handler", () => {
expect(redirectUrl).toContain("https://github.com/login/oauth/authorize");
expect(redirectUrl).toContain("client_id=test-client-id");
expect(redirectUrl).toContain("state=test-state-uuid");
expect(redirectUrl).toContain("scope=repo%2Cuser%3Aemail");
expect(redirectUrl).not.toContain("scope=");
expect(res.setHeader).toHaveBeenCalledWith(
"Set-Cookie",
expect.stringContaining("oauth_state:test-state-uuid="),
);
expect(res.setHeader).toHaveBeenCalledWith(
"Set-Cookie",
expect.stringContaining("Domain=.devguimaraes.com.br"),
);
});

it("sets Domain for www subdomain host", async () => {
const req = mockReq({
query: { provider: "github" },
headers: { host: "www.devguimaraes.com.br", origin: "https://www.devguimaraes.com.br" },
});
const res = mockRes();

await handler(req, res);

expect(res.setHeader).toHaveBeenCalledWith(
"Set-Cookie",
expect.stringContaining("Domain=.devguimaraes.com.br"),
);
});

it("sets Domain for staging subdomain host", async () => {
const req = mockReq({
query: { provider: "github" },
headers: { host: "staging.devguimaraes.com.br", origin: "https://staging.devguimaraes.com.br" },
});
const res = mockRes();

await handler(req, res);

expect(res.setHeader).toHaveBeenCalledWith(
"Set-Cookie",
expect.stringContaining("Domain=.staging.devguimaraes.com.br"),
);
});

it("sets Domain for apex host", async () => {
const req = mockReq({
query: { provider: "github" },
headers: { host: "devguimaraes.com.br", origin: "https://devguimaraes.com.br" },
});
const res = mockRes();

await handler(req, res);

expect(res.setHeader).toHaveBeenCalledWith(
"Set-Cookie",
expect.stringContaining("Domain=.devguimaraes.com.br"),
);
});

it("returns 400 for unsupported provider", async () => {
Expand Down Expand Up @@ -136,6 +186,19 @@ describe("api/auth handler", () => {

await handler(req, res);

expect(mockFetch).toHaveBeenNthCalledWith(
1,
"https://github.com/login/oauth/access_token",
expect.objectContaining({
body: JSON.stringify({
client_id: "test-client-id",
client_secret: "test-client-secret",
code: "abc123",
redirect_uri: "https://www.devguimaraes.com.br/api/auth/callback",
repository_id: "123456789",
}),
}),
);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.send).toHaveBeenCalledWith("Access denied");
});
Expand Down Expand Up @@ -176,6 +239,45 @@ describe("api/auth handler", () => {
expect(html).toContain("window.close");
});

it("escapes callback auth data before embedding it in script HTML", async () => {
mockFetch
.mockResolvedValueOnce({
json: () =>
Promise.resolve({
access_token: "tok</script><script>alert(1)</script>",
token_type: "bearer",
scope: "",
}),
})
.mockResolvedValueOnce({
json: () =>
Promise.resolve({
login: "devguimaraes",
name: "Bruno </script>",
avatar_url: "https://example.com/avatar.png?a=<x>&b=>",
bio: "bio & test",
}),
});

const req = mockReq({
query: { code: "valid-code", state: "test-state-uuid" },
headers: {
host: "devguimaraes.com.br",
origin: "https://devguimaraes.com.br",
cookie: "oauth_state:test-state-uuid=1",
},
});
const res = mockRes();

await handler(req, res);

const html = (res.send as ReturnType<typeof vi.fn>).mock.calls[0][0] as string;
const scriptBody = html.match(/<script>([\s\S]*)<\/script>/)?.[1] ?? "";
expect(scriptBody).not.toContain("</script>");
expect(scriptBody).toContain("\\u003c/script\\u003e");
expect(scriptBody).toContain("\\u0026");
});

it("returns 404 for unknown routes", async () => {
const req = mockReq({ query: {} });
const res = mockRes();
Expand Down
54 changes: 44 additions & 10 deletions api/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,40 @@ import type { VercelRequest, VercelResponse } from "@vercel/node";

const CLIENT_ID = process.env.GITHUB_CLIENT_ID ?? "";
const CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET ?? "";
const REPOSITORY_ID = process.env.GITHUB_REPOSITORY_ID ?? "";
const ALLOWED_USER = process.env.ALLOWED_GITHUB_USER ?? "";
const CSRF_STATE_PREFIX = "oauth_state:";
const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI ?? "https://www.devguimaraes.com.br/api/auth/callback";

function getDomain(host: string): string {
// Strip www. prefix so cookie is shared between apex and www
if (host.startsWith("www.")) {
return `.${host.slice(4)}`;
}
return `.${host}`;
}

function getStateCookie(oauthState: string, host: string): string {
const cookieParts = [
`${CSRF_STATE_PREFIX}${oauthState}=1`,
"Path=/",
"HttpOnly",
"Secure",
"SameSite=Lax",
"Max-Age=600",
`Domain=${getDomain(host)}`,
];

return cookieParts.join("; ");
}

function escapeForScript(value: string): string {
return value
.replace(/</g, "\\u003c")
.replace(/>/g, "\\u003e")
.replace(/&/g, "\\u0026");
}

export default async function handler(
req: VercelRequest,
res: VercelResponse,
Expand All @@ -22,11 +52,10 @@ export default async function handler(
}

const oauthState = crypto.randomUUID();
res.setHeader("Set-Cookie", `${CSRF_STATE_PREFIX}${oauthState}=1; Path=/; Domain=devguimaraes.com.br; HttpOnly; Secure; SameSite=Lax; Max-Age=600`);
res.setHeader("Set-Cookie", getStateCookie(oauthState, req.headers.host ?? ""));
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
scope: "repo,user:email",
state: oauthState,
});
res.redirect(`https://github.com/login/oauth/authorize?${params}`);
Expand All @@ -43,18 +72,23 @@ export default async function handler(
}

// Exchange code for access token
const tokenRequestBody: Record<string, string> = {
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI,
};
if (REPOSITORY_ID) {
tokenRequestBody.repository_id = REPOSITORY_ID;
}

const tokenRes = await fetch("https://github.com/login/oauth/access_token", {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI,
}),
body: JSON.stringify(tokenRequestBody),
});

const tokenData = (await tokenRes.json()) as {
Expand Down Expand Up @@ -87,15 +121,15 @@ export default async function handler(
// Return HTML that uses the NetlifyAuthenticator protocol:
// 1. Listen for "authorizing:github" from parent, respond to handshake
// 2. Then send "authorization:github:success:{JSON}" with token data
const authData = JSON.stringify({
const authData = escapeForScript(JSON.stringify({
token: tokenData.access_token,
provider: "github",
scope: tokenData.scope,
login: userData.login,
name: userData.name || userData.login,
avatar_url: userData.avatar_url || "",
bio: userData.bio || "",
});
}));
res.status(200).setHeader("Content-Type", "text/html; charset=utf-8").send(`<!DOCTYPE html>
<html><body><script>
function receiveMessage(e) {
Expand Down
31 changes: 31 additions & 0 deletions docs/ci-checklist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# CI Checklist

Checklist operacional para manter a qualidade local enquanto o GitHub Actions estiver bloqueado por billing.

## Rotina local (antes de commit/PR)

- [ ] Rodar `bun run lint`
- [ ] Rodar `bun run test:unit`
- [ ] Rodar `bun run build`
- [ ] Rodar `bun run test:e2e -- --project=chromium`
- [ ] Verificar `git status` limpo antes do commit
- [ ] Usar Conventional Commits nas mensagens
- [ ] Publicar trabalho na branch `develop`

## Retomada quando billing liberar

- [ ] Abrir **Actions > CI** no GitHub
- [ ] Disparar manualmente via **Run workflow** (branch `develop`)
- [ ] Confirmar execução dos jobs `lint`, `unit`, `build` e `e2e`
- [ ] Se falhar, salvar o link do run para análise
- [ ] Com CI verde em `develop`, seguir com PR de promoção para `main`

## Comandos úteis

```bash
bun run lint
bun run test:unit
bun run build
bun run test:e2e -- --project=chromium
```

28 changes: 0 additions & 28 deletions public/admin/config.yml

This file was deleted.

41 changes: 40 additions & 1 deletion public/admin/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,45 @@
<title>Admin | Bruno Guimarães</title>
</head>
<body>
<script src="https://unpkg.com/decap-cms@3.0.0/dist/decap-cms.js"></script>
<script>
window.CMS_MANUAL_INIT = true;
</script>
<script src="https://unpkg.com/decap-cms@3.0.0/dist/decap-cms.js" config="false"></script>
<script>
CMS.init({
config: {
backend: {
name: "github",
repo: "devguimaraes/developer-bruno",
branch: "main",
base_url: window.location.origin,
auth_endpoint: "/api/auth",
},
media_folder: "public/uploads",
public_folder: "/uploads",
locale: "pt-BR",
collections: [
{
name: "blog",
label: "Blog",
folder: "src/content/blog",
create: true,
slug: "{{slug}}",
preview_path: "blog/{{slug}}",
fields: [
{ label: "Título", name: "title", widget: "string" },
{ label: "Data", name: "date", widget: "datetime", format: "YYYY-MM-DD" },
{ label: "Tags", name: "tags", widget: "list", allow_add: true },
{ label: "Resumo", name: "excerpt", widget: "text" },
{ label: "Destacado", name: "featured", widget: "boolean", default: false },
{ label: "Imagem", name: "image", widget: "image", required: false },
{ label: "Autor", name: "author", widget: "string", default: "Bruno Guimarães" },
{ label: "Conteúdo", name: "body", widget: "markdown" },
],
},
],
},
});
</script>
</body>
</html>
Loading