Compare commits

...

18 commits

Author SHA1 Message Date
da93d052eb hero.kicker change 2026-10-01 10:47:24 -03:00
aa1bb53b96 pt about page correction 2026-10-01 10:23:06 -03:00
fa3f12a672 render about page from markdown, served at /pt/sobre in portuguese
The about body moves from i18n.ts to src/content/about/{en,pt}.md; the
slug frontmatter field sets each language's URL. /pt/about redirects to
/pt/sobre, and the language switcher and hreflang links point at the
right slug for the other language.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:12:51 -03:00
5e16c97462 merge fix 2026-09-30 19:21:47 -03:00
6dfe11a375 typography fix 2026-09-30 19:20:10 -03:00
14e3d2ad9a draft posts correction and linux since 10 post as draft 2026-09-30 11:08:40 -03:00
6cd13476dc i18n refactor 2026-09-29 16:50:21 -03:00
5f114e21e7 sitemap and rss 2026-09-29 12:12:07 -03:00
2db8a6925e umami analytics 2026-09-28 19:38:19 -03:00
4019956977 plugin4shell post og image regeneration 2026-09-28 15:11:54 -03:00
0cedf709b4 new plugin4shell post 2026-09-28 15:09:29 -03:00
b112df6454 regenerated 2 failed og cards 2026-09-28 14:43:38 -03:00
5e77706b87 og card regenerated 2026-09-28 14:39:34 -03:00
61d791c94e vercel deploy fix 2026-09-28 14:34:26 -03:00
523127092f og images generated 2026-09-28 14:25:21 -03:00
28d64159ab social cards 2026-09-28 14:06:43 -03:00
db0964e792 og images with cloudflare 2026-09-28 14:03:25 -03:00
1d8b7d58a4 new hello-world post 2026-09-22 18:04:06 -03:00
76 changed files with 1550 additions and 292 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
# Cloudflare Browser Run credentials for `npm run og` (copy to .env)
CF_ACCOUNT_ID=
CF_API_TOKEN=

3
.gitignore vendored
View file

@ -22,3 +22,6 @@ pnpm-debug.log*
# jetbrains setting folder # jetbrains setting folder
.idea/ .idea/
# vercel build output (generated by the adapter)
.vercel/

View file

@ -1,30 +1,84 @@
// @ts-check // @ts-check
import { readdirSync, readFileSync } from 'node:fs';
import { defineConfig } from 'astro/config'; import { defineConfig } from 'astro/config';
import { defineConfig, fontProviders } from 'astro/config';
import react from '@astrojs/react'; import react from '@astrojs/react';
import mdx from '@astrojs/mdx'; import mdx from '@astrojs/mdx';
import sitemap from '@astrojs/sitemap'
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import vercel from '@astrojs/vercel'; import vercel from '@astrojs/vercel';
// English used to be served without a /en prefix. Every URL published before
// the move keeps working through a 301; new pages only ever existed under /en.
// (Astro can't express this as a dynamic redirect in a static build: the
// destination must be an existing route with the same params.)
const LEGACY_EN_PATHS = [
'/',
'/about',
'/posts',
'/posts/hello-world',
'/posts/spotify-complaining',
'/posts/understanding-plugin4shell-git-trick-ai-coding-agents',
'/tags/ai',
'/tags/blogging',
'/tags/cybersecurity',
'/tags/git',
'/tags/life',
'/tags/meta',
'/tags/software',
'/feed.xml',
];
// Draft posts still get a page (reachable by URL) but stay out of the sitemap.
// This runs before content collections exist, so read the frontmatter directly.
const DRAFT_PATHS = readdirSync('./src/content/posts', { recursive: true, encoding: 'utf-8' })
.filter((file) => file.endsWith('.mdx'))
.map((file) => readFileSync(`./src/content/posts/${file}`, 'utf-8'))
.filter((raw) => /^draft:\s*true\s*$/m.test(raw))
.map((raw) => raw.match(/^slug:\s*"?(en|pt)-([^"\n]+)"?/m))
.filter((match) => match !== null)
.map(([, lang, slug]) => `/${lang}/posts/${slug.trim()}/`);
// https://astro.build/config // https://astro.build/config
export default defineConfig({ export default defineConfig({
site: 'https://gabeefran.co', site: 'https://gabeefran.co',
i18n: { // No `i18n` block on purpose: locales come from src/lib/i18n.ts and every
defaultLocale: 'en', // locale, English included, lives under src/pages/[lang]/. Astro's i18n
locales: ['en', 'pt'], // routing would add middleware that 404s the unprefixed on-demand
routing: { // /page-card and /social-card routes (prefix-always) or /en/* (default).
prefixDefaultLocale: false,
}, redirects: {
...Object.fromEntries(LEGACY_EN_PATHS.map((path) => [path, path === '/' ? '/en' : `/en${path}`])),
// The Portuguese about page moved to /pt/sobre (slug from src/content/about/pt.md).
'/pt/about': '/pt/sobre',
}, },
integrations: [react(), mdx()], integrations: [react(), mdx(), sitemap({ filter: (page) => !DRAFT_PATHS.some((path) => page.endsWith(path)) })],
adapter: vercel({ adapter: vercel({
webAnalytics: { enabled: true }, webAnalytics: { enabled: false },
}), }),
vite: { vite: {
plugins: [tailwindcss()], plugins: [tailwindcss()],
}, },
fonts: [
{
provider: fontProviders.local(),
name: "iA Writer Duo S",
cssVariable: "--font-iawriter",
options: {
variants: [
{
src: ['./src/assets/iAWriterDuoS-Regular.woff2'],
weight: 'normal',
style: 'normal'
}
]
}
}
]
}); });

192
package-lock.json generated
View file

@ -11,6 +11,8 @@
"@astrojs/markdown-satteri": "^0.4.0", "@astrojs/markdown-satteri": "^0.4.0",
"@astrojs/mdx": "^8.0.0", "@astrojs/mdx": "^8.0.0",
"@astrojs/react": "^6.0.5", "@astrojs/react": "^6.0.5",
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.4",
"@astrojs/vercel": "^11.0.9", "@astrojs/vercel": "^11.0.9",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
@ -289,6 +291,27 @@
"react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0" "react-dom": "^17.0.2 || ^18.0.0 || ^19.0.0"
} }
}, },
"node_modules/@astrojs/rss": {
"version": "4.0.19",
"resolved": "https://registry.npmjs.org/@astrojs/rss/-/rss-4.0.19.tgz",
"integrity": "sha512-e+z5wYeYtffQdHQO8c2tkSd2JEBdAuRXJV4ZEU5IxkYeE6e39woDd7nw1PH1Kk2tEYNCYuKdylnnbhGmt61awA==",
"license": "MIT",
"dependencies": {
"fast-xml-parser": "^5.5.7",
"piccolore": "^0.1.3",
"zod": "^4.3.6"
}
},
"node_modules/@astrojs/sitemap": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/@astrojs/sitemap/-/sitemap-3.7.4.tgz",
"integrity": "sha512-LbKNC24bdUWcQf/pThB6qLlSqHojxGjZDURIzFocY8rlWnAn2t74nnhnK6S5x0NHriHoAduLEpVjRykmeGiVvA==",
"license": "MIT",
"dependencies": {
"sitemap": "^9.0.0",
"zod": "^4.3.6"
}
},
"node_modules/@astrojs/telemetry": { "node_modules/@astrojs/telemetry": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz", "resolved": "https://registry.npmjs.org/@astrojs/telemetry/-/telemetry-3.3.3.tgz",
@ -1894,6 +1917,18 @@
"@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4"
} }
}, },
"node_modules/@nodable/entities": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
"integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/nodable"
}
],
"license": "MIT"
},
"node_modules/@oslojs/encoding": { "node_modules/@oslojs/encoding": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@oslojs/encoding/-/encoding-1.1.0.tgz",
@ -2898,6 +2933,15 @@
"@types/unist": "*" "@types/unist": "*"
} }
}, },
"node_modules/@types/node": {
"version": "24.19.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz",
"integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@types/react": { "node_modules/@types/react": {
"version": "19.2.18", "version": "19.2.18",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
@ -2916,6 +2960,15 @@
"@types/react": "^19.2.0" "@types/react": "^19.2.0"
} }
}, },
"node_modules/@types/sax": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/sax/-/sax-1.2.7.tgz",
"integrity": "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/unist": { "node_modules/@types/unist": {
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
@ -3155,6 +3208,24 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/anynum": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/arg": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz",
"integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==",
"license": "MIT"
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@ -3941,6 +4012,45 @@
"fast-string-width": "^3.0.2" "fast-string-width": "^3.0.2"
} }
}, },
"node_modules/fast-xml-builder": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"path-expression-matcher": "^1.6.2",
"xml-naming": "^0.3.0"
}
},
"node_modules/fast-xml-parser": {
"version": "5.11.2",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
"integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"@nodable/entities": "^3.0.1",
"fast-xml-builder": "^1.2.0",
"is-unsafe": "^2.0.0",
"path-expression-matcher": "^1.6.2",
"strnum": "^2.4.2",
"xml-naming": "^0.3.0"
},
"bin": {
"fxparser": "src/cli/cli.js"
}
},
"node_modules/fdir": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@ -4227,6 +4337,18 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-unsafe": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -5036,6 +5158,21 @@
"integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==", "integrity": "sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-expression-matcher": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": { "node_modules/path-key": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@ -5469,6 +5606,25 @@
"integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/sitemap": {
"version": "9.0.1",
"resolved": "https://registry.npmjs.org/sitemap/-/sitemap-9.0.1.tgz",
"integrity": "sha512-S6hzjGJSG3d6if0YoF5kTyeRJvia6FSTBroE5fQ0bu1QNxyJqhhinfUsXi9fH3MgtXODWvwo2BDyQSnhPQ88uQ==",
"license": "MIT",
"dependencies": {
"@types/node": "^24.9.2",
"@types/sax": "^1.2.1",
"arg": "^5.0.0",
"sax": "^1.4.1"
},
"bin": {
"sitemap": "dist/esm/cli.js"
},
"engines": {
"node": ">=20.19.5",
"npm": ">=10.8.2"
}
},
"node_modules/smol-toml": { "node_modules/smol-toml": {
"version": "1.8.0", "version": "1.8.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz", "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.8.0.tgz",
@ -5523,6 +5679,21 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/strnum": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.4.2.tgz",
"integrity": "sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"anynum": "^1.0.1"
}
},
"node_modules/svgo": { "node_modules/svgo": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.1.0.tgz",
@ -5692,6 +5863,12 @@
"node": ">=22.19.0" "node": ">=22.19.0"
} }
}, },
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/unified": { "node_modules/unified": {
"version": "11.0.5", "version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@ -6113,6 +6290,21 @@
"node": ">= 6.0" "node": ">= 6.0"
} }
}, },
"node_modules/xml-naming": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=16.0.0"
}
},
"node_modules/xxhash-wasm": { "node_modules/xxhash-wasm": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz", "resolved": "https://registry.npmjs.org/xxhash-wasm/-/xxhash-wasm-1.1.0.tgz",

View file

@ -9,12 +9,15 @@
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "preview": "astro preview",
"astro": "astro" "astro": "astro",
"og": "node --env-file-if-exists=.env scripts/generate-social-cards.ts"
}, },
"dependencies": { "dependencies": {
"@astrojs/markdown-satteri": "^0.4.0", "@astrojs/markdown-satteri": "^0.4.0",
"@astrojs/mdx": "^8.0.0", "@astrojs/mdx": "^8.0.0",
"@astrojs/react": "^6.0.5", "@astrojs/react": "^6.0.5",
"@astrojs/rss": "^4.0.19",
"@astrojs/sitemap": "^3.7.4",
"@astrojs/vercel": "^11.0.9", "@astrojs/vercel": "^11.0.9",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",

4
public/robots.txt Normal file
View file

@ -0,0 +1,4 @@
User-agent: *
Allow: /
Sitemap: https://gabeefran.co/sitemap-index.xml

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View file

@ -0,0 +1,163 @@
// Generates OG images for every post and every other page (home, posts,
// about, tags, 404) using Cloudflare Browser Run.
// Adapted from https://developers.cloudflare.com/browser-run/how-to/og-images-astro/
//
// Usage: npm run og (only missing images)
// npm run og -- --force (regenerate all)
//
// Needs CF_ACCOUNT_ID and CF_API_TOKEN (read from .env), and the
// /social-card and /page-card routes must be deployed at BASE_URL.
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { LANGS, pageMeta, tagPageMeta, type Lang, type PageKey } from '../src/lib/i18n.ts';
import { pageCardPath, postCardPath, tagCardPath } from '../src/lib/social-cards.ts';
const BASE_URL = process.env.SOCIAL_CARD_BASE_URL ?? 'https://gabeefran.co';
const CF_API = 'https://api.cloudflare.com/client/v4/accounts';
const POSTS_DIR = 'src/content/posts';
// 404.html is prerendered once, in English (see src/pages/404.astro).
const LANG_PAGES: Record<Lang, PageKey[]> = {
en: ['home', 'posts', 'about', '404'],
pt: ['home', 'posts', 'about'],
};
interface Post {
lang: Lang;
slug: string;
title: string;
description?: string;
tags: string[];
}
interface Card {
/** Path under public/, e.g. /social-cards/en/hello-world.png */
path: string;
/** Card template route: /social-card for posts, /page-card for everything else. */
route: '/social-card' | '/page-card';
params: Record<string, string>;
}
function getFrontmatterField(content: string, field: string): string | null {
const match = content.match(new RegExp(`^${field}:\\s*"?([^"\\n]+)"?`, 'm'));
return match ? match[1].trim() : null;
}
/** Reads an inline tag list, e.g. `tags: ["meta", "blogging"]`. */
function getFrontmatterTags(content: string): string[] {
const list = content.match(/^tags:\s*\[([^\]]*)\]/m)?.[1] ?? '';
return [...list.matchAll(/["']([^"']+)["']/g)].map((m) => m[1]);
}
/** Posts live at src/content/posts/<lang>/<slug>.mdx (see src/content.config.ts). */
function readPosts(): Post[] {
return LANGS.flatMap((lang) => {
const dir = join(POSTS_DIR, lang);
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith('.mdx'))
.map((file) => {
const raw = readFileSync(join(dir, file), 'utf-8');
if (getFrontmatterField(raw, 'draft') === 'true') return null;
const slug = file.replace(/\.mdx$/, '');
const title = getFrontmatterField(raw, 'title') ?? slug;
const description = getFrontmatterField(raw, 'description') ?? undefined;
const tags = getFrontmatterTags(raw);
return { lang, slug, title, description, tags };
})
.filter((p): p is Post => p !== null);
});
}
function listCards(posts: Post[]): Card[] {
const postCards: Card[] = posts.map((post) => {
const params: Record<string, string> = { title: post.title, author: 'Gabriel Franco' };
if (post.description) params.description = post.description;
return { path: postCardPath(post.lang, post.slug), route: '/social-card', params };
});
const pageCards: Card[] = LANGS.flatMap((lang) => {
const pages = LANG_PAGES[lang].map((key) => ({ path: pageCardPath(lang, key), meta: pageMeta(lang, key) }));
const tags = [...new Set(posts.filter((p) => p.lang === lang).flatMap((p) => p.tags))].sort();
const tagPages = tags.map((tag) => ({ path: tagCardPath(lang, tag), meta: tagPageMeta(lang, tag) }));
return [...pages, ...tagPages].map(({ path, meta }) => ({
path,
route: '/page-card' as const,
params: { title: meta.title, description: meta.description },
}));
});
return [...postCards, ...pageCards];
}
async function captureScreenshot(accountId: string, apiToken: string, pageUrl: string): Promise<ArrayBuffer> {
const res = await fetch(`${CF_API}/${accountId}/browser-run/screenshot`, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
url: pageUrl,
viewport: { width: 1200, height: 630 },
gotoOptions: { waitUntil: 'networkidle0' },
}),
});
if (!res.ok) {
throw new Error(`Screenshot API returned ${res.status}: ${await res.text()}`);
}
return res.arrayBuffer();
}
async function main() {
const accountId = process.env.CF_ACCOUNT_ID;
const apiToken = process.env.CF_API_TOKEN;
if (!accountId || !apiToken) {
console.error('Error: CF_ACCOUNT_ID and CF_API_TOKEN required (put them in .env)');
process.exit(1);
}
const force = process.argv.includes('--force');
const cards = listCards(readPosts());
console.log(`Found ${cards.length} cards to process\n`);
let generated = 0;
let skipped = 0;
let failed = 0;
for (let i = 0; i < cards.length; i++) {
const card = cards[i];
const outPath = join('public', card.path);
const name = card.path.replace(/^\/social-cards\//, '');
const label = `[${i + 1}/${cards.length}]`;
if (!force && existsSync(outPath)) {
console.log(`${label} ${name} — skipped (exists)`);
skipped++;
continue;
}
const url = `${BASE_URL}${card.route}?${new URLSearchParams(card.params)}`;
try {
const png = await captureScreenshot(accountId, apiToken, url);
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, Buffer.from(png));
console.log(`${label} ${name} — done`);
generated++;
} catch (err) {
console.error(`${label} ${name} — failed:`, err);
failed++;
}
// Stay under Browser Run rate limits.
if (i < cards.length - 1) {
await new Promise((resolve) => setTimeout(resolve, 10000));
}
}
console.log(`\nDone. Generated: ${generated}, Skipped: ${skipped}, Failed: ${failed}`);
if (failed > 0) process.exit(1);
}
main();

