├── .dev.vars.example
├── .vscode
├── extensions.json
├── t:hours.code-snippets
├── t:kwhclient.code-snippets
├── t:response.code-snippets
├── t:cache.code-snippets
├── launch.json
├── t:client.code-snippets
└── t:server.code-snippets
├── tsconfig.json
├── src
├── middleware
│ ├── index.ts
│ ├── user.ts
│ └── co2.ts
├── utils
│ └── index.ts
├── layouts
│ └── Layout.astro
├── env.d.ts
├── pages
│ ├── audio
│ │ └── [key].ts
│ ├── login
│ │ ├── github
│ │ │ ├── index.ts
│ │ │ └── callback.ts
│ │ └── index.astro
│ └── index.astro
├── actions.ts
├── auth.ts
├── icons
│ ├── github.svg
│ └── logo.svg
└── components
│ ├── Form.tsx
│ └── Boards.tsx
├── tailwind.config.mjs
├── wrangler.toml
├── .gitignore
├── .github
└── workflows
│ └── _studio.yml
├── astro.config.ts
├── public
└── favicon.svg
├── package.json
├── db
└── config.ts
└── README.md
/.dev.vars.example:
--------------------------------------------------------------------------------
1 | ASTRO_STUDIO_APP_TOKEN=
2 | GITHUB_ID=
3 | GITHUB_SECRET=
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["astro-build.astro-vscode"],
3 | "unwantedRecommendations": []
4 | }
5 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "astro/tsconfigs/strictest",
3 | "compilerOptions": {
4 | "jsx": "react-jsx",
5 | "jsxImportSource": "react"
6 | }
7 | }
--------------------------------------------------------------------------------
/src/middleware/index.ts:
--------------------------------------------------------------------------------
1 | import { sequence } from "astro:middleware";
2 | import { user } from "./user";
3 | import { co2 } from "./co2";
4 |
5 | export const onRequest = sequence(user, co2);
6 |
--------------------------------------------------------------------------------
/src/utils/index.ts:
--------------------------------------------------------------------------------
1 | import { customAlphabet, urlAlphabet } from "nanoid";
2 | import { twMerge } from "tailwind-merge";
3 |
4 | export const cn = twMerge;
5 |
6 | export const safeId = customAlphabet(urlAlphabet, 10);
7 |
--------------------------------------------------------------------------------
/.vscode/t:hours.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "t:hours": {
3 | "scope": "",
4 | "prefix": "t:hours",
5 | "body": [
6 | " const hours = time / 1000 / 3600;"
7 | ],
8 | "description": ""
9 | }
10 | }
--------------------------------------------------------------------------------
/tailwind.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ['./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}'],
4 | theme: {
5 | extend: {},
6 | },
7 | plugins: [],
8 | }
9 |
--------------------------------------------------------------------------------
/wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "womp"
2 | pages_build_output_dir = "./dist"
3 | compatibility_date = "2024-04-08"
4 | compatibility_flags = ["nodejs_compat"]
5 |
6 | [[r2_buckets]]
7 | binding = 'R2'
8 | bucket_name = 'womp-audio'
9 |
--------------------------------------------------------------------------------
/.vscode/t:kwhclient.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "t:kwhclient": {
3 | "scope": "",
4 | "prefix": "t:kwhclient",
5 | "body": [
6 | " const kWh = (bytes / Math.pow(10, 12)) * userCO2_per_GB;"
7 | ],
8 | "description": ""
9 | }
10 | }
--------------------------------------------------------------------------------
/.vscode/t:response.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "t:response": {
3 | "scope": "",
4 | "prefix": "t:response",
5 | "body": [
6 | " return new Response(\"File too large\", { status: 413 });"
7 | ],
8 | "description": ""
9 | }
10 | }
--------------------------------------------------------------------------------
/.vscode/t:cache.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "t:cache": {
3 | "scope": "",
4 | "prefix": "t:cache",
5 | "body": [
6 | " headers.set(\"Cache-Control\", \"public, max-age=31536000, immutable\");"
7 | ],
8 | "description": ""
9 | }
10 | }
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/.vscode/t:client.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "t:client": {
3 | "scope": "",
4 | "prefix": "t:client",
5 | "body": [
6 | " const kWh = (bytes / Math.pow(10, 12)) * userCO2_per_GB;\n const co2 = kWh * awsCO2_per_kWh;\n return co2;"
7 | ],
8 | "description": ""
9 | }
10 | }
--------------------------------------------------------------------------------
/.vscode/t:server.code-snippets:
--------------------------------------------------------------------------------
1 | {
2 | "t:server": {
3 | "scope": "",
4 | "prefix": "t:server",
5 | "body": [
6 | " const time = performance.now() - start;\n const hours = time / 1000 / 3600;\n const kWh = hours * awskW;\n const co2 = kWh * awsCO2_per_kWh;\n return co2;"
7 | ],
8 | "description": ""
9 | }
10 | }
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # build output
2 | dist/
3 | # generated types
4 | .astro/
5 |
6 | # dependencies
7 | node_modules/
8 |
9 | # logs
10 | npm-debug.log*
11 | yarn-debug.log*
12 | yarn-error.log*
13 | pnpm-debug.log*
14 |
15 |
16 | # environment variables
17 | .env
18 | .env.production
19 |
20 | # macOS-specific files
21 | .DS_Store
22 |
23 | # cloudflare
24 | .wrangler/
25 | worker-configuration.d.ts
26 | .dev.vars
27 | .dev.vars.*
28 | !.dev.vars.example
--------------------------------------------------------------------------------
/src/layouts/Layout.astro:
--------------------------------------------------------------------------------
1 | ---
2 | type Props = {
3 | title?: string;
4 | };
5 |
6 | const { title = "WOMP" } = Astro.props;
7 | ---
8 |
9 |
10 |
11 |
12 |
13 |
14 | {title}
15 |
16 |
17 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/.github/workflows/_studio.yml:
--------------------------------------------------------------------------------
1 | name: Astro Studio
2 |
3 | env:
4 | ASTRO_STUDIO_APP_TOKEN: ${{secrets.ASTRO_STUDIO_APP_TOKEN }}
5 |
6 | on:
7 | push:
8 | branches:
9 | - main
10 | pull_request:
11 | types: [opened, reopened, synchronize]
12 |
13 | jobs:
14 | DB:
15 | permissions:
16 | contents: read
17 | actions: read
18 | pull-requests: write
19 | runs-on: ubuntu-latest
20 | steps:
21 | - uses: actions/checkout@v4
22 | - uses: actions/setup-node@v4
23 | with:
24 | node-version: 20
25 | - uses: jaid/action-npm-install@v1.2.1
26 | - uses: withastro/action-studio@main
27 |
--------------------------------------------------------------------------------
/src/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 | ///
4 | ///
5 | ///
6 | ///
7 |
8 | interface CfEnv extends Env {
9 | GITHUB_ID: string;
10 | GITHUB_SECRET: string;
11 | }
12 |
13 | type Runtime = import("@astrojs/cloudflare").Runtime;
14 |
15 | declare namespace App {
16 | interface Locals extends Runtime {
17 | session: import("lucia").Session | null;
18 | user: import("lucia").User | null;
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/src/pages/audio/[key].ts:
--------------------------------------------------------------------------------
1 | import type { APIRoute } from "astro";
2 |
3 | export const prerender = false;
4 |
5 | export const GET: APIRoute = async (ctx) => {
6 | const { key } = ctx.params;
7 | if (!key) return new Response(null, { status: 400 });
8 |
9 | const { R2 } = ctx.locals.runtime.env;
10 | const obj = await R2.get(key);
11 |
12 | if (obj === null) {
13 | return new Response(`${key} not found`, { status: 404 });
14 | }
15 |
16 | const headers = new Headers(Object.entries(obj.httpMetadata ?? {}));
17 | headers.set("etag", obj.httpEtag);
18 |
19 | headers.set("Cache-Control", "public, max-age=31536000, immutable");
20 |
21 | return new Response(obj.body, { headers });
22 | };
23 |
--------------------------------------------------------------------------------
/src/pages/login/github/index.ts:
--------------------------------------------------------------------------------
1 | // pages/login/github/index.ts
2 | import { generateState } from "arctic";
3 | import { github } from "../../../auth";
4 |
5 | import type { APIContext } from "astro";
6 |
7 | export const prerender = false;
8 |
9 | export async function GET(context: APIContext): Promise {
10 | const state = generateState();
11 | const url = await github(context.locals.runtime.env).createAuthorizationURL(
12 | state
13 | );
14 |
15 | context.cookies.set("github_oauth_state", state, {
16 | path: "/",
17 | secure: true,
18 | httpOnly: true,
19 | maxAge: 60 * 10,
20 | sameSite: "lax",
21 | });
22 |
23 | return context.redirect(url.toString());
24 | }
25 |
--------------------------------------------------------------------------------
/src/actions.ts:
--------------------------------------------------------------------------------
1 | import { defineAction, z } from "astro:actions";
2 | import { CO2, db, eq } from "astro:db";
3 |
4 | type Result = Array<{
5 | client: number;
6 | server: number;
7 | route: string;
8 | referer: string;
9 | }>;
10 |
11 | export const server = {
12 | getCo2: defineAction({
13 | input: z.object({
14 | referer: z.string(),
15 | }),
16 | handler: async ({ referer }): Promise => {
17 | const metrics = await db
18 | .select({
19 | client: CO2.client,
20 | server: CO2.server,
21 | route: CO2.route,
22 | })
23 | .from(CO2)
24 | .where(eq(CO2.referer, referer));
25 | return metrics;
26 | },
27 | }),
28 | };
29 |
--------------------------------------------------------------------------------
/astro.config.ts:
--------------------------------------------------------------------------------
1 | import cloudflare from "@astrojs/cloudflare";
2 | import tailwind from "@astrojs/tailwind";
3 | import simpleStackForm from "simple-stack-form";
4 | import react from "@astrojs/react";
5 | import { defineConfig } from "astro/config";
6 | import db from "@astrojs/db";
7 |
8 | import icon from "astro-icon";
9 |
10 | // https://astro.build/config
11 | export default defineConfig({
12 | output: "hybrid",
13 | adapter: cloudflare({
14 | platformProxy: {
15 | enabled: true,
16 | },
17 | }),
18 | integrations: [tailwind(), simpleStackForm(), react(), db(), icon()],
19 | vite: {
20 | optimizeDeps: {
21 | exclude: ["astro:db", "oslo"],
22 | },
23 | },
24 | experimental: {
25 | actions: true,
26 | },
27 | });
28 |
--------------------------------------------------------------------------------
/public/favicon.svg:
--------------------------------------------------------------------------------
1 |
10 |
--------------------------------------------------------------------------------
/src/pages/login/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import Layout from "../../layouts/Layout.astro";
3 | import { Icon } from "astro-icon/components";
4 | ---
5 |
6 |
7 |
8 |
18 |
19 |
20 |
--------------------------------------------------------------------------------
/src/auth.ts:
--------------------------------------------------------------------------------
1 | // src/auth.ts
2 | import { Lucia } from "lucia";
3 | import { AstroDBAdapter } from "lucia-adapter-astrodb";
4 | import { db, Session, User } from "astro:db";
5 | import { GitHub } from "arctic";
6 |
7 | export const github = (env: { GITHUB_ID: string; GITHUB_SECRET: string }) =>
8 | new GitHub(env.GITHUB_ID, env.GITHUB_SECRET);
9 |
10 | const adapter = new AstroDBAdapter(db, Session, User);
11 |
12 | export const lucia = new Lucia(adapter, {
13 | sessionCookie: {
14 | attributes: {
15 | // set to `true` when using HTTPS
16 | secure: import.meta.env.PROD,
17 | },
18 | },
19 | getUserAttributes: (attributes) => {
20 | return {
21 | // attributes has the type of DatabaseUserAttributes
22 | githubId: attributes.github_id,
23 | username: attributes.username,
24 | };
25 | },
26 | });
27 |
28 | declare module "lucia" {
29 | interface Register {
30 | Lucia: typeof lucia;
31 | DatabaseUserAttributes: DatabaseUserAttributes;
32 | }
33 | }
34 |
35 | interface DatabaseUserAttributes {
36 | github_id: number;
37 | username: string;
38 | }
39 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "womp",
3 | "type": "module",
4 | "version": "0.0.1",
5 | "scripts": {
6 | "dev": "wrangler types && astro dev",
7 | "start": "wrangler types && astro dev",
8 | "build": "wrangler types && astro check && astro build --remote",
9 | "preview": "wrangler types && astro preview",
10 | "astro": "astro"
11 | },
12 | "dependencies": {
13 | "@astrojs/check": "^0.7.0",
14 | "@astrojs/cloudflare": "^10.4.0",
15 | "@astrojs/db": "^0.11.5",
16 | "@astrojs/react": "^3.5.0",
17 | "@astrojs/tailwind": "^5.1.0",
18 | "@emoji-mart/data": "^1.1.2",
19 | "@emoji-mart/react": "^1.1.1",
20 | "@types/react": "^18.2.45",
21 | "@types/react-dom": "^18.2.18",
22 | "arctic": "^1.5.0",
23 | "astro": "^4.10.2",
24 | "astro-icon": "^1.1.0",
25 | "emoji-mart": "^5.5.2",
26 | "lucia": "^3.1.1",
27 | "lucia-adapter-astrodb": "^0.0.7",
28 | "nanoid": "^5.0.4",
29 | "oslo": "^1.2.0",
30 | "react": "^18.2.0",
31 | "react-aria-components": "^1.0.0",
32 | "react-dom": "^18.2.0",
33 | "simple-stack-form": "^0.1.5",
34 | "tailwind-merge": "^2.2.0",
35 | "tailwindcss": "^3.4.0",
36 | "typescript": "^5.3.3",
37 | "wrangler": "^3.47.1",
38 | "zod": "^3.22.4"
39 | },
40 | "devDependencies": {
41 | "@cloudflare/workers-types": "^4.20240404.0"
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/middleware/user.ts:
--------------------------------------------------------------------------------
1 | import { lucia } from "../auth";
2 | import { verifyRequestOrigin } from "lucia";
3 | import { defineMiddleware } from "astro:middleware";
4 |
5 | export const user = defineMiddleware(async (context, next) => {
6 | if (context.request.method !== "GET") {
7 | const originHeader = context.request.headers.get("Origin");
8 | const hostHeader = context.request.headers.get("Host");
9 | if (
10 | !originHeader ||
11 | !hostHeader ||
12 | !verifyRequestOrigin(originHeader, [hostHeader])
13 | ) {
14 | return new Response(null, {
15 | status: 403,
16 | });
17 | }
18 | }
19 |
20 | const sessionId = context.cookies.get(lucia.sessionCookieName)?.value ?? null;
21 | if (!sessionId) {
22 | context.locals.user = null;
23 | context.locals.session = null;
24 | return next();
25 | }
26 |
27 | const { session, user } = await lucia.validateSession(sessionId);
28 | if (session?.fresh) {
29 | const sessionCookie = lucia.createSessionCookie(session.id);
30 | context.cookies.set(
31 | sessionCookie.name,
32 | sessionCookie.value,
33 | sessionCookie.attributes
34 | );
35 | }
36 | if (!session) {
37 | const sessionCookie = lucia.createBlankSessionCookie();
38 | context.cookies.set(
39 | sessionCookie.name,
40 | sessionCookie.value,
41 | sessionCookie.attributes
42 | );
43 | }
44 | context.locals.session = session;
45 | context.locals.user = user;
46 | return next();
47 | });
48 |
--------------------------------------------------------------------------------
/db/config.ts:
--------------------------------------------------------------------------------
1 | import { column, defineDb, defineTable } from "astro:db";
2 |
3 | const User = defineTable({
4 | columns: {
5 | id: column.text({
6 | primaryKey: true,
7 | }),
8 | github_id: column.text({ unique: true }),
9 | username: column.text(),
10 | },
11 | });
12 |
13 | const Session = defineTable({
14 | columns: {
15 | id: column.text({
16 | primaryKey: true,
17 | }),
18 | expiresAt: column.date(),
19 | userId: column.text({
20 | references: () => User.columns.id,
21 | }),
22 | },
23 | });
24 |
25 | const Sound = defineTable({
26 | columns: {
27 | id: column.number({ primaryKey: true }),
28 | boardId: column.number({
29 | references: () => Board.columns.id,
30 | }),
31 | emojiId: column.text(),
32 | emojiSkin: column.number({
33 | optional: true,
34 | }),
35 | name: column.text({
36 | optional: true,
37 | }),
38 | audioFileKey: column.text({
39 | unique: true,
40 | }),
41 | audioFileName: column.text(),
42 | },
43 | });
44 |
45 | const Board = defineTable({
46 | columns: {
47 | id: column.number({ primaryKey: true }),
48 | userId: column.text({
49 | references: () => User.columns.id,
50 | }),
51 | name: column.text(),
52 | },
53 | });
54 |
55 | const CO2 = defineTable({
56 | columns: {
57 | route: column.text({ primaryKey: true }),
58 | referer: column.text(),
59 | server: column.number(),
60 | client: column.number(),
61 | },
62 | });
63 |
64 | // https://astro.build/db/config
65 | export default defineDb({
66 | tables: { Sound, Board, User, Session, CO2 },
67 | });
68 |
--------------------------------------------------------------------------------
/src/icons/github.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/icons/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Astro Starter Kit: Minimal
2 |
3 | ```sh
4 | npm create astro@latest -- --template minimal
5 | ```
6 |
7 | [](https://stackblitz.com/github/withastro/astro/tree/latest/examples/minimal)
8 | [](https://codesandbox.io/p/sandbox/github/withastro/astro/tree/latest/examples/minimal)
9 | [](https://codespaces.new/withastro/astro?devcontainer_path=.devcontainer/minimal/devcontainer.json)
10 |
11 | > 🧑🚀 **Seasoned astronaut?** Delete this file. Have fun!
12 |
13 | ## 🚀 Project Structure
14 |
15 | Inside of your Astro project, you'll see the following folders and files:
16 |
17 | ```text
18 | /
19 | ├── public/
20 | ├── src/
21 | │ └── pages/
22 | │ └── index.astro
23 | └── package.json
24 | ```
25 |
26 | Astro looks for `.astro` or `.md` files in the `src/pages/` directory. Each page is exposed as a route based on its file name.
27 |
28 | There's nothing special about `src/components/`, but that's where we like to put any Astro/React/Vue/Svelte/Preact components.
29 |
30 | Any static assets, like images, can be placed in the `public/` directory.
31 |
32 | ## 🧞 Commands
33 |
34 | All commands are run from the root of the project, from a terminal:
35 |
36 | | Command | Action |
37 | | :------------------------ | :----------------------------------------------- |
38 | | `npm install` | Installs dependencies |
39 | | `npm run dev` | Starts local dev server at `localhost:4321` |
40 | | `npm run build` | Build your production site to `./dist/` |
41 | | `npm run preview` | Preview your build locally, before deploying |
42 | | `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
43 | | `npm run astro -- --help` | Get help using the Astro CLI |
44 |
45 | ## 👀 Want to learn more?
46 |
47 | Feel free to check [our documentation](https://docs.astro.build) or jump into our [Discord server](https://astro.build/chat).
48 |
--------------------------------------------------------------------------------
/src/pages/login/github/callback.ts:
--------------------------------------------------------------------------------
1 | // pages/login/github/callback.ts
2 | import { github, lucia } from "../../../auth";
3 | import { OAuth2RequestError } from "arctic";
4 | import { generateId } from "lucia";
5 | import { db, User, eq, Board } from "astro:db";
6 | import type { APIContext } from "astro";
7 |
8 | export const prerender = false;
9 |
10 | export async function GET(context: APIContext): Promise {
11 | const code = context.url.searchParams.get("code");
12 | const state = context.url.searchParams.get("state");
13 | const storedState = context.cookies.get("github_oauth_state")?.value ?? null;
14 | if (!code || !state || !storedState || state !== storedState) {
15 | return new Response(null, {
16 | status: 400,
17 | });
18 | }
19 |
20 | try {
21 | const tokens = await github(
22 | context.locals.runtime.env
23 | ).validateAuthorizationCode(code);
24 | const githubUserResponse = await fetch("https://api.github.com/user", {
25 | headers: {
26 | Authorization: `Bearer ${tokens.accessToken}`,
27 | "User-Agent": import.meta.env.PROD
28 | ? "astro-womp-prod"
29 | : "astro-womp-dev",
30 | },
31 | });
32 | const githubUser: GitHubUser = await githubUserResponse.json();
33 |
34 | // Replace this with your own DB client.
35 | const existingUser = await db
36 | .select()
37 | .from(User)
38 | .where(eq(User.github_id, githubUser.id))
39 | .get();
40 |
41 | if (existingUser) {
42 | const session = await lucia.createSession(existingUser.id, {});
43 | const sessionCookie = lucia.createSessionCookie(session.id);
44 | context.cookies.set(
45 | sessionCookie.name,
46 | sessionCookie.value,
47 | sessionCookie.attributes
48 | );
49 | return context.redirect("/");
50 | }
51 |
52 | const userId = generateId(15);
53 |
54 | await db.insert(User).values({
55 | id: userId,
56 | github_id: githubUser.id,
57 | username: githubUser.login,
58 | });
59 |
60 | await db.insert(Board).values({
61 | name: githubUser.login,
62 | userId,
63 | });
64 |
65 | const session = await lucia.createSession(userId, {});
66 | const sessionCookie = lucia.createSessionCookie(session.id);
67 | context.cookies.set(
68 | sessionCookie.name,
69 | sessionCookie.value,
70 | sessionCookie.attributes
71 | );
72 | return context.redirect("/");
73 | } catch (e) {
74 | console.error("Auth error:", e);
75 | // the specific error message depends on the provider
76 | if (e instanceof OAuth2RequestError) {
77 | // invalid code
78 | return new Response(null, {
79 | status: 400,
80 | });
81 | }
82 | return new Response(null, {
83 | status: 500,
84 | });
85 | }
86 | }
87 |
88 | interface GitHubUser {
89 | id: string;
90 | login: string;
91 | }
92 |
--------------------------------------------------------------------------------
/src/middleware/co2.ts:
--------------------------------------------------------------------------------
1 | import { CO2, db, eq } from "astro:db";
2 | import { defineMiddleware } from "astro:middleware";
3 |
4 | // Source: memory-intensive load for c5 processor
5 | // https://medium.com/teads-engineering/estimating-aws-ec2-instances-power-consumption-c9745e347959
6 | const awsTotalMachineWatts = 174;
7 | // Fudge factor since we're not using the full machine
8 | const awsUtilization = 0.1;
9 | const awskW = (awsTotalMachineWatts * awsUtilization) / 1000;
10 | // Source: cloud carbon footprint
11 | // https://github.com/cloud-carbon-footprint/cloud-carbon-footprint/blob/e48c659f6dafc8b783e570053024f28b88aafc79/microsite/docs/Methodology.md#aws-2
12 | const awsCO2g_per_kWh = 0.000415755 * Math.pow(10, 6);
13 |
14 | // Source: global averages per Sustainable Web Design model
15 | // https://sustainablewebdesign.org/estimating-digital-emissions/#faq
16 | const userOperationalkWh_per_GB =
17 | (421 * Math.pow(10, 9)) / (5.29 * Math.pow(10, 12));
18 | const userEmbodiedkWh_per_GB =
19 | (430 * Math.pow(10, 9)) / (5.29 * Math.pow(10, 12));
20 | const userkWh_per_GB = userOperationalkWh_per_GB + userEmbodiedkWh_per_GB;
21 | const userCO2g_per_kWh = 494;
22 |
23 | export const co2 = defineMiddleware(async (context, next) => {
24 | if (context.url.pathname.endsWith("_actions/getCo2")) return next();
25 |
26 | const referer = context.request.headers.get("Referer");
27 | // If no referer, we don't have a key to track for co2 analytics
28 | if (!referer) return next();
29 |
30 | const start = performance.now();
31 | const response = await next();
32 |
33 | if (context.url.href === referer) {
34 | await db.delete(CO2).where(eq(CO2.referer, referer));
35 | }
36 | if (!response.body) {
37 | await db
38 | .insert(CO2)
39 | .values({
40 | route: context.url.pathname,
41 | referer,
42 | client: 0,
43 | server: getServerCO2(start),
44 | })
45 | .onConflictDoUpdate({
46 | target: CO2.route,
47 | set: {
48 | referer,
49 | client: 0,
50 | server: getServerCO2(start),
51 | },
52 | });
53 | return response;
54 | }
55 |
56 | async function* render() {
57 | let clientBytes = 0;
58 | for await (const chunk of response.body as ReadableStream) {
59 | clientBytes += chunk.byteLength;
60 | yield chunk;
61 | }
62 | await db
63 | .insert(CO2)
64 | .values({
65 | route: context.url.pathname,
66 | referer,
67 | client: getClientCO2(clientBytes),
68 | server: getServerCO2(start),
69 | })
70 | .onConflictDoUpdate({
71 | target: CO2.route,
72 | set: {
73 | referer,
74 | client: getClientCO2(clientBytes),
75 | server: getServerCO2(start),
76 | },
77 | });
78 | }
79 |
80 | // @ts-expect-error generator not assignable to ReadableStream
81 | return new Response(render(), { headers: response.headers });
82 | });
83 |
84 | function getServerCO2(start: number) {
85 | const time = performance.now() - start;
86 | const hours = time / 1000 / 3600;
87 | const kWh = hours * awskW;
88 | const co2 = kWh * awsCO2g_per_kWh;
89 | return co2;
90 | }
91 |
92 | function getClientCO2(bytes: number) {
93 | const kWh = (bytes / Math.pow(10, 12)) * userkWh_per_GB;
94 | const co2 = kWh * userCO2g_per_kWh;
95 | return co2;
96 | }
97 |
--------------------------------------------------------------------------------
/src/pages/index.astro:
--------------------------------------------------------------------------------
1 | ---
2 | import {
3 | newSound,
4 | editEmoji,
5 | editFile,
6 | NewSoundDropZone,
7 | EditCard,
8 | } from "../components/Boards";
9 | import { ViewTransitions } from "astro:transitions";
10 | import { db, eq, Sound } from "astro:db";
11 | import { safeId } from "../utils";
12 | import untypedData, { type EmojiMartData } from "@emoji-mart/data";
13 | import { Board } from "astro:db";
14 |
15 | export const prerender = false;
16 |
17 | const user = Astro.locals.user;
18 | if (!user) {
19 | return Astro.redirect("/login");
20 | }
21 |
22 | let board = await db
23 | .select({ id: Board.id })
24 | .from(Board)
25 | .where(eq(Board.userId, user.id))
26 | .get();
27 |
28 | if (!board) {
29 | board = await db
30 | .insert(Board)
31 | .values({
32 | name: user.username,
33 | userId: user.id,
34 | })
35 | .returning({ id: Board.id })
36 | .get();
37 | }
38 |
39 | const data = untypedData as EmojiMartData;
40 |
41 | const { R2 } = Astro.locals.runtime.env;
42 | const newSoundReq = await Astro.locals.form.getDataByName(
43 | "new-sound",
44 | newSound
45 | );
46 |
47 | const editEmojiReq = await Astro.locals.form.getDataByName(
48 | "edit-emoji",
49 | editEmoji
50 | );
51 |
52 | const editFileReq = await Astro.locals.form.getDataByName(
53 | "edit-file",
54 | editFile
55 | );
56 |
57 | if (editEmojiReq?.data) {
58 | const { id, emojiId, emojiSkin = null } = editEmojiReq.data;
59 | const entry = await db
60 | .select({ key: Sound.audioFileKey })
61 | .from(Sound)
62 | .where(eq(Sound.id, id))
63 | .get();
64 | if (!entry) {
65 | Astro.response.status = 404;
66 | Astro.response.statusText = `Sound ${id} not found`;
67 | } else {
68 | await db
69 | .update(Sound)
70 | .set({
71 | emojiId,
72 | emojiSkin,
73 | })
74 | .where(eq(Sound.id, id));
75 | }
76 | }
77 |
78 | if (editFileReq?.data) {
79 | const { id, audioFile } = editFileReq.data;
80 | const entry = await db
81 | .select({ key: Sound.audioFileKey })
82 | .from(Sound)
83 | .where(eq(Sound.id, id))
84 | .get();
85 | if (!entry) {
86 | Astro.response.status = 404;
87 | Astro.response.statusText = `Sound ${id} not found`;
88 | } else {
89 | const { key } = entry;
90 | const buffer = await audioFile.arrayBuffer();
91 | const kb500 = 500 * 1024;
92 | if (buffer.byteLength > kb500) {
93 | return new Response("File too large", { status: 413 });
94 | }
95 | await R2.put(key, buffer);
96 | await db
97 | .update(Sound)
98 | .set({
99 | audioFileName: audioFile.name,
100 | })
101 | .where(eq(Sound.id, id));
102 | }
103 | }
104 |
105 | if (newSoundReq?.data) {
106 | const { audioFiles } = newSoundReq.data;
107 |
108 | for (const audioFile of audioFiles) {
109 | const key = `${safeId()}-${audioFile.name}`;
110 |
111 | const buffer = await audioFile.arrayBuffer();
112 | const kb500 = 500 * 1024;
113 | if (buffer.byteLength > kb500) {
114 | return new Response("File too large", { status: 413 });
115 | }
116 | await R2.put(key, buffer);
117 |
118 | const emojis = Object.values(data.emojis);
119 | const randomIdx = Math.floor(Math.random() * emojis.length);
120 |
121 | await db.insert(Sound).values({
122 | boardId: board.id,
123 | emojiId: emojis[randomIdx]!.id,
124 | audioFileName: audioFile.name,
125 | audioFileKey: key,
126 | });
127 | }
128 | }
129 |
130 | const sounds = await db
131 | .select({
132 | id: Sound.id,
133 | emojiId: Sound.emojiId,
134 | emojiSkin: Sound.emojiSkin,
135 | audioFileName: Sound.audioFileName,
136 | audioFileKey: Sound.audioFileKey,
137 | })
138 | .from(Sound);
139 | ---
140 |
141 |
142 |
143 |
144 |
145 |
146 |
147 |
148 | WOMP
149 |
150 |
151 |
152 | CO2 usage
153 |
154 |
155 |
156 |
157 |
158 |
161 | {
162 | sounds.map((s) => (
163 |
171 | ))
172 | }
173 |
174 |
177 |
178 |
179 |
210 |
211 |
212 |
--------------------------------------------------------------------------------
/src/components/Form.tsx:
--------------------------------------------------------------------------------
1 | // Generated by simple:form
2 |
3 | import { navigate } from "astro:transitions/client";
4 | import {
5 | type ComponentProps,
6 | createContext,
7 | useContext,
8 | useState,
9 | useRef,
10 | type FormEvent,
11 | } from "react";
12 | import {
13 | DropZone,
14 | FileTrigger,
15 | type DropZoneProps,
16 | type FileDropItem,
17 | type FileTriggerProps,
18 | } from "react-aria-components";
19 | import {
20 | type FieldErrors,
21 | type FormState,
22 | type FormValidator,
23 | getInitialFormState,
24 | toSetValidationErrors,
25 | toTrackAstroSubmitStatus,
26 | toValidateField,
27 | validateForm,
28 | formNameInputProps,
29 | } from "simple:form";
30 |
31 | export function useCreateFormContext(
32 | validator: FormValidator,
33 | fieldErrors?: FieldErrors,
34 | formRef?: React.MutableRefObject
35 | ) {
36 | const initial = getInitialFormState({ validator, fieldErrors });
37 | const [formState, setFormState] = useState(initial);
38 | return {
39 | value: formState,
40 | set: setFormState,
41 | formRef,
42 | validator,
43 | setValidationErrors: toSetValidationErrors(setFormState),
44 | validateField: toValidateField(setFormState),
45 | trackAstroSubmitStatus: toTrackAstroSubmitStatus(setFormState),
46 | };
47 | }
48 |
49 | export function useFormContext() {
50 | const formContext = useContext(FormContext);
51 | if (!formContext) {
52 | throw new Error(
53 | "Form context not found. `useFormContext()` should only be called from children of a