Binary file not shown.

View file

@ -11,25 +11,53 @@ const year = new Date().getFullYear();
--- ---
<footer class="border-t border-border/80 font-mono"> <footer class="border-t border-border/80 font-mono">
<div class="mx-auto flex max-w-3xl flex-col gap-2 px-4 py-8 text-sm text-text-muted sm:flex-row sm:items-center sm:justify-between sm:px-6"> <div class="mx-auto flex flex-col gap-2 px-4 py-8 items-center text-xs text-text-muted lg:flex-row lg:justify-center lg:px-6">
<p> <p>
&copy; {year} <span class="font-mono text-text">gabeefranco</span> &middot; {t('footer.rights')} &copy; {year} <span class="font-mono text-text">gabeefranco</span> &middot; {t('footer.rights')}
</p> </p>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<span class="hidden sm:inline">{t('footer.tagline')}</span>
<a <a
href="https://github.com/gabeefranco" href="https://github.com/gabeefranco"
target="_blank" target="_blank"
rel="noopener noreferrer me" rel="noopener noreferrer me"
class="transition-colors hover:text-primary" class="text-primary hover:text-primary-hover underline"
data-umami-event="github-profile-footer-click"
> >
GitHub GitHub
</a> </a>
<a <a
href="https://github.com/gabeefranco/gabeefran.co-blog" href="https://linkedin.com/in/gabeefranco"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="transition-colors hover:text-primary" class="text-primary hover:text-primary-hover underline"
data-umami-event="linkedin-footer-click"
>
LinkedIn
</a>
<a
href="https://x.com/gabeefranco_"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:text-primary-hover underline"
data-umami-event="twitter-footer-click"
>
X.com
</a>
<a
href="https://instagram.com/gabeefranco_"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:text-primary-hover underline"
data-umami-event="instagram-footer-click"
>
Instagram
</a>
<a
href="https://git.gabeefran.co/gabeefranco/gabeefran.co-blog"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:text-primary-hover underline"
data-umami-event="source-footer-click"
> >
{t('footer.source')} {t('footer.source')}
</a> </a>

View file

@ -1,9 +1,12 @@
--- ---
import ThemeToggle from './ThemeToggle.astro'; import ThemeToggle from './ThemeToggle.astro';
import { getAboutPath } from '../lib/about';
import { getLocalizedPath, LANG_LABELS, useTranslations, type Lang } from '../lib/i18n'; import { getLocalizedPath, LANG_LABELS, useTranslations, type Lang } from '../lib/i18n';
interface Props { interface Props {
lang: Lang; lang: Lang;
/** Where the language switch goes; defaults to swapping the /<lang> prefix. */
langSwitchHref?: string;
} }
const { lang } = Astro.props; const { lang } = Astro.props;
@ -13,17 +16,17 @@ const otherLang: Lang = lang === 'en' ? 'pt' : 'en';
const pathname = Astro.url.pathname; const pathname = Astro.url.pathname;
const homeHref = getLocalizedPath('/', lang); const homeHref = getLocalizedPath('/', lang);
const postsHref = getLocalizedPath('/posts', lang); const postsHref = getLocalizedPath('/posts', lang);
const aboutHref = getLocalizedPath('/about', lang); const aboutHref = await getAboutPath(lang);
const langSwitchHref = getLocalizedPath(pathname, otherLang); const langSwitchHref = Astro.props.langSwitchHref ?? getLocalizedPath(pathname, otherLang);
const navLinks = [ const navLinks = [
{ href: homeHref, label: t('nav.home') }, { href: homeHref, label: t('nav.home'), id: 'home' },
{ href: postsHref, label: t('nav.posts') }, { href: postsHref, label: t('nav.posts'), id: 'posts' },
{ href: aboutHref, label: t('nav.about') }, { href: aboutHref, label: t('nav.about'), id: 'about' },
]; ];
function isActive(href: string): boolean { function isActive(href: string): boolean {
if (href === homeHref) return pathname === href; if (href === homeHref) return pathname.replace(/\/$/, '') === href;
return pathname === href || pathname.startsWith(`${href}/`); return pathname === href || pathname.startsWith(`${href}/`);
} }
--- ---
@ -31,8 +34,8 @@ function isActive(href: string): boolean {
<header class="sticky top-0 z-40 border-b border-border/80 bg-bg/85 backdrop-blur"> <header class="sticky top-0 z-40 border-b border-border/80 bg-bg/85 backdrop-blur">
<div class="flex flex-col justify-center gap-4 px-4 py-3 sm:px-6"> <div class="flex flex-col justify-center gap-4 px-4 py-3 sm:px-6">
<div class="flex flex-row justify-between items-end"> <div class="flex flex-row justify-between items-center">
<a href={homeHref} class="flex hover:underline shrink-0 items-center gap-2.5" aria-label={t('site.name')}> <a href={homeHref} class="flex hover:underline shrink-0 items-center gap-2.5" aria-label={t('site.name')} data-umami-event="logo-header-click">
<img src="/logo.png" class="h-4"/> <img src="/logo.png" class="h-4"/>
<span class="font-mono text-[15px] font-semibold tracking-tight text-text">{t('site.name')}</span> <span class="font-mono text-[15px] font-semibold tracking-tight text-text">{t('site.name')}</span>
@ -43,7 +46,8 @@ function isActive(href: string): boolean {
hreflang={otherLang} hreflang={otherLang}
title={t('lang.switch')} title={t('lang.switch')}
aria-label={`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`} aria-label={`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`}
class="text-accent max-w-fit font-mono text-xs sm:text-sm underline hover:cursor-pointer hover:text-accent-deep" data-umami-event="switch-language-header-click"
class="text-accent max-w-fit font-mono text-xs sm:text-sm underline hover:cursor-pointer hover:text-accent-deep"
> >
{`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`} {`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`}
</a> </a>
@ -63,6 +67,7 @@ function isActive(href: string): boolean {
? 'text-primary hover:text-primary-hover' ? 'text-primary hover:text-primary-hover'
: 'text-text-muted hover:text-text', : 'text-text-muted hover:text-text',
]} ]}
data-umami-event={`${link.id}-header-click`}
> >
{link.label} {link.label}
</a> </a>
@ -73,7 +78,8 @@ function isActive(href: string): boolean {
hreflang={otherLang} hreflang={otherLang}
title={t('lang.switch')} title={t('lang.switch')}
aria-label={`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`} aria-label={`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`}
class="text-accent max-w-fit py-1 font-mono text-xs sm:text-sm underline hover:cursor-pointer hover:text-accent-deep " data-umami-event="switch-language-header-click"
class="text-accent max-w-fit py-1 font-mono text-xs sm:text-sm underline hover:cursor-pointer hover:text-accent-deep "
> >
{`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`} {`${t('lang.switch')}: ${LANG_LABELS[otherLang]}`}
</a> </a>
@ -94,6 +100,7 @@ function isActive(href: string): boolean {
? 'text-primary hover:text-primary-hover' ? 'text-primary hover:text-primary-hover'
: 'text-text-muted hover:text-text', : 'text-text-muted hover:text-text',
]} ]}
data-umami-event={`${link.id}-header-click`}
> >
{link.label} {link.label}
</a> </a>

View file

@ -1,6 +1,7 @@
--- ---
import PostCard from './PostCard.astro'; import PostCard from './PostCard.astro';
import { getPostsByLang } from '../lib/posts'; import { getPostsByLang } from '../lib/posts';
import { getAboutPath } from '../lib/about';
import { getLocalizedPath, useTranslations, type Lang } from '../lib/i18n'; import { getLocalizedPath, useTranslations, type Lang } from '../lib/i18n';
interface Props { interface Props {
@ -12,7 +13,7 @@ const t = useTranslations(lang);
const posts = await getPostsByLang(lang); const posts = await getPostsByLang(lang);
const recentPosts = posts.slice(0, 3); const recentPosts = posts.slice(0, 3);
const postsHref = getLocalizedPath('/posts', lang); const postsHref = getLocalizedPath('/posts', lang);
const aboutHref = getLocalizedPath('/about', lang); const aboutHref = await getAboutPath(lang);
--- ---
<section class="mx-10 my-10 flex flex-col gap-5 font-mono"> <section class="mx-10 my-10 flex flex-col gap-5 font-mono">

View file

@ -13,7 +13,7 @@ const href = getLocalizedPath(`/posts/${slugOf(post)}`, lang);
const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang); const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang);
--- ---
<article class="group border-b border-border py-6 first:pt-0 last:border-b-0 font-mono"> <article class="group border-b border-border py-6 first:pt-0 last:border-b-0 font-prose">
<a href={href} class="block"> <a href={href} class="block">
<time datetime={post.data.pubDate.toISOString()} class="text-xs font-medium uppercase tracking-wide text-text-muted"> <time datetime={post.data.pubDate.toISOString()} class="text-xs font-medium uppercase tracking-wide text-text-muted">
{formatDate(post.data.pubDate, lang)} {formatDate(post.data.pubDate, lang)}

View file

@ -14,6 +14,7 @@ const t = useTranslations(lang);
type="button" type="button"
class="theme-toggle text-accent sm:ml-2 font-mono text-xs sm:text-sm underline hover:cursor-pointer hover:text-accent-deep" class="theme-toggle text-accent sm:ml-2 font-mono text-xs sm:text-sm underline hover:cursor-pointer hover:text-accent-deep"
aria-label={t('theme.switch')} aria-label={t('theme.switch')}
data-umami-event="toggle-theme-header-click"
title={t('theme.switch')} title={t('theme.switch')}
> >
{t('theme.switch')} {t('theme.switch')}

View file

@ -9,11 +9,23 @@ const posts = defineCollection({
schema: z.object({ schema: z.object({
title: z.string(), title: z.string(),
description: z.string(), description: z.string(),
language: z.enum(['en', 'pt']),
pubDate: z.coerce.date(), pubDate: z.coerce.date(),
slug: z.string(),
updatedDate: z.coerce.date().optional(), updatedDate: z.coerce.date().optional(),
tags: z.array(z.string()).default([]), tags: z.array(z.string()).default([]),
draft: z.boolean().default(false), draft: z.boolean().default(false),
}), }),
}); });
export const collections = { posts }; // The about page, one file per language: src/content/about/<lang>.md.
// `slug` is the URL segment for that language (about, sobre).
const about = defineCollection({
loader: glob({ pattern: '*.md', base: './src/content/about' }),
schema: z.object({
language: z.enum(['en', 'pt']),
slug: z.string(),
}),
});
export const collections = { posts, about };

12
src/content/about/en.md Normal file
View file

@ -0,0 +1,12 @@
---
language: en
slug: about
---
Hi, I'm Gabe — Gabriel Franco. I'm a computer science student at PUCRS (Pontifícia Universidade Católica do Rio Grande do Sul), and this blog is where I write down the things I'm learning and thinking about along the way.
I care a lot about computer science as a field, not just as a set of tools to ship products with. I have a fairly specific way of doing things: I'd rather understand a system properly than memorize the steps to make it work, and I tend to slow down on purpose to figure out what's actually happening under the hood — in a compiler, an operating system, a language runtime, whatever it is. That habit shapes most of what ends up on this blog.
I'm also genuinely interested in the history of the open source movement: the people, the arguments, and the decisions that shaped the software we all rely on. Figures like Linus Torvalds and projects like GNU come up often in what I read and write about, not as trivia, but because understanding where a tool came from usually explains a lot about how and why it works the way it does today.
This site is bilingual, in English and in Portuguese, and every post exists in both languages. Thanks for stopping by.

12
src/content/about/pt.md Normal file
View file

@ -0,0 +1,12 @@
---
language: pt
slug: sobre
---
Olá, eu sou o Gabe — Gabriel Franco. Sou estudante de ciência da computação na PUCRS (Pontifícia Universidade Católica do Rio Grande do Sul), e este blog é onde registro as coisas que vou aprendendo e pensando pelo caminho.
Eu me importo bastante com ciência da computação como área, não só como um conjunto de ferramentas para lançar produtos. Tenho um jeito bem específico de fazer as coisas: prefiro entender um sistema de verdade do que decorar os passos para fazê-lo funcionar, e costumo desacelerar de propósito para descobrir o que realmente está acontecendo por baixo dos panos — em um compilador, em um sistema operacional, no runtime de uma linguagem, seja lá o que for. Esse hábito molda a maior parte do que acaba virando post aqui.
Também tenho um interesse genuíno pela história do movimento open source: as pessoas, os debates e as decisões que moldaram o software do qual todos dependemos. Figuras como Linus Torvalds e projetos como o GNU aparecem com frequência no que leio e escrevo, não como curiosidade, mas porque entender de onde uma ferramenta veio costuma explicar bastante sobre como e por que ela funciona do jeito que funciona hoje.
Este site é bilíngue, em inglês e em português, e todo post existe nos dois idiomas. Obrigado pela visita.

View file

@ -1,8 +1,17 @@
--- ---
title: "Hello, world" title: "This blog's Hello World (or whatever you want to call this site)"
description: "A short note on why this blog exists and what I plan to write about here." slug: "en-hello-world"
pubDate: 2026-01-12 description: "A short note on why this exists and what I plan to write about here."
pubDate: 2026-09-22
tags: ["meta", "blogging"] tags: ["meta", "blogging"]
language: "en"
--- ---
Hi! I've always had a creative side, as anyone who knows me can tell you. Sometimes it gets tucked away somewhere obscure, in the back of a drawer, waiting years to be used. And sometimes I make really good use of it.
I've always loved writing, and I've wanted a place of my own to publish my thoughts for a long time. Only the ones I'd want to be public, of course. Nobody wants to hand over the entire inner workings of their brain just like that, for free. Still, I think some of my intellectual adventures deserve a bit of exposure, especially since, without someone to judge them, I'll never know if they were worth it.
One of my inspirations for creating a site/blog was [Fábio Akita](https://instagram.com/akitaonrails). I've been following him for a long time, and it was through the videos on [his YouTube channel](https://youtube.com/akitaonrails) that I started studying computer science, before I even got into college. Even though the videos are genuinely great, teaching things that are hard to learn any other way, after a while of binge-watching them I found his blog, [akitaonrails.com](https://akitaonrails.com). I thought it was super cool, and I liked the idea of having a personal place to share your opinions and the things you've learned.
Akita is a seasoned guy who's been in the industry for years. Me? Just a student slightly ahead of the material in my CS program. To people my age, this whole writing thing, having a blog, sounds like something old people do. But, like I said in my [last post](https://gabeefran.co/posts/spotify-complaining), I like the idea of doing things the old-fashioned way every now and then. The word _"Blog"_ sounds so 2010, but I don't know what else I'd call this crappy site you're reading. I guess _"blog"_ it is. Good thing I've been practicing caring less about what other people think, and look at that: it's working!
Anyway, call it whatever you want, I think it's worth posting something here every now and then. Even if nobody's interested, whatever! Then let it just be my public folder of notes. Just the fact that I opened the drawer and pulled my creativity out from the back of it already makes it all worth it.

View file

@ -1,8 +1,10 @@
--- ---
title: "Spotify's Time Capsule Change and the Enshittification of Software" title: "Spotify's Time Capsule Change and the Enshittification of Software"
slug: "en-spotify-complaining"
description: "Spotify just removed one of its best features. Beyond a bit of complaining, I want to open a debate about what software is turning into." description: "Spotify just removed one of its best features. Beyond a bit of complaining, I want to open a debate about what software is turning into."
pubDate: 2026-09-2 pubDate: 2026-09-2
tags: ["software", "life"] tags: ["software", "life"]
language: "en"
--- ---
import ObservationCard from '../../../components/ObservationCard.tsx' import ObservationCard from '../../../components/ObservationCard.tsx'

View file

@ -0,0 +1,230 @@
---
title: "Understanding Plugin4Shell: An Exploit That Uses a Simple Git Trick to Fool AI Coding Agents"
description: "A git branch named after a commit hash was enough to make AI coding agents run unverified plugins. I reproduced the Plugin4Shell trick on GitHub and Forgejo."
slug: "en-understanding-plugin4shell-git-trick-ai-coding-agents"
pubDate: 2026-09-28
tags: ["cybersecurity", "git", "ai", "software"]
seoKeywords: "Plugin4Shell, git branch named after commit hash, SHA pinning bypass, AI coding agents security, Claude Code plugin vulnerability, supply chain attack, git internals, Forgejo"
language: "en"
---
This post is based on [Ana Maria Constantin's post at TheNextWeb](https://thenextweb.com/news/plugin4shell-ai-coding-agents-zero-click-rce-sha-pinning) and [Air Security's report](https://www.air.security/blog-posts/plugin4shell).
AI coding agents like Claude Code and Codex install plugins from their marketplaces. Knowing that attackers could use plugins to ship malicious code, AI companies employed a simple security measure. They locked the plugins to a version that was reviewed and approved. But what if someone could bypass that lock? That's *exactly* what happened.
Researchers at Air Security discovered a vulnerability they called [Plugin4Shell](https://www.air.security/blog-posts/plugin4shell), which exploited how agents verified plugins. With a simple git trick, they were able to make the agent download and run unverified code. That means **Remote Code Execution**, token stealing, or anything that malicious code does.
Anthropic fixed it in **Claude Code 2.1.179** and OpenAI in **Codex 0.146.0**. Anyway, as a Computer Science student, I think this is a great opportunity to learn about git, supply chain attacks, and cybersecurity in general. So, let's dive into it!
## The Scenario
The industry's defense against malicious plugins was to *pin* each plugin to a specific commit. That commit was reviewed and approved, so it was considered safe. A commit hash is a SHA-1 of the commit's content and history, so it's content-addressed and *immutable*. But there is an issue with git itself. **Branches and tags are just movable name tags.** Interesting...
The commit hash is a 40-character-long string. What if there is a branch with the same name as a commit hash? Some git commands, like `checkout` and `clone --branch`, **actually prefer the branch**.
That's what they used to trick the agent into downloading and running unverified code!
## The attack, step by step
The attack consisted of the following steps:
1. The attackers create a plugin that is completely secure, no exploits and no malicious code. Let's suppose the hash for that commit is `xxxx`.
2. They get the marketplace's approval, and the plugin is pinned at commit `xxxx`.
3. Users start downloading and using the plugin.
4. They ship malicious code in a separate branch, named `xxxx`.
5. The agents refresh the plugin in the background, **no user interaction needed**.
6. Instead of the pinned commit, git chooses the latest commit on the branch with the same name.
7. The malicious code gets executed and, just like that, users are hacked.
## How does it work?
Let's test this trick ourselves in a local git repository. First, we create the repo and make a clean commit to pass the verification:
```sh
git init demo && cd demo
echo "print('safe plugin')" > plugin.py
git add . && git commit -m "safe version"
```
Now, let's run `git log` to get the commit hash.
```
commit 290b9e171e7b6facfe1e244a22c7152b31b6c290 (HEAD -> main)
Author: Gabriel Franco <gabe@example.com>
Date: Mon Sep 28 09:10:09 2026 -0300
safe version
```
There it is! `290b9e171e7b6facfe1e244a22c7152b31b6c290` is our commit hash. Now, let's create the evil branch:
```sh
git switch -c evil
echo "print('you got HACKED')" > plugin.py
git commit -am "evil version"
```
But the branch name is still `evil`. Sounds pretty suspicious, actually. Let's change it to our commit hash.
```sh
git branch -m 290b9e171e7b6facfe1e244a22c7152b31b6c290
```
This renamed the branch, so its name is exactly the hash from the safe and verified commit. Now, let's switch to the main branch with `git switch -` and try to access our safe commit with `git checkout`:
```sh
git checkout 290b9e171e7b6facfe1e244a22c7152b31b6c290
```
Git even gave me a warning:
```
warning: refname '290b9e171e7b6facfe1e244a22c7152b31b6c290' is ambiguous.
Git normally never creates a ref that ends with 40 hex characters
because it will be ignored when you just specify 40-hex. These refs
may be created by mistake. For example,
git switch -c $br $(git rev-parse ...)
where "$br" is somehow empty and a 40-hex ref is created. Please
examine these refs and maybe delete them. Turn this message off by
running "git config set advice.objectNameWarning false"
Switched to branch '290b9e171e7b6facfe1e244a22c7152b31b6c290'
```
That's a good thing. But the warning probably isn't seen by the agent, who is not expecting any output from `checkout`, as long as its exit status is 0.
Let's check the content of `plugin.py`:
```python
print('you got HACKED')
```
Oops... Looks like we got HACKED!
## But can the git platforms fix it?
Apparently, GitHub already did it. I couldn't find a source that confirms when this change was made, but let's test it ourselves. I created a private repository on GitHub, then went back to the terminal:
```sh
git switch main
git remote add origin git@github.com:gabeefranco/demo-trick.git
git push -u origin main
```
Well, the `main` branch pushes fine:
```
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 236 bytes | 236.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To github.com:gabeefranco/demo-trick.git
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.
```
Now, let's see what happens to our branch named after the commit hash:
```sh
git push -u origin 290b9e171e7b6facfe1e244a22c7152b31b6c290
```
As we can see, GitHub rejects it:
```
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 269 bytes | 269.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote: error: GH002: Sorry, branch or tag names consisting of 40 or 64 hex characters are not allowed.
remote: error: Invalid branch or tag name "290b9e171e7b6facfe1e244a22c7152b31b6c290"
To github.com:gabeefranco/demo-trick.git
! [remote rejected] 290b9e171e7b6facfe1e244a22c7152b31b6c290 -> 290b9e171e7b6facfe1e244a22c7152b31b6c290 (pre-receive hook declined)
error: failed to push some refs to 'github.com:gabeefranco/demo-trick.git'
```
That's really good! Kudos to GitHub and all their Microsoft slop. They surprised me *this time*!
### What about other platforms?
In AI plugin marketplaces, the git repositories can be hosted on any platform, including a self-hosted Forgejo instance, for example. [Forgejo](https://forgejo.org/) is a fork of [Gitea](https://about.gitea.com/), and it allows us to self-host our git projects in the style of GitHub, but 100% open-source. It's good open-source software, I will write a post about it someday. My Forgejo version is `9.0.3+gitea-1.22.0`, as you can verify with `curl -s https://git.gabeefran.co/api/v1/version`. Keep in mind the behavior I showcase here may change in a future release.
In my own instance, let's test the branch naming. After creating a repository (this time, I will keep it public), I ran:
```sh
git remote remove origin
git remote add origin git@git.gabeefran.co:gabeefranco/git-trick.git
git push -u origin main
```
Again, `main` pushes correctly:
```
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 236 bytes | 236.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To git.gabeefran.co:gabeefranco/git-trick.git
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.
```
But `main` is verified by the marketplace. Let's test our evil branch:
```sh
git push -u origin 290b9e171e7b6facfe1e244a22c7152b31b6c290
```
Unfortunately, Forgejo **doesn't implement the same fix**:
```
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 269 bytes | 269.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: Create a new pull request for '290b9e171e7b6facfe1e244a22c7152b31b6c290':
remote: https://git.gabeefran.co/gabeefranco/git-trick/compare/main...290b9e171e7b6facfe1e244a22c7152b31b6c290
remote:
To git.gabeefran.co:gabeefranco/git-trick.git
* [new branch] 290b9e171e7b6facfe1e244a22c7152b31b6c290 -> 290b9e171e7b6facfe1e244a22c7152b31b6c290
branch '290b9e171e7b6facfe1e244a22c7152b31b6c290' set up to track 'origin/290b9e171e7b6facfe1e244a22c7152b31b6c290'.
```
I will keep this repository public. You can check it out [here](https://git.gabeefran.co/gabeefranco/git-trick). Just note that the commit hashes are different, because I had to edit them in order to maintain the consistency in the writing of this post.
## How to defend against it
First things first: if you use Claude Code or Codex, update them. Anything from **Claude Code 2.1.179** and **Codex 0.146.0** onwards is already fixed.
But what if you're writing a tool that pins git commits, just like the agents do? My first idea was `git checkout --detach`, since it should treat the argument as a commit, not a branch. I tested it in the same demo repo:
```sh
git checkout --detach 290b9e171e7b6facfe1e244a22c7152b31b6c290
```
And `plugin.py` still said `you got HACKED`. Not even `--detach` saves us here! What actually works is appending `^{commit}` to the hash, which forces git to resolve it as a commit object:
```sh
git checkout --detach "290b9e171e7b6facfe1e244a22c7152b31b6c290^{commit}"
```
This time, we got the `safe plugin` back. Even so, don't trust the resolution blindly. **After checking out, always verify that `HEAD` is exactly the pinned hash**, and abort if it isn't:
```sh
PIN=290b9e171e7b6facfe1e244a22c7152b31b6c290
[ "$(git rev-parse HEAD)" = "$PIN" ] || { echo "HEAD doesn't match the pin, aborting!"; exit 1; }
```
It's one line of shell, and it would have stopped this attack. If the agents did this, the evil branch would be useless.
And if you self-host your git projects, like me, you can do what GitHub did and reject branch and tag names made of 40 or 64 hex characters with a pre-receive hook. There's no legitimate reason for a branch to look like a commit hash anyway.
## Wrapping up
The main problem was that the agents asked git for the pinned commit, but they never verified if what they got back actually matched that hash. Pinning to commit SHAs is the standard advice against supply-chain attacks. Plugin4Shell shows that **pinning is only as good as how you _resolve_ the pin**.
By studying this case, we learned a lot about supply chain attacks, git internals and the cybersecurity involved in the AI agents scene. Many props to [Air Security](https://www.air.security/) for researching this topic and discovering this vulnerability.
I had a lot of fun writing this post, hope you enjoyed!

View file

@ -1,8 +1,19 @@
--- ---
title: "Olá, mundo" title: "O Hello World desse blog (ou seja lá como você queira chamar esse site)"
description: "Uma nota curta sobre por que este blog existe e sobre o que pretendo escrever aqui." description: "Uma nota curta sobre por que isso existe e sobre o que pretendo escrever aqui."
pubDate: 2026-01-12 pubDate: 2026-09-22
slug: "pt-hello-world"
tags: ["meta", "blogging"] tags: ["meta", "blogging"]
language: "pt"
--- ---
Oie Eu sempre tive um lado criativo, quem me conhece sabe. Às vezes ele fica guardado em algum lugar obscuro, no fundo da gaveta, esperando anos pra ser utilizado. E às vezes eu me aproveito dele muito bem.
Sempre gostei muito de escrever, e faz tempo que queria ter um lugar só meu pra publicar meus pensamentos. Mas só aqueles que eu gostaria que fossem públicos, claro. Ninguém quer entregar todo o funcionamento interno do seu cérebro assim, de graça. Ainda assim, acho que algumas das minhas aventuras intelectuais merecem um pouco de exposição, até porque, sem alguém pra julgar, nunca saberei se valeram a pena.
Uma das minhas inspirações pra criar um site/blog foi o [Fábio Akita](https://instagram.com/akitaonrails). Eu acompanho ele há muito tempo, e foi por meio dos vídeos do [canal do YouTube dele](https://youtube.com/akitaonrails) que eu comecei a estudar sobre computação, antes de entrar na faculdade. Apesar dos vídeos serem realmente muito bons, fornecendo aprendizados difíceis de obter de outra forma, depois de um tempo maratonando os vídeos eu descobri o blog dele, [akitaonrails.com](https://akitaonrails.com). Eu achei muito massa, e gostei da ideia de se ter um lugar pessoal onde se expõem opiniões e aprendizados.
O Akita é um cara com experiência, que vive há anos na indústria. Eu, um mero estudante levemente adiantado no conteúdo da faculdade de computação. Esse negócio de escrever, ter um blog, parece coisa de gente velha pras pessoas da minha geração. Mas, como falei no [último post](https://gabeefran.co/pt/posts/spotify-complaining), eu gosto da ideia de fazer coisas à moda antiga de vez em quando. O termo _"Blog"_ soa muito 2010, mas não sei que outro nome daria a esta porcaria deste site que você está lendo. Acho que vai ser _"blog"_ mesmo. Bem que eu tenho treinado pra ligar menos pra opinião dos outros, e olha só: tá funcionando!
Enfim, chame como você quiser, eu acho que vale a pena publicar algo de vez em quando por aqui. Mesmo que ninguém esteja interessado, tanto faz! Que seja apenas a minha pasta pública de notas, então. Só de eu ter aberto a gaveta e tirado de lá do fundo a minha criatividade, considero que já valeu a pena.

View file

@ -0,0 +1,50 @@
---
title: "Eu Uso Linux Desde os Meus 10 Anos de Idade: Aqui Está o Que Ninguém te Conta"
description: "ainda não tem descrição"
slug: "pt-linux-since-10-what-nobody-tells-you"
pubDate: 2026-09-30
tags: ["draft"]
seoKeywords: ""
draft: true
language: "pt"
---
Quando eu comecei a programar, ouvi dizer que pra aprender programação de verdade tem que usar Linux. Eu simplesmente fui lá e segui essa dica à risca. Instalei o Ubuntu 18.04 em dual boot com o Windows 7, e a partir daí nunca mais pensei da mesma forma.
Nesse post, quero compartilhar um pouco da minha experiência, e dar algumas dicas pra quem está começando agora ou quer começar em breve.
## Um pouco da minha história
Tudo começa em maio de 2018. Eu tinha 10 anos de idade e já sabia que gostava muito de tecnologia, e tinha muito interesse por informática e edição de vídeos. O ponto de partida foi meu pai, que me levou a uma livraria aqui em Porto Alegre pra escolher um livro, já que ele queria que eu retomasse o gosto pela leitura. Ele sempre foi o maior [early adopter](https://en.wikipedia.org/wiki/Technology_adoption_life_cycle) das fases que se iniciavam na minha vida, até hoje é.
Ele queria que eu comprasse um livro de literatura mesmo, de histórias com início, meio e fim. Eu, por outro lado, queria comprar um livro que me ensinasse alguma coisa nova, dentro dos meus interesses. Concordamos em levar um de cada. Eu escolhi ["Programação em Python 3"](https://www.estantevirtual.com.br/livro/programacao-em-python-3-0FS-0355-000-BK), do [Mark Summerfield](https://mark-summerfield.github.io/index.html).
Meu pai tinha um PC que ele raramente usava. Na verdade, quem usava era eu, pra aprender programação e praticar o conteúdo do livro. Tinha um AMD Athlon, já ultrapassado mesmo naquela época, e rodava Windows 7. Conseguia programar, mas era meio lento. Além disso, eu era muito de pesquisar na internet, principalmente no YouTube, por dicas sobre assuntos que eu gostava. Assim, numa dessas pesquisas, vi alguns vídeos que diziam que Linux era melhor pra programar e aprender sobre informática e computação. Ouvi que tinha ferramentas melhores e era mais prático. Então já fui logo pesquisar como instalar, e depois de muito tempo tentando e me perguntando se tinha apagado os arquivos do meu pai, consegui colocar o Ubuntu ao lado do Windows 7 sem perder nada.
## A real vantagem do Linux
Depois de algum tempo nesse mundo, percebi que a maior vantagem de ter escolhido o Linux não foi a praticidade, nem nada parecido. Foi aprender na marra sobre como um sistema operacional funciona, e como consertar coisas que dão errado.
cotinuar ....
## Acho que eu sei como posso te ajudar
Dar dicas sobre como se adaptar melhor ao linux, tirando o mindset do windows da cabeça, comparando com como as coisas são no windows. A finalidade é ajudar o eleitor a se acostumar mais rápido. Falar com o leitor em segunda pessoa nessa seção.
### Entenda o terminal
Falar que o terminal não é a única opção, mas falar sobre a importância de saber fazer as coisas pela linha de comando.
### Entenda o gerenciamento de pacotes
Falar sobre o que é gerenciamento de pacotes, como ele funciona no windows, e como funciona nas distros linux.
### Entenda os drivers
Falar sobre minha experiência com os drivers de wifi como exemplo
## Enfim...
Conclusão.

View file

@ -1,8 +1,10 @@
--- ---
title: "A mudança na cápsula do tempo do Spotify e a bostificação do software" title: "A mudança na cápsula do tempo do Spotify e a bostificação do software"
description: "Spotify simplesmente removeu uma das suas melhores funcionalidades. Além de reclamar um pouco, quero abrir um debate sobre o que os softwares estão se tornando." description: "Spotify simplesmente removeu uma das suas melhores funcionalidades. Além de reclamar um pouco, quero abrir um debate sobre o que os softwares estão se tornando."
slug: "pt-spotify-complaining"
pubDate: 2026-09-2 pubDate: 2026-09-2
tags: ["software", "life"] tags: ["software", "life"]
language: "pt"
--- ---
import ObservationCard from '../../../components/ObservationCard.tsx' import ObservationCard from '../../../components/ObservationCard.tsx'

View file

@ -0,0 +1,230 @@
---
title: "Entendendo o Plugin4Shell: um exploit que usa um truque simples do git para enganar agentes de IA de programação"
description: "Uma branch do git com o nome de um hash de commit bastou para fazer agentes de IA de programação rodarem plugins não verificados. Reproduzi o truque do Plugin4Shell no GitHub e no Forgejo."
slug: "pt-understanding-plugin4shell-git-trick-ai-coding-agents"
pubDate: 2026-09-28
tags: ["cybersecurity", "git", "ai", "software"]
seoKeywords: "Plugin4Shell, branch do git com nome de hash de commit, bypass de SHA pinning, segurança de agentes de IA de programação, vulnerabilidade de plugin do Claude Code, ataque à cadeia de suprimentos, internals do git, Forgejo"
language: "pt"
---
Este post é baseado [no texto da Ana Maria Constantin no TheNextWeb](https://thenextweb.com/news/plugin4shell-ai-coding-agents-zero-click-rce-sha-pinning) e [no relatório da Air Security](https://www.air.security/blog-posts/plugin4shell).
Agentes de IA de programação como o Claude Code e o Codex instalam plugins dos seus marketplaces. Sabendo que atacantes poderiam usar plugins para distribuir código malicioso, as empresas de IA adotaram uma medida de segurança simples: travaram os plugins numa versão que tinha sido revisada e aprovada. Mas e se alguém conseguisse burlar essa trava? Foi *exatamente* isso que aconteceu.
Pesquisadores da Air Security descobriram uma vulnerabilidade que batizaram de [Plugin4Shell](https://www.air.security/blog-posts/plugin4shell), que explorava a forma como os agentes verificavam os plugins. Com um truque simples de git, eles conseguiram fazer o agente baixar e rodar código não verificado. Isso significa **execução remota de código (RCE)**, roubo de tokens ou qualquer coisa que um código malicioso possa fazer.
A Anthropic corrigiu isso no **Claude Code 2.1.179**, e a OpenAI, no **Codex 0.146.0**. De qualquer forma, como estudante de Ciência da Computação, acho que essa é uma ótima oportunidade pra aprender sobre git, ataques à cadeia de suprimentos e cibersegurança em geral. Então, bora lá!
## O cenário
A defesa da indústria contra plugins maliciosos era *fixar* (o famoso *pinning*) cada plugin num commit específico. Esse commit tinha sido revisado e aprovado, então era considerado seguro. O hash de um commit é um SHA-1 do conteúdo e do histórico do commit, ou seja, ele é endereçado por conteúdo e *imutável*. Mas existe um problema no próprio git. **Branches e tags são só etiquetas com nome, que podem ser movidas.** Interessante...
O hash do commit é uma string de 40 caracteres. E se existir uma branch com o mesmo nome de um hash de commit? Alguns comandos do git, como `checkout` e `clone --branch`, **preferem a branch**.
Foi isso que eles usaram pra enganar o agente e fazer ele baixar e rodar código não verificado!
## O ataque, passo a passo
O ataque seguia estes passos:
1. Os atacantes criam um plugin completamente seguro, sem exploits e sem código malicioso. Vamos supor que o hash desse commit seja `xxxx`.
2. Eles conseguem a aprovação do marketplace, e o plugin fica fixado no commit `xxxx`.
3. Os usuários começam a baixar e usar o plugin.
4. Eles publicam código malicioso numa branch separada, chamada `xxxx`.
5. Os agentes atualizam o plugin em segundo plano, **sem precisar de nenhuma interação do usuário**.
6. Em vez do commit fixado, o git escolhe o último commit da branch com o mesmo nome.
7. O código malicioso é executado e, simples assim, os usuários são hackeados.
## Como isso funciona?
Vamos testar esse truque por conta própria num repositório git local. Primeiro, criamos o repo e fazemos um commit limpo pra passar pela verificação:
```sh
git init demo && cd demo
echo "print('safe plugin')" > plugin.py
git add . && git commit -m "safe version"
```
Agora, vamos rodar `git log` pra pegar o hash do commit.
```
commit 290b9e171e7b6facfe1e244a22c7152b31b6c290 (HEAD -> main)
Author: Gabriel Franco <gabe@example.com>
Date: Mon Sep 28 09:10:09 2026 -0300
safe version
```
Aí está! `290b9e171e7b6facfe1e244a22c7152b31b6c290` é o nosso hash de commit. Agora, vamos criar a branch do mal:
```sh
git switch -c evil
echo "print('you got HACKED')" > plugin.py
git commit -am "evil version"
```
Mas o nome da branch ainda é `evil`. Na real, isso soa bem suspeito. Vamos trocar pelo nosso hash de commit.
```sh
git branch -m 290b9e171e7b6facfe1e244a22c7152b31b6c290
```
Isso renomeou a branch, então o nome dela agora é exatamente o hash do commit seguro e verificado. Agora, vamos voltar pra branch main com `git switch -` e tentar acessar nosso commit seguro com `git checkout`:
```sh
git checkout 290b9e171e7b6facfe1e244a22c7152b31b6c290
```
O git até me deu um aviso:
```
warning: refname '290b9e171e7b6facfe1e244a22c7152b31b6c290' is ambiguous.
Git normally never creates a ref that ends with 40 hex characters
because it will be ignored when you just specify 40-hex. These refs
may be created by mistake. For example,
git switch -c $br $(git rev-parse ...)
where "$br" is somehow empty and a 40-hex ref is created. Please
examine these refs and maybe delete them. Turn this message off by
running "git config set advice.objectNameWarning false"
Switched to branch '290b9e171e7b6facfe1e244a22c7152b31b6c290'
```
Isso é bom. Mas o aviso provavelmente passa batido pelo agente, que não espera nenhuma saída do `checkout`, desde que o status de saída seja 0.
Vamos conferir o conteúdo do `plugin.py`:
```python
print('you got HACKED')
```
Ops... Parece que fomos HACKEADOS!
## Mas as plataformas de git conseguem corrigir isso?
Pelo visto, o GitHub já corrigiu. Não achei nenhuma fonte que confirme quando essa mudança foi feita, mas vamos testar por conta própria. Criei um repositório privado no GitHub e voltei pro terminal:
```sh
git switch main
git remote add origin git@github.com:gabeefranco/demo-trick.git
git push -u origin main
```
Bom, a branch `main` sobe sem problema:
```
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 236 bytes | 236.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To github.com:gabeefranco/demo-trick.git
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.
```
Agora, vamos ver o que acontece com a nossa branch batizada com o hash do commit:
```sh
git push -u origin 290b9e171e7b6facfe1e244a22c7152b31b6c290
```
Como dá pra ver, o GitHub rejeita:
```
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 269 bytes | 269.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote: error: GH002: Sorry, branch or tag names consisting of 40 or 64 hex characters are not allowed.
remote: error: Invalid branch or tag name "290b9e171e7b6facfe1e244a22c7152b31b6c290"
To github.com:gabeefranco/demo-trick.git
! [remote rejected] 290b9e171e7b6facfe1e244a22c7152b31b6c290 -> 290b9e171e7b6facfe1e244a22c7152b31b6c290 (pre-receive hook declined)
error: failed to push some refs to 'github.com:gabeefranco/demo-trick.git'
```
Isso é muito bom! Palmas pro GitHub e pra todo o slop da Microsoft. *Dessa vez* eles me surpreenderam!
### E as outras plataformas?
Nos marketplaces de plugins de IA, os repositórios git podem estar hospedados em qualquer plataforma, inclusive numa instância self-hosted do Forgejo, por exemplo. O [Forgejo](https://forgejo.org/) é um fork do [Gitea](https://about.gitea.com/), e permite hospedar nossos próprios projetos git no estilo do GitHub, só que 100% open-source. É um bom software open-source, e um dia ainda escrevo um post sobre ele. Minha versão do Forgejo é a `9.0.3+gitea-1.22.0`, como dá pra conferir com `curl -s https://git.gabeefran.co/api/v1/version`. Lembre que o comportamento que eu mostro aqui pode mudar numa versão futura.
Na minha própria instância, vamos testar a nomeação de branches. Depois de criar um repositório (dessa vez, vou deixar ele público), rodei:
```sh
git remote remove origin
git remote add origin git@git.gabeefran.co:gabeefranco/git-trick.git
git push -u origin main
```
De novo, a `main` sobe normalmente:
```
Enumerating objects: 3, done.
Counting objects: 100% (3/3), done.
Writing objects: 100% (3/3), 236 bytes | 236.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
To git.gabeefran.co:gabeefranco/git-trick.git
* [new branch] main -> main
branch 'main' set up to track 'origin/main'.
```
Mas a `main` é a que o marketplace verificou. Vamos testar nossa branch do mal:
```sh
git push -u origin 290b9e171e7b6facfe1e244a22c7152b31b6c290
```
Infelizmente, o Forgejo **não implementa a mesma correção**:
```
Enumerating objects: 5, done.
Counting objects: 100% (5/5), done.
Writing objects: 100% (3/3), 269 bytes | 269.00 KiB/s, done.
Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
remote:
remote: Create a new pull request for '290b9e171e7b6facfe1e244a22c7152b31b6c290':
remote: https://git.gabeefran.co/gabeefranco/git-trick/compare/main...290b9e171e7b6facfe1e244a22c7152b31b6c290
remote:
To git.gabeefran.co:gabeefranco/git-trick.git
* [new branch] 290b9e171e7b6facfe1e244a22c7152b31b6c290 -> 290b9e171e7b6facfe1e244a22c7152b31b6c290
branch '290b9e171e7b6facfe1e244a22c7152b31b6c290' set up to track 'origin/290b9e171e7b6facfe1e244a22c7152b31b6c290'.
```
Vou deixar esse repositório público. Dá pra conferir [aqui](https://git.gabeefran.co/gabeefranco/git-trick). Só note que os hashes de commit lá são diferentes, porque eu tive que editar os deste post pra manter a consistência do texto.
## Como se defender
Antes de tudo: se você usa o Claude Code ou o Codex, atualize. Qualquer versão a partir do **Claude Code 2.1.179** e do **Codex 0.146.0** já tem a correção.
Mas e se você estiver escrevendo uma ferramenta que fixa commits do git, do mesmo jeito que os agentes fazem? Minha primeira ideia foi `git checkout --detach`, já que ele deveria tratar o argumento como um commit, e não como uma branch. Testei no mesmo repo de demonstração:
```sh
git checkout --detach 290b9e171e7b6facfe1e244a22c7152b31b6c290
```
E o `plugin.py` continuou dizendo `you got HACKED`. Nem o `--detach` salva a gente aqui! O que funciona de verdade é adicionar `^{commit}` ao final do hash, o que força o git a resolvê-lo como um objeto commit:
```sh
git checkout --detach "290b9e171e7b6facfe1e244a22c7152b31b6c290^{commit}"
```
Dessa vez, o `safe plugin` voltou. Mesmo assim, não confie cegamente na resolução. **Depois do checkout, sempre verifique se o `HEAD` é exatamente o hash fixado**, e aborte se não for:
```sh
PIN=290b9e171e7b6facfe1e244a22c7152b31b6c290
[ "$(git rev-parse HEAD)" = "$PIN" ] || { echo "HEAD doesn't match the pin, aborting!"; exit 1; }
```
É uma linha de shell, e teria barrado esse ataque. Se os agentes fizessem isso, a branch do mal seria inútil.
E se você hospeda seus próprios projetos git, como eu, dá pra fazer o mesmo que o GitHub e rejeitar nomes de branch e de tag formados por 40 ou 64 caracteres hexadecimais com um hook de pre-receive. Não existe nenhum motivo legítimo pra uma branch parecer um hash de commit, de qualquer forma.
## Concluindo
O problema principal era que os agentes pediam ao git o commit fixado, mas nunca verificavam se o que recebiam de volta batia de fato com aquele hash. Fixar commits pelo SHA é a recomendação padrão contra ataques à cadeia de suprimentos. O Plugin4Shell mostra que **fixar um commit só adianta se você _resolver_ essa fixação do jeito certo**.
Estudando esse caso, aprendemos muito sobre ataques à cadeia de suprimentos, internals do git e a cibersegurança envolvida no cenário dos agentes de IA. Todo o crédito pra [Air Security](https://www.air.security/) por pesquisar esse tema e descobrir essa vulnerabilidade.
Me diverti muito escrevendo este post, espero que tenham gostado!

View file

@ -1,16 +1,33 @@
--- ---
import { existsSync } from 'node:fs';
import { Font } from 'astro:assets';
import '../styles/global.css'; import '../styles/global.css';
import Header from '../components/Header.astro'; import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro'; import Footer from '../components/Footer.astro';
import type { Lang } from '../lib/i18n'; import { getLocalizedPath, type Lang } from '../lib/i18n';
interface Props { interface Props {
title: string; title: string;
description: string; description: string;
lang: Lang; lang: Lang;
/**
* Path under public/ for the OG image, e.g. /social-cards/en/hello-world.png.
* Cards are generated by scripts/generate-social-cards.ts; until one exists
* on disk we fall back to the logo.
*/
image?: string;
/** Keep the page out of search engines (used for draft posts). */
noindex?: boolean;
/**
* This page's path in each language, for pages whose path isn't the same
* across languages (the about page). Defaults to swapping the /<lang> prefix.
*/
alternates?: Partial<Record<Lang, string>>;
} }
const { title, description, lang } = Astro.props; const { title, description, lang, noindex, alternates = {} } = Astro.props;
const alternatePath = (target: Lang) => alternates[target] ?? getLocalizedPath(Astro.url.pathname, target);
const image = Astro.props.image && existsSync(`public${Astro.props.image}`) ? Astro.props.image : '/logo.png';
const canonicalURL = new URL(Astro.url.pathname, Astro.site); const canonicalURL = new URL(Astro.url.pathname, Astro.site);
--- ---
@ -23,26 +40,42 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site);
<link rel="alternate icon" href="/favicon.ico" /> <link rel="alternate icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" /> <link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="canonical" href={canonicalURL} /> <link rel="canonical" href={canonicalURL} />
<link rel="alternate" hreflang="en" href={new URL(Astro.url.pathname.replace(/^\/pt/, '') || '/', Astro.site)} /> <link rel="alternate" hreflang="en" href={new URL(alternatePath('en'), Astro.site)} />
<link rel="alternate" hreflang="pt-BR" href={new URL(`/pt${Astro.url.pathname.replace(/^\/pt/, '')}`, Astro.site)} /> <link rel="alternate" hreflang="pt-BR" href={new URL(alternatePath('pt'), Astro.site)} />
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<link rel="sitemap" href="/sitemap-index.xml" />
<title>{title}</title> <title>{title}</title>
<meta name="description" content={description} /> <meta name="description" content={description} />
{noindex && <meta name="robots" content="noindex" />}
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content="Gabriel Franco's notes" />
<meta property="og:title" content={title} /> <meta property="og:title" content={title} />
<meta property="og:description" content={description} /> <meta property="og:description" content={description} />
<meta property="og:url" content={canonicalURL} /> <meta property="og:url" content={canonicalURL} />
<meta property="og:image" content={new URL('/logo.png', Astro.site)} /> <meta property="og:image" content={new URL(image, Astro.site)} />
{image.startsWith('/social-cards/') && (
<>
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
</>
)}
<meta name="twitter:card" content="summary_large_image" /> <meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content={title} />
<meta name="twitter:description" content={description} />
<meta name="twitter:image" content={new URL(image, Astro.site)} />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <Font cssVariable="--font-iawriter" preload />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Inter:ital,wght@0,400..700;1,400..700&family=JetBrains+Mono:wght@400;500;600&display=swap" <script
rel="stylesheet" is:inline
/> defer
src="https://stats.gabeefran.co/gabest.js"
data-website-id="d8448824-f620-48e0-98ab-c0ea596dc17d"
data-domains="gabeefran.co"
></script>
<script is:inline> <script is:inline>
// Runs before paint to avoid a flash of the wrong theme. // Runs before paint to avoid a flash of the wrong theme.
@ -55,7 +88,7 @@ const canonicalURL = new URL(Astro.url.pathname, Astro.site);
</script> </script>
</head> </head>
<body class="flex min-h-screen flex-col bg-bg font-sans text-text antialiased selection:bg-primary selection:text-white"> <body class="flex min-h-screen flex-col bg-bg font-sans text-text antialiased selection:bg-primary selection:text-white">
<Header lang={lang} /> <Header lang={lang} langSwitchHref={alternates[lang === 'en' ? 'pt' : 'en']} />
<main class="flex-1"> <main class="flex-1">
<slot /> <slot />
</main> </main>

View file

@ -15,7 +15,7 @@ const postsHref = getLocalizedPath('/posts', lang);
const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang); const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang);
--- ---
<article class="mx-10 mt-10 font-mono"> <article class="mx-10 my-10 font-prose">
<a href={postsHref} class="underline inline-flex items-center gap-1.5 text-sm font-medium text-text-muted hover:text-primary"> <a href={postsHref} class="underline inline-flex items-center gap-1.5 text-sm font-medium text-text-muted hover:text-primary">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="size-4">
<path d="M15 18l-6-6 6-6"></path> <path d="M15 18l-6-6 6-6"></path>
@ -35,7 +35,7 @@ const tagsHref = (tag: string) => getLocalizedPath(`/tags/${tag}`, lang);
</> </>
)} )}
</div> </div>
<h1 class="mt-3 text-3xl font-bold tracking-tight text-text sm:text-4xl">{post.data.title}</h1> <h1 class="mt-3 text-3xl leading-11 font-bold tracking-tight text-text sm:text-4xl">{post.data.title}</h1>
<p class="mt-3 text-lg text-text-muted">{post.data.description}</p> <p class="mt-3 text-lg text-text-muted">{post.data.description}</p>
{post.data.tags.length > 0 && ( {post.data.tags.length > 0 && (
<div class="mt-4 flex flex-wrap gap-2"> <div class="mt-4 flex flex-wrap gap-2">

25
src/lib/about.ts Normal file
View file

@ -0,0 +1,25 @@
import { getCollection, type CollectionEntry } from 'astro:content';
import type { Lang } from './i18n';
// The about page lives at src/content/about/<lang>.md; its URL segment comes
// from the `slug` frontmatter field, so each language can have its own
// (/en/about, /pt/sobre).
export type AboutPage = CollectionEntry<'about'>;
export async function getAboutPages(): Promise<AboutPage[]> {
return getCollection('about');
}
export async function getAboutPage(lang: Lang): Promise<AboutPage> {
const page = (await getAboutPages()).find((entry) => entry.data.language === lang);
if (!page) throw new Error(`Missing src/content/about/${lang}.md`);
return page;
}
export function aboutPathOf(page: AboutPage): string {
return `/${page.data.language}/${page.data.slug}`;
}
export async function getAboutPath(lang: Lang): Promise<string> {
return aboutPathOf(await getAboutPage(lang));
}

View file

@ -1,3 +1,6 @@
// Every localized string on the site lives here. Keep this file free of
// runtime imports: scripts/generate-social-cards.ts loads it directly with Node.
export const LANGS = ['en', 'pt'] as const; export const LANGS = ['en', 'pt'] as const;
export type Lang = (typeof LANGS)[number]; export type Lang = (typeof LANGS)[number];
export const DEFAULT_LANG: Lang = 'en'; export const DEFAULT_LANG: Lang = 'en';
@ -16,17 +19,20 @@ export const ui = {
'nav.menu': 'Menu', 'nav.menu': 'Menu',
'lang.switch': 'Switch language', 'lang.switch': 'Switch language',
'theme.switch': 'Toggle color theme', 'theme.switch': 'Toggle color theme',
'hero.kicker': "Gabriel Franco's notes", 'hero.kicker': "Computer Science in Real Life",
'hero.title': 'Building things, and figuring out how they work.', 'hero.title': 'Building things, and figuring out how they work.',
'hero.subtitle': 'hero.subtitle':
"Notes on computer science, systems, and the open source projects that shaped how I think about software. I'm Gabriel Franco — welcome.", "Notes on computer science, systems, and the open source projects that shaped how I think about software. I'm Gabriel Franco — welcome.",
'hero.cta.posts': 'Read the posts', 'hero.cta.posts': 'Read the posts',
'hero.cta.about': 'About me', 'hero.cta.about': 'About me',
'home.description':
'Notes on computer science, systems, and the open source projects that shaped how I think about software.',
'home.recent': 'Recent posts', 'home.recent': 'Recent posts',
'home.recent.subtitle': 'The latest things I’ve written down.', 'home.recent.subtitle': 'The latest things I’ve written down.',
'home.viewAll': 'View all posts', 'home.viewAll': 'View all posts',
'posts.title': 'All posts', 'posts.title': 'All posts',
'posts.subtitle': 'Everything I’ve written, oldest to newest below, newest on top.', 'posts.subtitle': 'Everything I’ve written, oldest to newest below, newest on top.',
'posts.description': 'Everything I’ve written about computer science, systems, and open source, newest first.',
'posts.empty': 'No posts yet. Check back soon.', 'posts.empty': 'No posts yet. Check back soon.',
'posts.filteredBy': 'Posts tagged', 'posts.filteredBy': 'Posts tagged',
'posts.clearFilter': 'Clear filter', 'posts.clearFilter': 'Clear filter',
@ -35,10 +41,15 @@ export const ui = {
'post.readingTime': 'min read', 'post.readingTime': 'min read',
'post.updated': 'Updated', 'post.updated': 'Updated',
'tag.label': 'Tag', 'tag.label': 'Tag',
'tag.description': 'Everything I’ve written tagged #{tag}.',
'about.title': 'About', 'about.title': 'About',
'about.description':
'Gabriel Franco: computer science student at PUCRS, interested in how software actually works and in the history of open source.',
'footer.tagline': 'Written by hand in Porto Alegre.', 'footer.tagline': 'Written by hand in Porto Alegre.',
'footer.rights': 'All rights reserved.', 'footer.rights': 'All rights reserved.',
'footer.source': 'Source', 'footer.source': 'Source',
'feed.title': 'gabeefran.co',
'feed.description': "Gabriel Franco's notes about computer science and other topics",
'404.title': 'Page not found', '404.title': 'Page not found',
'404.body': 'There’s nothing here. Let’s get you back home.', '404.body': 'There’s nothing here. Let’s get you back home.',
'404.cta': 'Back to home', '404.cta': 'Back to home',
@ -51,17 +62,21 @@ export const ui = {
'nav.menu': 'Menu', 'nav.menu': 'Menu',
'lang.switch': 'Mudar idioma', 'lang.switch': 'Mudar idioma',
'theme.switch': 'Alternar tema', 'theme.switch': 'Alternar tema',
'hero.kicker': 'Anotações do Gabriel Franco', 'hero.kicker': 'Ciência da Computação na Vida Real',
'hero.title': 'Construindo coisas e entendendo como elas funcionam.', 'hero.title': 'Construindo coisas e entendendo como elas funcionam.',
'hero.subtitle': 'hero.subtitle':
'Notas sobre ciência da computação, sistemas e os projetos open source que moldaram a forma como penso sobre software. Eu sou o Gabriel Franco — seja bem-vindo.', 'Notas sobre ciência da computação, sistemas e os projetos open source que moldaram a forma como penso sobre software. Eu sou o Gabriel Franco — seja bem-vindo.',
'hero.cta.posts': 'Ler os posts', 'hero.cta.posts': 'Ler os posts',
'hero.cta.about': 'Sobre mim', 'hero.cta.about': 'Sobre mim',
'home.description':
'Notas sobre ciência da computação, sistemas e os projetos open source que moldaram a forma como penso sobre software.',
'home.recent': 'Posts recentes', 'home.recent': 'Posts recentes',
'home.recent.subtitle': 'As últimas coisas que escrevi.', 'home.recent.subtitle': 'As últimas coisas que escrevi.',
'home.viewAll': 'Ver todos os posts', 'home.viewAll': 'Ver todos os posts',
'posts.title': 'Todos os posts', 'posts.title': 'Todos os posts',
'posts.subtitle': 'Tudo que já escrevi, do mais recente para o mais antigo.', 'posts.subtitle': 'Tudo que já escrevi, do mais recente para o mais antigo.',
'posts.description':
'Tudo que já escrevi sobre ciência da computação, sistemas e open source, do mais recente ao mais antigo.',
'posts.empty': 'Ainda não há posts. Volte em breve.', 'posts.empty': 'Ainda não há posts. Volte em breve.',
'posts.filteredBy': 'Posts com a tag', 'posts.filteredBy': 'Posts com a tag',
'posts.clearFilter': 'Limpar filtro', 'posts.clearFilter': 'Limpar filtro',
@ -70,10 +85,15 @@ export const ui = {
'post.readingTime': 'min de leitura', 'post.readingTime': 'min de leitura',
'post.updated': 'Atualizado em', 'post.updated': 'Atualizado em',
'tag.label': 'Tag', 'tag.label': 'Tag',
'tag.description': 'Tudo que já escrevi com a tag #{tag}.',
'about.title': 'Sobre', 'about.title': 'Sobre',
'about.description':
'Gabriel Franco: estudante de ciência da computação na PUCRS, interessado em como o software realmente funciona e na história do open source.',
'footer.tagline': 'Escrito à mão em Porto Alegre.', 'footer.tagline': 'Escrito à mão em Porto Alegre.',
'footer.rights': 'Todos os direitos reservados.', 'footer.rights': 'Todos os direitos reservados.',
'footer.source': 'Código-fonte', 'footer.source': 'Código-fonte',
'feed.title': 'gabeefran.co',
'feed.description': 'Anotações do Gabriel Franco sobre ciência da computação e outros assuntos',
'404.title': 'Página não encontrada', '404.title': 'Página não encontrada',
'404.body': 'Não há nada aqui. Vamos te levar de volta para o início.', '404.body': 'Não há nada aqui. Vamos te levar de volta para o início.',
'404.cta': 'Voltar para o início', '404.cta': 'Voltar para o início',
@ -82,30 +102,51 @@ export const ui = {
export type UiKey = keyof (typeof ui)['en']; export type UiKey = keyof (typeof ui)['en'];
export function getLangFromUrl(url: URL): Lang { export function isLang(value: string | undefined): value is Lang {
const [, first] = url.pathname.split('/'); return LANGS.some((lang) => lang === value);
return first === 'pt' ? 'pt' : DEFAULT_LANG;
} }
/** getStaticPaths() result for pages that only vary by language. */
export function langPaths() {
return LANGS.map((lang) => ({ params: { lang } }));
}
/** `{name}` placeholders in a string are replaced with `vars[name]`. */
export function useTranslations(lang: Lang) { export function useTranslations(lang: Lang) {
return function t(key: UiKey): string { return function t(key: UiKey, vars: Record<string, string> = {}): string {
return ui[lang][key] ?? ui[DEFAULT_LANG][key]; const template: string = ui[lang][key] ?? ui[DEFAULT_LANG][key];
return template.replace(/\{(\w+)\}/g, (placeholder, name: string) => vars[name] ?? placeholder);
}; };
} }
/** Prefix-strip the given pathname down to its language-agnostic form. */ /** Pages with their own title/description (tag pages use tagPageMeta). */
export function stripLangPrefix(pathname: string): string { export type PageKey = 'home' | 'posts' | 'about' | '404';
return pathname.replace(/^\/pt(?=\/|$)/, '') || '/';
export interface PageMeta {
title: string;
description: string;
}
const PAGE_META_KEYS: Record<PageKey, { title: UiKey; description: UiKey }> = {
home: { title: 'hero.title', description: 'home.description' },
posts: { title: 'posts.title', description: 'posts.description' },
about: { title: 'about.title', description: 'about.description' },
'404': { title: '404.title', description: '404.body' },
};
export function pageMeta(lang: Lang, page: PageKey): PageMeta {
const t = useTranslations(lang);
const keys = PAGE_META_KEYS[page];
return { title: t(keys.title), description: t(keys.description) };
}
export function tagPageMeta(lang: Lang, tag: string): PageMeta {
return { title: `#${tag}`, description: useTranslations(lang)('tag.description', { tag }) };
} }
/** Given any pathname, build the equivalent path in the target language. */ /** Given any pathname, build the equivalent path in the target language. */
export function getLocalizedPath(pathname: string, lang: Lang): string { export function getLocalizedPath(pathname: string, lang: Lang): string {
const stripped = stripLangPrefix(pathname); const [, first] = pathname.split('/');
if (lang === DEFAULT_LANG) return stripped; const stripped = (isLang(first) ? pathname.slice(first.length + 1) : pathname) || '/';
return stripped === '/' ? '/pt' : `/pt${stripped}`; return stripped === '/' ? `/${lang}` : `/${lang}${stripped}`;
}
export function localizeHref(path: string, lang: Lang): string {
if (lang === DEFAULT_LANG) return path;
return path === '/' ? '/pt' : `/pt${path}`;
} }

View file

@ -9,12 +9,16 @@ export function langOf(entry: PostEntry): Lang {
} }
export function slugOf(entry: PostEntry): string { export function slugOf(entry: PostEntry): string {
return entry.id.split('/').slice(1).join('/'); return entry.id.replace('pt-', '').replace('en-', '');
} }
export async function getPostsByLang(lang: Lang): Promise<PostEntry[]> { /**
* Drafts are left out by default so they never show up in lists, tags or the
* feed. Pass `includeDrafts` to still build their pages (reachable by URL only).
*/
export async function getPostsByLang(lang: Lang, { includeDrafts = false } = {}): Promise<PostEntry[]> {
const posts = await getCollection('posts', ({ id, data }) => { const posts = await getCollection('posts', ({ id, data }) => {
return id.startsWith(`${lang}/`) && !data.draft; return id.startsWith(`${lang}-`) && (includeDrafts || !data.draft);
}); });
return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); return posts.sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf());
} }

16
src/lib/social-cards.ts Normal file
View file

@ -0,0 +1,16 @@
// Where generated OG cards live, relative to public/. Shared by the pages and
// by scripts/generate-social-cards.ts (so keep this file free of runtime
// imports: the script loads it directly with Node).
import type { Lang, PageKey } from './i18n';
export function postCardPath(lang: Lang, slug: string): string {
return `/social-cards/${lang}/${slug}.png`;
}
export function pageCardPath(lang: Lang, page: PageKey): string {
return `/social-cards/${lang}/pages/${page}.png`;
}
export function tagCardPath(lang: Lang, tag: string): string {
return `/social-cards/${lang}/tags/${tag}.png`;
}

View file

@ -1,18 +1,24 @@
--- ---
import BaseLayout from '../layouts/BaseLayout.astro'; import BaseLayout from '../layouts/BaseLayout.astro';
import { getLocalizedPath, ui, type Lang } from '../lib/i18n'; import { getLocalizedPath, LANGS, pageMeta, ui } from '../lib/i18n';
import { pageCardPath } from '../lib/social-cards';
// This file is prerendered once as a static /404.html and served by the host // This file is prerendered once as a static /404.html and served by the host
// for any unmatched path, so we can't know at build time whether the visitor // for any unmatched path, so we can't know at build time whether the visitor
// was under /pt/*. Render both languages and pick one at runtime with a tiny // was under /pt/*. Render both languages and pick one at runtime with a tiny
// inline script based on the actual requested path. // inline script based on the actual requested path.
const langs: Lang[] = ['en', 'pt']; const page = pageMeta('en', '404');
--- ---
<BaseLayout title={`${ui.en['404.title']} — gabeefranco`} description={ui.en['404.body']} lang="en"> <BaseLayout
title={`${page.title} — ${ui.en['site.name']}`}
description={page.description}
lang="en"
image={pageCardPath('en', '404')}
>
<section class="mx-10 flex flex-col items-start px-4 py-24 sm:px-6 font-mono"> <section class="mx-10 flex flex-col items-start px-4 py-24 sm:px-6 font-mono">
<p class="font-mono text-sm text-primary">404</p> <p class="font-mono text-sm text-primary">404</p>
{langs.map((lang) => ( {LANGS.map((lang) => (
<div data-notfound-lang={lang} class={lang === 'en' ? '' : 'hidden'}> <div data-notfound-lang={lang} class={lang === 'en' ? '' : 'hidden'}>
<h1 class="mt-2 text-3xl font-bold tracking-tight text-text">{ui[lang]['404.title']}</h1> <h1 class="mt-2 text-3xl font-bold tracking-tight text-text">{ui[lang]['404.title']}</h1>
<p class="mt-3 text-text-muted">{ui[lang]['404.body']}</p> <p class="mt-3 text-text-muted">{ui[lang]['404.body']}</p>

View file

@ -0,0 +1,38 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import { aboutPathOf, getAboutPages, type AboutPage } from '../../lib/about';
import { pageMeta, useTranslations, type Lang } from '../../lib/i18n';
import { pageCardPath } from '../../lib/social-cards';
export async function getStaticPaths() {
const pages = await getAboutPages();
// The language switcher and hreflang links need the other language's slug.
const alternates = Object.fromEntries(pages.map((page) => [page.data.language, `${aboutPathOf(page)}/`]));
return pages.map((page) => ({
params: { lang: page.data.language, about: page.data.slug },
props: { page, alternates },
}));
}
interface Props {
page: AboutPage;
alternates: Record<Lang, string>;
}
const { page: entry, alternates } = Astro.props;
const lang = entry.data.language;
const t = useTranslations(lang);
const page = pageMeta(lang, 'about');
const { Content } = await render(entry);
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang={lang} image={pageCardPath(lang, 'about')} alternates={alternates}>
<section class="mx-10 mt-10 font-mono">
<h1 class="text-3xl font-bold tracking-tight text-text sm:text-4xl">{page.title}</h1>
<div class="prose prose-neutral mt-6 max-w-none prose-headings:font-semibold prose-a:text-primary prose-a:no-underline hover:prose-a:underline dark:prose-invert">
<Content />
</div>
</section>
</BaseLayout>

View file

@ -0,0 +1,24 @@
import rss from '@astrojs/rss';
import type { APIContext } from 'astro';
import { getLocalizedPath, langPaths, useTranslations, type Lang } from '../../lib/i18n';
import { getPostsByLang, slugOf } from '../../lib/posts';
export const getStaticPaths = langPaths;
export async function GET(context: APIContext) {
const lang = context.params.lang as Lang;
const t = useTranslations(lang);
const posts = await getPostsByLang(lang);
return rss({
title: t('feed.title'),
description: t('feed.description'),
site: new URL(getLocalizedPath('/', lang), context.site),
customData: `<language>${lang}</language>`,
items: posts.map((post) => ({
title: post.data.title,
pubDate: post.data.pubDate,
description: post.data.description,
link: `${getLocalizedPath(`/posts/${slugOf(post)}`, lang)}/`,
})),
});
}

View file

@ -0,0 +1,20 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import HomeContent from '../../components/HomeContent.astro';
import { langPaths, pageMeta, useTranslations, type Lang } from '../../lib/i18n';
import { pageCardPath } from '../../lib/social-cards';
export const getStaticPaths = langPaths;
const lang = Astro.params.lang as Lang;
const t = useTranslations(lang);
---
<BaseLayout
title={`${t('site.name')} — ${t('hero.kicker')}`}
description={pageMeta(lang, 'home').description}
lang={lang}
image={pageCardPath(lang, 'home')}
>
<HomeContent lang={lang} />
</BaseLayout>

View file

@ -0,0 +1,36 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostLayout from '../../../layouts/PostLayout.astro';
import ObservationCard from '../../../components/ObservationCard';
import { LANGS, useTranslations, type Lang } from '../../../lib/i18n';
import { getPostsByLang, slugOf, estimateReadingMinutes } from '../../../lib/posts';
import { postCardPath } from '../../../lib/social-cards';
export async function getStaticPaths() {
const pathsByLang = await Promise.all(
LANGS.map(async (lang) =>
(await getPostsByLang(lang, { includeDrafts: true })).map((post) => ({ params: { lang, slug: slugOf(post) }, props: { post } })),
),
);
return pathsByLang.flat();
}
const lang = Astro.params.lang as Lang;
const t = useTranslations(lang);
const { post } = Astro.props;
const { Content } = await render(post);
const readingMinutes = estimateReadingMinutes(post.body ?? '');
---
<BaseLayout
title={`${post.data.title} — ${t('site.name')}`}
description={post.data.description}
lang={lang}
image={postCardPath(lang, slugOf(post))}
noindex={post.data.draft}
>
<PostLayout post={post} lang={lang} readingMinutes={readingMinutes}>
<Content components={{ ObservationCard }} />
</PostLayout>
</BaseLayout>

View file

@ -0,0 +1,16 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostsContent from '../../../components/PostsContent.astro';
import { langPaths, pageMeta, useTranslations, type Lang } from '../../../lib/i18n';
import { pageCardPath } from '../../../lib/social-cards';
export const getStaticPaths = langPaths;
const lang = Astro.params.lang as Lang;
const t = useTranslations(lang);
const page = pageMeta(lang, 'posts');
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang={lang} image={pageCardPath(lang, 'posts')}>
<PostsContent lang={lang} />
</BaseLayout>

View file

@ -0,0 +1,23 @@
---
import BaseLayout from '../../../../layouts/BaseLayout.astro';
import PostsContent from '../../../../components/PostsContent.astro';
import { LANGS, tagPageMeta, useTranslations, type Lang } from '../../../../lib/i18n';
import { getAllTags } from '../../../../lib/posts';
import { tagCardPath } from '../../../../lib/social-cards';
export async function getStaticPaths() {
const pathsByLang = await Promise.all(
LANGS.map(async (lang) => (await getAllTags(lang)).map((tag) => ({ params: { lang, tag } }))),
);
return pathsByLang.flat();
}
const lang = Astro.params.lang as Lang;
const tag = Astro.params.tag!;
const t = useTranslations(lang);
const page = tagPageMeta(lang, tag);
---
<BaseLayout title={`${page.title} — ${t('site.name')}`} description={page.description} lang={lang} image={tagCardPath(lang, tag)}>
<PostsContent lang={lang} tag={tag} />
</BaseLayout>

View file

@ -1,41 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { useTranslations } from '../lib/i18n';
const t = useTranslations('en');
const description =
"Gabriel Franco (gabeefranco): computer science student at PUCRS, interested in how software actually works and in the history of the open source movement.";
---
<BaseLayout title={`${t('about.title')} — ${t('site.name')}`} description={description} lang="en">
<section class="mx-10 mt-10 font-mono">
<h1 class="text-3xl font-bold tracking-tight text-text sm:text-4xl">{t('about.title')}</h1>
<div class="prose prose-neutral mt-6 max-w-none prose-headings:font-semibold prose-a:text-primary prose-a:no-underline hover:prose-a:underline dark:prose-invert">
<p>
Hi, I'm Gabe — Gabriel Franco. I'm a computer science student at PUCRS (Pontifícia
Universidade Católica do Rio Grande do Sul), and this blog is where I write down the
things I'm learning and thinking about along the way.
</p>
<p>
I care a lot about computer science as a field, not just as a set of tools to ship
products with. I have a fairly specific way of doing things: I'd rather understand a
system properly than memorize the steps to make it work, and I tend to slow down on
purpose to figure out what's actually happening under the hood — in a compiler, an
operating system, a language runtime, whatever it is. That habit shapes most of what ends
up on this blog.
</p>
<p>
I'm also genuinely interested in the history of the open source movement: the people,
the arguments, and the decisions that shaped the software we all rely on. Figures like
Linus Torvalds and projects like GNU come up often in what I read and write about, not
as trivia, but because understanding where a tool came from usually explains a lot about
how and why it works the way it does today.
</p>
<p>
This site is bilingual, in English and in Portuguese, and every post exists in both
languages. Thanks for stopping by.
</p>
</div>
</section>
</BaseLayout>

View file

@ -1,11 +0,0 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import HomeContent from '../components/HomeContent.astro';
import { useTranslations } from '../lib/i18n';
const t = useTranslations('en');
---
<BaseLayout title={`${t('site.name')} — ${t('hero.kicker')}`} description={t('hero.subtitle')} lang="en">
<HomeContent lang="en" />
</BaseLayout>

78
src/pages/page-card.astro Normal file
View file

@ -0,0 +1,78 @@
---
// OG image template for non-post pages (home, posts, about, tags, 404).
// Same look as social-card.astro, without the author line. Screenshotted by
// scripts/generate-social-cards.ts via Cloudflare Browser Run; not meant to be
// visited directly.
export const prerender = false;
const title = Astro.url.searchParams.get('title') || 'gabeefranco';
const description = Astro.url.searchParams.get('description');
---
<html>
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=JetBrains+Mono:wght@600&display=swap"
rel="stylesheet"
/>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
position: relative;
width: 1200px;
height: 630px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 72px;
padding-bottom: 96px;
font-family: 'Inter', system-ui, sans-serif;
background: #fbf9f6;
color: #201a14;
border-bottom: 16px solid #e8590c;
}
.brand {
position: absolute;
top: 60px;
left: 24px;
display: flex;
align-items: center;
gap: 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 40px;
font-weight: 600;
}
.brand img {
height: 64px;
}
.title {
font-size: 64px;
margin-left: 24px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: 24px;
}
.description {
font-size: 28px;
line-height: 1.35;
color: #6b5f52;
margin-left: 24px;
}
</style>
</head>
<body>
<div class="brand">
<img src="/logo.png" alt="" />
<span>gabeefranco</span>
</div>
<h1 class="title">{title}</h1>
{description && <p class="description">{description}</p>}
</body>
</html>

View file

@ -1,22 +0,0 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostLayout from '../../layouts/PostLayout.astro';
import ObservationCard from '../../components/ObservationCard';
import { getPostsByLang, slugOf, estimateReadingMinutes } from '../../lib/posts';
export async function getStaticPaths() {
const posts = await getPostsByLang('en');
return posts.map((post) => ({ params: { slug: slugOf(post) }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
const readingMinutes = estimateReadingMinutes(post.body ?? '');
---
<BaseLayout title={`${post.data.title} — gabeefranco`} description={post.data.description} lang="en">
<PostLayout post={post} lang="en" readingMinutes={readingMinutes}>
<Content components={{ ObservationCard }} />
</PostLayout>
</BaseLayout>

View file

@ -1,11 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import PostsContent from '../../components/PostsContent.astro';
import { useTranslations } from '../../lib/i18n';
const t = useTranslations('en');
---
<BaseLayout title={`${t('posts.title')} — ${t('site.name')}`} description={t('posts.subtitle')} lang="en">
<PostsContent lang="en" />
</BaseLayout>

View file

@ -1,42 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { useTranslations } from '../../lib/i18n';
const t = useTranslations('pt');
const description =
'Gabriel Franco (gabeefranco): estudante de ciência da computação na PUCRS, interessado em como o software realmente funciona e na história do movimento open source.';
---
<BaseLayout title={`${t('about.title')} — ${t('site.name')}`} description={description} lang="pt">
<section class="mx-10 mt-10 font-mono">
<h1 class="text-3xl font-bold tracking-tight text-text sm:text-4xl">{t('about.title')}</h1>
<div class="prose prose-neutral mt-6 max-w-none prose-headings:font-semibold prose-a:text-primary prose-a:no-underline hover:prose-a:underline dark:prose-invert">
<p>
Olá, eu sou o Gabe — Gabriel Franco. Sou estudante de ciência da computação na PUCRS
(Pontifícia Universidade Católica do Rio Grande do Sul), e este blog é onde registro as
coisas que vou aprendendo e pensando pelo caminho.
</p>
<p>
Eu me importo bastante com ciência da computação como área, não só como um conjunto de
ferramentas para lançar produtos. Tenho um jeito bem específico de fazer as coisas:
prefiro entender um sistema de verdade a decorar os passos para fazê-lo funcionar, e
costumo desacelerar de propósito para descobrir o que realmente está acontecendo por
baixo dos panos — em um compilador, em um sistema operacional, no runtime de uma
linguagem, seja lá o que for. Esse hábito molda a maior parte do que acaba virando post
aqui.
</p>
<p>
Também tenho um interesse genuíno pela história do movimento open source: as pessoas, os
debates e as decisões que moldaram o software do qual todos dependemos. Figuras como
Linus Torvalds e projetos como o GNU aparecem com frequência no que leio e escrevo, não
como curiosidade, mas porque entender de onde uma ferramenta veio costuma explicar
bastante sobre como e por que ela funciona do jeito que funciona hoje.
</p>
<p>
Este site é bilíngue, em inglês e em português, e todo post existe nos dois idiomas.
Obrigado pela visita.
</p>
</div>
</section>
</BaseLayout>

View file

@ -1,11 +0,0 @@
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import HomeContent from '../../components/HomeContent.astro';
import { useTranslations } from '../../lib/i18n';
const t = useTranslations('pt');
---
<BaseLayout title={`${t('site.name')} — ${t('hero.kicker')}`} description={t('hero.subtitle')} lang="pt">
<HomeContent lang="pt" />
</BaseLayout>

View file

@ -1,22 +0,0 @@
---
import { render } from 'astro:content';
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostLayout from '../../../layouts/PostLayout.astro';
import ObservationCard from '../../../components/ObservationCard';
import { getPostsByLang, slugOf, estimateReadingMinutes } from '../../../lib/posts';
export async function getStaticPaths() {
const posts = await getPostsByLang('pt');
return posts.map((post) => ({ params: { slug: slugOf(post) }, props: { post } }));
}
const { post } = Astro.props;
const { Content } = await render(post);
const readingMinutes = estimateReadingMinutes(post.body ?? '');
---
<BaseLayout title={`${post.data.title} — gabeefranco`} description={post.data.description} lang="pt">
<PostLayout post={post} lang="pt" readingMinutes={readingMinutes}>
<Content components={{ ObservationCard }} />
</PostLayout>
</BaseLayout>

View file

@ -1,11 +0,0 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostsContent from '../../../components/PostsContent.astro';
import { useTranslations } from '../../../lib/i18n';
const t = useTranslations('pt');
---
<BaseLayout title={`${t('posts.title')} — ${t('site.name')}`} description={t('posts.subtitle')} lang="pt">
<PostsContent lang="pt" />
</BaseLayout>

View file

@ -1,23 +0,0 @@
---
import BaseLayout from '../../../../layouts/BaseLayout.astro';
import PostsContent from '../../../../components/PostsContent.astro';
import { getAllTags, getPostsByLang } from '../../../../lib/posts';
import { useTranslations } from '../../../../lib/i18n';
export async function getStaticPaths() {
const tags = await getAllTags('pt');
return tags.map((tag) => ({ params: { tag } }));
}
const { tag } = Astro.params;
const t = useTranslations('pt');
const posts = (await getPostsByLang('pt')).filter((p) => p.data.tags.includes(tag!));
---
<BaseLayout
title={`#${tag} — ${t('site.name')}`}
description={`${t('posts.filteredBy')} #${tag}: ${posts.map((p) => p.data.title).join(', ')}`}
lang="pt"
>
<PostsContent lang="pt" tag={tag} />
</BaseLayout>

View file

@ -0,0 +1,85 @@
---
// OG image template. Screenshotted by scripts/generate-social-cards.ts via
// Cloudflare Browser Run; not meant to be visited directly.
export const prerender = false;
const title = Astro.url.searchParams.get('title') || 'Untitled';
const description = Astro.url.searchParams.get('description');
const author = Astro.url.searchParams.get('author');
---
<html>
<head>
<meta charset="utf-8" />
<meta name="robots" content="noindex" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;700&family=JetBrains+Mono:wght@600&display=swap"
rel="stylesheet"
/>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
position: relative;
width: 1200px;
height: 630px;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 72px;
font-family: 'Inter', system-ui, sans-serif;
background: #fbf9f6;
color: #201a14;
border-bottom: 16px solid #e8590c;
}
.brand {
position: absolute;
top: 30px;
left: 24px;
display: flex;
align-items: center;
gap: 16px;
font-family: 'JetBrains Mono', monospace;
font-size: 40px;
font-weight: 600;
}
.brand img {
height: 64px;
}
.title {
font-size: 64px;
margin-left: 24px;
font-weight: 700;
line-height: 1.1;
letter-spacing: -0.02em;
margin-bottom: 24px;
}
.description {
font-size: 28px;
line-height: 1.35;
color: #6b5f52;
margin-left: 24px;
}
.author {
margin-top: 24px;
margin-left: 24px;
margin-bottom: 24px;
font-family: 'JetBrains Mono', monospace;
font-size: 24px;
color: #e8590c;
}
</style>
</head>
<body>
<div class="brand">
<img src="/logo.png" alt="" />
<span>gabeefranco</span>
</div>
<h1 class="title">{title}</h1>
{description && <p class="description">{description}</p>}
{author && <p class="author">{author}</p>}
</body>
</html>

View file

@ -1,23 +0,0 @@
---
import BaseLayout from '../../../layouts/BaseLayout.astro';
import PostsContent from '../../../components/PostsContent.astro';
import { getAllTags, getPostsByLang } from '../../../lib/posts';
import { useTranslations } from '../../../lib/i18n';
export async function getStaticPaths() {
const tags = await getAllTags('en');
return tags.map((tag) => ({ params: { tag } }));
}
const { tag } = Astro.params;
const t = useTranslations('en');
const posts = (await getPostsByLang('en')).filter((p) => p.data.tags.includes(tag!));
---
<BaseLayout
title={`#${tag} — ${t('site.name')}`}
description={`${t('posts.filteredBy')} #${tag}: ${posts.map((p) => p.data.title).join(', ')}`}
lang="en"
>
<PostsContent lang="en" tag={tag} />
</BaseLayout>

View file

@ -6,7 +6,8 @@
@theme inline { @theme inline {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif; --font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace; --font-prose: var(--font-iawriter), ui-monospace, SFMono-Regular, Menlo, monospace;
--font-mono: var(--font-iawriter), ui-monospace, SFMono-Regular, Menlo, monospace;
--color-bg: var(--bg); --color-bg: var(--bg);
--color-surface: var(--surface); --color-surface: var(--surface);