├── web ├── .prettierrc.json ├── env.d.ts ├── public │ └── favicon.ico ├── e2e │ ├── tsconfig.json │ └── vue.spec.ts ├── src │ ├── components │ │ ├── nav │ │ │ ├── index.ts │ │ │ ├── NavBar.vue │ │ │ └── NavLink.vue │ │ ├── Avatar.vue │ │ ├── UserProfileImage.vue │ │ ├── CodePreview.vue │ │ └── DropdownMenu.vue │ ├── assets │ │ ├── main.css │ │ ├── logo-black.svg │ │ └── logo-white.svg │ ├── stores │ │ └── counter.ts │ ├── main.ts │ ├── views │ │ ├── HomeView.vue │ │ ├── posts │ │ │ ├── MyPostsView.vue │ │ │ └── NewPostView.vue │ │ └── ProfileView.vue │ ├── router │ │ └── index.ts │ ├── App.vue │ ├── utils │ │ └── index.ts │ └── api │ │ └── index.ts ├── postcss.config.js ├── tsconfig.vitest.json ├── tsconfig.config.json ├── tsconfig.json ├── tsconfig.app.json ├── .eslintrc.cjs ├── vite.config.ts ├── .gitignore ├── index.html ├── package.json ├── README.md ├── playwright.config.ts └── tailwind.config.js ├── api ├── profile │ ├── .gitignore │ ├── Cargo.toml │ ├── src │ │ ├── config.rs │ │ ├── auth.rs │ │ ├── model.rs │ │ └── lib.rs │ └── Cargo.lock └── post │ ├── go.mod │ ├── config.go │ ├── go.sum │ ├── middleware.go │ ├── utils.go │ ├── main.go │ ├── model.go │ └── data.go ├── .gitignore ├── modules └── spin_static_fs.wasm ├── scripts ├── create-profile.json ├── update-profile.json ├── db │ ├── initdb.d │ │ ├── 1_create_table_profiles.sql │ │ └── 2_create_table_posts.sql │ └── up.sh └── validate.sh ├── .vscode ├── settings.json └── extensions.json ├── README.md ├── spin.toml └── LICENSE /web/.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /api/profile/.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | -------------------------------------------------------------------------------- /web/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | scripts/db/data/ 2 | .spin/ 3 | api/post/main.wasm 4 | -------------------------------------------------------------------------------- /web/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fermyon/code-things/HEAD/web/public/favicon.ico -------------------------------------------------------------------------------- /modules/spin_static_fs.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fermyon/code-things/HEAD/modules/spin_static_fs.wasm -------------------------------------------------------------------------------- /web/e2e/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["./**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /web/src/components/nav/index.ts: -------------------------------------------------------------------------------- 1 | export { default as NavBar } from "./NavBar.vue"; 2 | export { default as NavLink } from "./NavLink.vue"; 3 | -------------------------------------------------------------------------------- /scripts/create-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "A4916AC2-AB0D-45A9-ADEA-959F8DEB2A14", 3 | "handle": "justin", 4 | "avatar": "https://avatars.githubusercontent.com/u/3060890?v=4" 5 | } -------------------------------------------------------------------------------- /scripts/update-profile.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "A4916AC2-AB0D-45A9-ADEA-959F8DEB2A14", 3 | "handle": "justin", 4 | "avatar": "https://avatars.githubusercontent.com/u/3060890" 5 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[rust]": { 3 | "editor.defaultFormatter": "rust-lang.rust-analyzer" 4 | }, 5 | "rust-analyzer.linkedProjects": [ 6 | "api/profile/Cargo.toml" 7 | ], 8 | } -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | require("postcss-import"), 4 | require("tailwindcss/nesting"), 5 | require("tailwindcss"), 6 | require("autoprefixer"), 7 | ], 8 | }; 9 | -------------------------------------------------------------------------------- /scripts/db/initdb.d/1_create_table_profiles.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE profiles ( 2 | id varchar(64) NOT NULL, 3 | handle varchar(32) NOT NULL, 4 | avatar varchar(256), 5 | UNIQUE(handle), 6 | PRIMARY KEY (id) 7 | ); -------------------------------------------------------------------------------- /web/tsconfig.vitest.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "exclude": [], 4 | "compilerOptions": { 5 | "composite": true, 6 | "lib": [], 7 | "types": ["node", "jsdom"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /web/tsconfig.config.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.node.json", 3 | "include": ["vite.config.*", "vitest.config.*", "cypress.config.*", "playwright.config.*"], 4 | "compilerOptions": { 5 | "composite": true, 6 | "types": ["node"] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /web/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.config.json" 6 | }, 7 | { 8 | "path": "./tsconfig.app.json" 9 | }, 10 | { 11 | "path": "./tsconfig.vitest.json" 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "bradlc.vscode-tailwindcss", 4 | "esbenp.prettier-vscode", 5 | "rust-lang.rust-analyzer", 6 | "serayuzgur.crates", 7 | "vue.vscode-typescript-vue-plugin", 8 | "vue.volar" 9 | ] 10 | } -------------------------------------------------------------------------------- /web/src/assets/main.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @layer base { 3 | html { 4 | font-family: Sen, Europa, Avenir, system, -apple-system, ".SFNSText-Regular", San Francisco, Segoe UI, Helvetica Neue, Lucida Grande, sans-serif; 5 | } 6 | } 7 | 8 | @tailwind components; 9 | @tailwind utilities; 10 | -------------------------------------------------------------------------------- /web/e2e/vue.spec.ts: -------------------------------------------------------------------------------- 1 | import { test, expect } from "@playwright/test"; 2 | 3 | // See here how to get started: 4 | // https://playwright.dev/docs/intro 5 | test("visits the app root url", async ({ page }) => { 6 | await page.goto("/"); 7 | await expect(page.locator("div.greetings > h1")).toHaveText("You did it!"); 8 | }); 9 | -------------------------------------------------------------------------------- /web/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@vue/tsconfig/tsconfig.web.json", 3 | "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], 4 | "exclude": ["src/**/__tests__/*"], 5 | "compilerOptions": { 6 | "composite": true, 7 | "baseUrl": ".", 8 | "paths": { 9 | "@/*": ["./src/*"] 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /scripts/db/initdb.d/2_create_table_posts.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE posts ( 2 | id serial4 NOT NULL, 3 | author_id varchar(64) NOT NULL, 4 | "content" text NOT NULL, 5 | "type" varchar(64) NOT NULL, 6 | "data" text NOT NULL, 7 | visibility varchar(32) NOT NULL DEFAULT 'public', 8 | PRIMARY KEY (id), 9 | FOREIGN KEY (author_id) REFERENCES profiles(id) 10 | ); -------------------------------------------------------------------------------- /web/src/stores/counter.ts: -------------------------------------------------------------------------------- 1 | import { ref, computed } from "vue"; 2 | import { defineStore } from "pinia"; 3 | 4 | export const useCounterStore = defineStore("counter", () => { 5 | const count = ref(0); 6 | const doubleCount = computed(() => count.value * 2); 7 | function increment() { 8 | count.value++; 9 | } 10 | 11 | return { count, doubleCount, increment }; 12 | }); 13 | -------------------------------------------------------------------------------- /api/post/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/fermyon/code-things/api/post 2 | 3 | go 1.20 4 | 5 | replace github.com/fermyon/spin/sdk/go v1.0.0 => github.com/jpflueger/spin/sdk/go v0.6.1-0.20230405131322-423d9b11be46 6 | 7 | require ( 8 | github.com/MicahParks/keyfunc v1.9.0 9 | github.com/fermyon/spin/sdk/go v1.0.0 10 | github.com/go-chi/chi/v5 v5.0.8 11 | github.com/golang-jwt/jwt/v4 v4.5.0 12 | ) 13 | -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | /* eslint-env node */ 2 | require("@rushstack/eslint-patch/modern-module-resolution"); 3 | 4 | module.exports = { 5 | root: true, 6 | extends: [ 7 | "plugin:vue/vue3-essential", 8 | "eslint:recommended", 9 | "@vue/eslint-config-typescript", 10 | "@vue/eslint-config-prettier", 11 | ], 12 | parserOptions: { 13 | ecmaVersion: "latest", 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /web/src/components/Avatar.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /web/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath, URL } from "node:url"; 2 | 3 | import { defineConfig } from "vite"; 4 | import vue from "@vitejs/plugin-vue"; 5 | import vueJsx from "@vitejs/plugin-vue-jsx"; 6 | 7 | // https://vitejs.dev/config/ 8 | export default defineConfig({ 9 | plugins: [vue(), vueJsx()], 10 | resolve: { 11 | alias: { 12 | "@": fileURLToPath(new URL("./src", import.meta.url)), 13 | }, 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | .DS_Store 12 | dist 13 | dist-ssr 14 | coverage 15 | *.local 16 | 17 | /cypress/videos/ 18 | /cypress/screenshots/ 19 | 20 | # Editor directories and files 21 | .vscode/* 22 | !.vscode/extensions.json 23 | .idea 24 | *.suo 25 | *.ntvs* 26 | *.njsproj 27 | *.sln 28 | *.sw? 29 | 30 | test-results/ 31 | playwright-report/ 32 | -------------------------------------------------------------------------------- /web/src/components/UserProfileImage.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Code Things 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /scripts/db/up.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | name=code-things-pg 4 | image=postgres 5 | username=code-things 6 | password=password 7 | dbname=code_things 8 | port=5432 9 | 10 | [[ $(docker ps -f "name=$name" --format '{{.Names}}') == $name ]] || docker run -d \ 11 | --name "$name" \ 12 | -p $port:$port \ 13 | -e POSTGRES_USER=$username \ 14 | -e POSTGRES_PASSWORD=$password \ 15 | -e POSTGRES_DB=$dbname \ 16 | -v "$(pwd)/scripts/db/data:/var/lib/postgresql/data" \ 17 | -v "$(pwd)/scripts/db/initdb.d:/docker-entrypoint-initdb.d" \ 18 | $image 19 | -------------------------------------------------------------------------------- /api/profile/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "profile-api" 3 | authors = ["Justin Pflueger "] 4 | description = "Profile REST API" 5 | version = "0.1.0" 6 | edition = "2021" 7 | 8 | [lib] 9 | crate-type = [ "cdylib" ] 10 | 11 | [dependencies] 12 | anyhow = "1" 13 | bytes = "1" 14 | http = "0.2" 15 | spin-sdk = { git = "https://github.com/fermyon/spin", tag = "v1.0.0" } 16 | wit-bindgen-rust = { git = "https://github.com/bytecodealliance/wit-bindgen", rev = "cb871cfa1ee460b51eb1d144b175b9aab9c50aba" } 17 | serde = { version = "1.0", features = ["derive"] } 18 | serde_json = "1.0" 19 | jwt-simple = "0.11.3" 20 | base64 = "0.21.0" 21 | 22 | [workspace] 23 | -------------------------------------------------------------------------------- /web/src/components/nav/NavBar.vue: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /web/src/components/nav/NavLink.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 29 | -------------------------------------------------------------------------------- /api/post/config.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/fermyon/spin/sdk/go/config" 7 | ) 8 | 9 | // Config Helpers 10 | 11 | func configGetRequired(key string) string { 12 | if val, err := config.Get(key); err != nil { 13 | panic(fmt.Sprintf("Missing required config item 'jwks_uri': %v", err)) 14 | } else { 15 | return val 16 | } 17 | } 18 | 19 | func getIssuer() string { 20 | domain := configGetRequired("auth_domain") 21 | return fmt.Sprintf("https://%v/", domain) 22 | } 23 | 24 | func getAudience() string { 25 | return configGetRequired("auth_audience") 26 | } 27 | 28 | func getJwksUri() string { 29 | domain := configGetRequired("auth_domain") 30 | return fmt.Sprintf("https://%v/.well-known/jwks.json", domain) 31 | } 32 | 33 | func getDbUrl() string { 34 | return configGetRequired("db_url") 35 | } 36 | -------------------------------------------------------------------------------- /api/post/go.sum: -------------------------------------------------------------------------------- 1 | github.com/MicahParks/keyfunc v1.9.0 h1:lhKd5xrFHLNOWrDc4Tyb/Q1AJ4LCzQ48GVJyVIID3+o= 2 | github.com/MicahParks/keyfunc v1.9.0/go.mod h1:IdnCilugA0O/99dW+/MkvlyrsX8+L8+x95xuVNtM5jw= 3 | github.com/go-chi/chi/v5 v5.0.8 h1:lD+NLqFcAi1ovnVZpsnObHGW4xb4J8lNmoYVfECH1Y0= 4 | github.com/go-chi/chi/v5 v5.0.8/go.mod h1:DslCQbL2OYiznFReuXYUmQ2hGd1aDpCnlMNITLSKoi8= 5 | github.com/golang-jwt/jwt/v4 v4.4.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 6 | github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= 7 | github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= 8 | github.com/jpflueger/spin/sdk/go v0.6.1-0.20230405131322-423d9b11be46 h1:pcspeUjy3L8kvh6s1obO7KYI9I3uYwSVar7EeHayydE= 9 | github.com/jpflueger/spin/sdk/go v0.6.1-0.20230405131322-423d9b11be46/go.mod h1:ARV2oVtnUCykLM+xCBZq8MQrCZddzb3JbeBettYv1S0= 10 | -------------------------------------------------------------------------------- /web/src/main.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import { createPinia } from "pinia"; 3 | import { createAuth0 } from "@auth0/auth0-vue"; 4 | import piniaPluginPersistedState from 'pinia-plugin-persistedstate'; 5 | 6 | import "@/assets/main.css"; 7 | 8 | import App from "./App.vue"; 9 | import router from "./router"; 10 | 11 | const app = createApp(App); 12 | 13 | // setup application state 14 | const pinia = createPinia(); 15 | pinia.use(piniaPluginPersistedState) 16 | app.use(pinia); 17 | 18 | // router must come after store 19 | app.use(router); 20 | 21 | const auth0 = createAuth0({ 22 | domain: import.meta.env.VITE_AUTH0_DOMAIN, 23 | clientId: import.meta.env.VITE_AUTH0_CLIENT_ID, 24 | authorizationParams: { 25 | audience: import.meta.env.VITE_AUTH0_AUDIENCE, 26 | redirect_uri: window.location.origin, 27 | }, 28 | cacheLocation: "localstorage", 29 | }); 30 | app.use(auth0); 31 | 32 | app.mount("#app"); 33 | -------------------------------------------------------------------------------- /web/src/views/HomeView.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | -------------------------------------------------------------------------------- /scripts/validate.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | scheme="http" 4 | host="127.0.0.1:3000" 5 | 6 | echo "Creating the profile" 7 | curl -v -X POST $scheme://$host/api/profile \ 8 | -H 'Content-Type: application/json' \ 9 | -d @scripts/create-profile.json 10 | echo "------------------------------------------------------------"; echo 11 | 12 | echo "Fetching the profile" 13 | curl -v -X GET $scheme://$host/api/profile/justin 14 | echo "------------------------------------------------------------"; echo 15 | 16 | echo "Updating the avatar" 17 | curl -v -X PUT $scheme://$host/api/profile/justin \ 18 | -H 'Content-Type: application/json' \ 19 | -d @scripts/update-profile.json 20 | echo "------------------------------------------------------------"; echo 21 | 22 | echo "Deleting profile" 23 | curl -v -X DELETE $scheme://$host/api/profile/justin 24 | echo "------------------------------------------------------------"; echo 25 | 26 | echo "Fetching after delete should be 404" 27 | curl -v -X GET $scheme://$host/api/profile/justin 28 | echo "------------------------------------------------------------"; echo 29 | -------------------------------------------------------------------------------- /web/src/router/index.ts: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory } from "vue-router"; 2 | import HomeView from "../views/HomeView.vue"; 3 | 4 | const router = createRouter({ 5 | history: createWebHistory(import.meta.env.BASE_URL), 6 | routes: [ 7 | { 8 | path: "/", 9 | name: "home", 10 | component: HomeView, 11 | }, 12 | { 13 | path: "/profile", 14 | name: "profile", 15 | // route level code-splitting 16 | // this generates a separate chunk (About.[hash].js) for this route 17 | // which is lazy-loaded when the route is visited. 18 | component: () => import("../views/ProfileView.vue"), 19 | }, 20 | { 21 | path: "/posts/new", 22 | name: "posts-new", 23 | // route level code-splitting 24 | // this generates a separate chunk (About.[hash].js) for this route 25 | // which is lazy-loaded when the route is visited. 26 | component: () => import("../views/posts/NewPostView.vue"), 27 | }, 28 | { 29 | path: "/posts", 30 | name: "my-posts", 31 | // route level code-splitting 32 | // this generates a separate chunk (About.[hash].js) for this route 33 | // which is lazy-loaded when the route is visited. 34 | component: () => import("../views/posts/MyPostsView.vue"), 35 | }, 36 | ], 37 | }); 38 | 39 | export default router; 40 | -------------------------------------------------------------------------------- /web/src/App.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 58 | -------------------------------------------------------------------------------- /web/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | // regular expression to validate permalink with 2 | const permalinkRegex = 3 | /https:\/\/github\.com\/[a-zA-Z0-9-_\.]+\/[a-zA-Z0-9-_\.]+\/blob\/[a-z0-9]{40}(\/[a-zA-Z0-9-_\.]+)+#L[0-9]+-L[0-9]+/; 4 | 5 | // function to get the permalink 6 | export const getPermalinkPreview = async ( 7 | permalink: string 8 | ): Promise => { 9 | try { 10 | // test the input returning null if not valid 11 | if (!permalinkRegex.test(permalink)) { 12 | return null; 13 | } 14 | 15 | // parse the permalink 16 | const permalinkUrl = new URL(permalink); 17 | 18 | // get the range start/end from the hash 19 | const [rangeStart, rangeEnd] = permalinkUrl.hash 20 | .slice(1) // remove the '#' 21 | .split("-") // separate start/end 22 | .map((v) => parseInt(v.slice(1))); // remove the 'L' from start/end & parse as int 23 | permalinkUrl.hash = ""; 24 | 25 | // change the host from github.com to raw.githubusercontent.com 26 | permalinkUrl.host = "raw.githubusercontent.com"; 27 | 28 | // remove the /blob segment from the url 29 | permalinkUrl.pathname = permalinkUrl.pathname 30 | .split("/") 31 | .filter((part) => part != "blob") 32 | .join("/"); 33 | 34 | const response = await fetch(permalinkUrl); 35 | const contents = await response.text(); 36 | const contentRange = contents 37 | .split(/\r\n|\n|\r/) 38 | .slice(rangeStart - 1, rangeEnd) 39 | .join("\n"); 40 | return contentRange; 41 | } catch (e: any) { 42 | //TODO: better error handling 43 | console.error("Failed to fetch the code preview", e); 44 | return null; 45 | } 46 | }; 47 | -------------------------------------------------------------------------------- /api/profile/src/config.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashSet; 2 | 3 | use jwt_simple::prelude::Duration; 4 | use spin_sdk::config::get; 5 | 6 | const KEY_DB_URL: &str = "db_url"; 7 | const KEY_AUTH_DOMAIN: &str = "auth_domain"; 8 | const KEY_AUTH_AUDIENCE: &str = "auth_audience"; 9 | const KEY_AUTH_MAX_VALIDITY_SECS: &str = "auth_max_validity_secs"; 10 | 11 | #[derive(Debug)] 12 | pub(crate) struct Config { 13 | pub db_url: String, 14 | pub auth_audiences: HashSet, 15 | pub auth_issuers: HashSet, 16 | pub auth_max_validity: Option, 17 | pub jwks_url: String, 18 | } 19 | 20 | impl Default for Config { 21 | fn default() -> Self { 22 | let db_url = get(KEY_DB_URL).expect("Missing config item 'db_url'"); 23 | let auth_domain = get(KEY_AUTH_DOMAIN).expect("Missing config item 'auth_domain'"); 24 | let auth_max_validity: Option = get(KEY_AUTH_MAX_VALIDITY_SECS) 25 | .ok() 26 | .map(|s| { 27 | s.parse::() 28 | .expect("Value provided must parse into an integer") 29 | }) 30 | .map(Duration::from_secs); 31 | 32 | let auth_audiences = HashSet::from([ 33 | get(KEY_AUTH_AUDIENCE).expect("Missing configuration item 'auth_audience'") 34 | ]); 35 | let auth_issuers = HashSet::from([format!("https://{0}/", auth_domain)]); 36 | let jwks_url = format!("https://{0}/.well-known/jwks.json", auth_domain); 37 | 38 | Self { 39 | db_url, 40 | auth_audiences, 41 | auth_issuers, 42 | auth_max_validity, 43 | jwks_url, 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "code-things-web", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "vite --open --host 127.0.0.1 --port 3000", 7 | "build": "run-p type-check build-only", 8 | "preview": "vite preview", 9 | "test:unit": "vitest --environment jsdom --root src/", 10 | "test:e2e": "playwright test", 11 | "build-only": "vite build", 12 | "type-check": "vue-tsc --noEmit -p tsconfig.vitest.json --composite false", 13 | "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore" 14 | }, 15 | "dependencies": { 16 | "@auth0/auth0-vue": "^2.0.1", 17 | "@headlessui/vue": "^1.7.7", 18 | "@heroicons/vue": "^2.0.13", 19 | "pinia": "^2.0.28", 20 | "pinia-plugin-persistedstate": "^3.0.2", 21 | "vue": "^3.2.45", 22 | "vue-router": "^4.1.6" 23 | }, 24 | "devDependencies": { 25 | "@playwright/test": "^1.28.1", 26 | "@rushstack/eslint-patch": "^1.1.4", 27 | "@tailwindcss/forms": "^0.5.3", 28 | "@types/jsdom": "^20.0.1", 29 | "@types/node": "^18.11.12", 30 | "@vitejs/plugin-vue": "^4.0.0", 31 | "@vitejs/plugin-vue-jsx": "^3.0.0", 32 | "@vue/eslint-config-prettier": "^7.0.0", 33 | "@vue/eslint-config-typescript": "^11.0.0", 34 | "@vue/test-utils": "^2.2.6", 35 | "@vue/tsconfig": "^0.1.3", 36 | "autoprefixer": "^10.4.13", 37 | "eslint": "^8.22.0", 38 | "eslint-plugin-vue": "^9.3.0", 39 | "jsdom": "^20.0.3", 40 | "npm-run-all": "^4.1.5", 41 | "postcss": "^8.4.21", 42 | "prettier": "^2.7.1", 43 | "prettier-plugin-tailwindcss": "^0.2.2", 44 | "tailwindcss": "^3.2.4", 45 | "typescript": "~4.7.4", 46 | "vite": "^4.0.0", 47 | "vitest": "^0.25.6", 48 | "vue-tsc": "^1.0.12" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /web/src/components/CodePreview.vue: -------------------------------------------------------------------------------- 1 | 43 | 44 | 63 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Code Things 2 | This social media application built using the Spin SDK is intended to be an in-depth reference or guide for Spin developers. It focuses on more common real-world use cases like CRUD APIs, Token auth, etc. 3 | 4 | ## SDK Requirements 5 | The following SDKs are required to build this application. 6 | - [Spin SDK](https://developer.fermyon.com/spin/install) 7 | - [rustup](https://rustup.rs) 8 | - [Node.js](https://nodejs.org) 9 | 10 | ## External Resources 11 | The following resources are required to run this application. For local development, these resources can be started via docker. 12 | - [PostreSQL](https://www.postgresql.org) 13 | 14 | ## Auth0 Setup 15 | 16 | To complete this setup for Fermyon Cloud, you must have run `spin deploy` at least once to capture the app's URL. For example, the application's URL in the following example is `https://code-things-xxx.fermyon.app`: 17 | ``` 18 | % spin deploy 19 | Uploading code-things version 0.1.0+rcf68d278... 20 | Deploying... 21 | Waiting for application to become ready.......... ready 22 | Available Routes: 23 | web: https://code-things-xxx.fermyon.app (wildcard) 24 | profile-api: https://code-things-xxx.fermyon.app/api/profile (wildcard) 25 | ``` 26 | 27 | 1. Sign up for Auth0 account (free) 28 | 2. Create a "Single Page Web" application 29 | a. Configure callback URLs: `http://127.0.0.1:3000, https://code-things-xxx.fermyon.app` 30 | b. Configure logout URLs: `http://127.0.0.1:3000, https://code-things-xxx.fermyon.app` 31 | c. Allowed web origins: `http://127.0.0.1:3000, https://code-things-xxx.fermyon.app` 32 | d. Add GitHub Connection 33 | 3. Create API 34 | a. Name: 'Code Things API' 35 | b. Identifier: `https://code-things-xxx.fermyon.app/` 36 | c. Signing Algorithm: `RS256` 37 | 4. Add the Auth0 configuration to Vue.js: 38 | a. Create a file at `./web/.env.local` (this is gitignored) 39 | b. Add domain: `VITE_AUTH0_DOMAIN = "dev-xxx.us.auth0.com"` 40 | c. Add client id: `VITE_AUTH0_CLIENT_ID = "xxx"` 41 | c. Add audience: `VITE_AUTH0_AUDIENCE = "https://code-things.fermyon.app/api"` 42 | -------------------------------------------------------------------------------- /spin.toml: -------------------------------------------------------------------------------- 1 | spin_version = "1" 2 | authors = ["Justin Pflueger "] 3 | description = "Social media app for code snippets" 4 | name = "code-things" 5 | trigger = { type = "http", base = "/" } 6 | version = "0.1.0" 7 | 8 | [variables] 9 | db_url = { default = "host=127.0.0.1 user=code-things password=password dbname=code_things" } 10 | auth_domain = { default = "dev-czhnnl8ikcojc040.us.auth0.com" } 11 | auth_audience = { default = "https://code-things.fermyon.app/api" } 12 | auth_max_validity_secs = { default = "86400" } 13 | 14 | [[component]] 15 | id = "web" 16 | source = "modules/spin_static_fs.wasm" 17 | environment = { FALLBACK_PATH = "index.html" } 18 | [[component.files]] 19 | source = "web/dist" 20 | destination = "/" 21 | [component.trigger] 22 | route = "/..." 23 | [component.build] 24 | command = "npm run build" 25 | workdir = "web" 26 | 27 | [[component]] 28 | id = "profile-api" 29 | source = "api/profile/target/wasm32-wasi/release/profile_api.wasm" 30 | allowed_http_hosts = [ "dev-czhnnl8ikcojc040.us.auth0.com", "code-things.us.auth0.com" ] 31 | key_value_stores = ["default"] 32 | [component.trigger] 33 | route = "/api/profile/..." 34 | [component.build] 35 | command = "cargo build --target wasm32-wasi --release" 36 | workdir = "api/profile" 37 | watch = ["api/profile/src/**/*.rs", "api/profile/Cargo.toml", "spin.toml"] 38 | [component.config] 39 | db_url = "{{ db_url }}" 40 | auth_domain = "{{ auth_domain }}" 41 | auth_audience = "{{ auth_audience }}" 42 | auth_max_validity_secs = "{{ auth_max_validity_secs }}" 43 | 44 | [[component]] 45 | id = "post" 46 | source = "api/post/main.wasm" 47 | allowed_http_hosts = [ "dev-czhnnl8ikcojc040.us.auth0.com", "code-things.us.auth0.com" ] 48 | key_value_stores = ["default"] 49 | [component.trigger] 50 | route = "/api/post/..." 51 | [component.build] 52 | command = "tinygo build -target=wasi -gc=leaking -no-debug -o main.wasm ." 53 | workdir = "./api/post" 54 | watch = ["api/post/*.go", "api/post/go.mod", "spin.toml"] 55 | [component.config] 56 | db_url = "{{ db_url }}" 57 | auth_domain = "{{ auth_domain }}" 58 | auth_audience = "{{ auth_audience }}" 59 | auth_max_validity_secs = "{{ auth_max_validity_secs }}" -------------------------------------------------------------------------------- /web/src/components/DropdownMenu.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 67 | -------------------------------------------------------------------------------- /web/src/api/index.ts: -------------------------------------------------------------------------------- 1 | export interface Profile { 2 | id?: string; 3 | handle: string; 4 | avatar: string; 5 | } 6 | 7 | export const profile = { 8 | get: (token: string, id: string) => 9 | fetch(`/api/profile/${id}`, { 10 | headers: { 11 | Authorization: `Bearer ${token}`, 12 | }, 13 | }), 14 | create: (token: string, profile: Profile) => 15 | fetch("/api/profile", { 16 | method: "POST", 17 | headers: { 18 | Authorization: `Bearer ${token}`, 19 | }, 20 | body: JSON.stringify(profile), 21 | }), 22 | update: (token: string, profile: Profile) => 23 | fetch(`/api/profile`, { 24 | method: "PUT", 25 | headers: { 26 | Authorization: `Bearer ${token}`, 27 | }, 28 | body: JSON.stringify(profile), 29 | }), 30 | delete: (token: string, id: string) => 31 | fetch(`/api/profile/${id}`, { 32 | method: "DELETE", 33 | headers: { 34 | Authorization: `Bearer ${token}`, 35 | }, 36 | }), 37 | }; 38 | 39 | export interface Post { 40 | id?: number; 41 | author_id: string; 42 | content: string; 43 | type: string; 44 | data: string; 45 | visibility: string; 46 | } 47 | 48 | export const posts = { 49 | get: (token: string, id: string) => 50 | fetch(`/api/posts/${id}`, { 51 | headers: { 52 | Authorization: `Bearer ${token}`, 53 | }, 54 | }), 55 | list: (token: string, limit: number, offset: number) => 56 | fetch(`/api/posts?limit=${limit}&offset=${offset}`, { 57 | headers: { 58 | Authorization: `Bearer ${token}`, 59 | }, 60 | }), 61 | create: (token: string, post: Post) => 62 | fetch("/api/posts", { 63 | method: "POST", 64 | headers: { 65 | Authorization: `Bearer ${token}`, 66 | }, 67 | body: JSON.stringify(post), 68 | }), 69 | update: (token: string, post: Post) => 70 | fetch("/api/posts", { 71 | method: "PUT", 72 | headers: { 73 | Authorization: `Bearer ${token}`, 74 | }, 75 | body: JSON.stringify(post), 76 | }), 77 | delete: (token: string, id: string) => 78 | fetch(`/api/posts/${id}`, { 79 | method: "DELETE", 80 | headers: { 81 | Authorization: `Bearer ${token}`, 82 | }, 83 | }), 84 | }; 85 | -------------------------------------------------------------------------------- /web/README.md: -------------------------------------------------------------------------------- 1 | # web 2 | 3 | This template should help get you started developing with Vue 3 in Vite. 4 | 5 | ## Recommended IDE Setup 6 | 7 | [VSCode](https://code.visualstudio.com/) + [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur) + [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin). 8 | 9 | ## Type Support for `.vue` Imports in TS 10 | 11 | TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [TypeScript Vue Plugin (Volar)](https://marketplace.visualstudio.com/items?itemName=Vue.vscode-typescript-vue-plugin) to make the TypeScript language service aware of `.vue` types. 12 | 13 | If the standalone TypeScript plugin doesn't feel fast enough to you, Volar has also implemented a [Take Over Mode](https://github.com/johnsoncodehk/volar/discussions/471#discussioncomment-1361669) that is more performant. You can enable it by the following steps: 14 | 15 | 1. Disable the built-in TypeScript Extension 16 | 1) Run `Extensions: Show Built-in Extensions` from VSCode's command palette 17 | 2) Find `TypeScript and JavaScript Language Features`, right click and select `Disable (Workspace)` 18 | 2. Reload the VSCode window by running `Developer: Reload Window` from the command palette. 19 | 20 | ## Customize configuration 21 | 22 | See [Vite Configuration Reference](https://vitejs.dev/config/). 23 | 24 | ## Project Setup 25 | 26 | ```sh 27 | npm install 28 | ``` 29 | 30 | ### Compile and Hot-Reload for Development 31 | 32 | ```sh 33 | npm run dev 34 | ``` 35 | 36 | ### Type-Check, Compile and Minify for Production 37 | 38 | ```sh 39 | npm run build 40 | ``` 41 | 42 | ### Run Unit Tests with [Vitest](https://vitest.dev/) 43 | 44 | ```sh 45 | npm run test:unit 46 | ``` 47 | 48 | ### Run End-to-End Tests with [Playwright](https://playwright.dev) 49 | 50 | ```sh 51 | # Install browsers for the first run 52 | npx playwright install 53 | 54 | # When testing on CI, must build the project first 55 | npm run build 56 | 57 | # Runs the end-to-end tests 58 | npm run test:e2e 59 | # Runs the tests only on Chromium 60 | npm run test:e2e -- --project=chromium 61 | # Runs the tests of a specific file 62 | npm run test:e2e -- tests/example.spec.ts 63 | # Runs the tests in debug mode 64 | npm run test:e2e -- --debug 65 | ``` 66 | 67 | ### Lint with [ESLint](https://eslint.org/) 68 | 69 | ```sh 70 | npm run lint 71 | ``` 72 | -------------------------------------------------------------------------------- /api/profile/src/auth.rs: -------------------------------------------------------------------------------- 1 | use anyhow::{bail, Result}; 2 | use base64::{alphabet, engine, Engine as _}; 3 | use jwt_simple::prelude::*; 4 | use serde::{Deserialize, Serialize}; 5 | use spin_sdk::outbound_http; 6 | 7 | // base64 decoding should support URL safe with no padding and allow trailing bits for JWT tokens 8 | const BASE64_CONFIG: engine::GeneralPurposeConfig = engine::GeneralPurposeConfig::new() 9 | .with_decode_allow_trailing_bits(true) 10 | .with_decode_padding_mode(engine::DecodePaddingMode::RequireNone); 11 | const BASE64_ENGINE: engine::GeneralPurpose = 12 | engine::GeneralPurpose::new(&alphabet::URL_SAFE, BASE64_CONFIG); 13 | 14 | #[derive(Serialize, Deserialize, Debug)] 15 | pub(crate) struct JsonWebKey { 16 | #[serde(rename = "alg")] 17 | algorithm: String, 18 | #[serde(rename = "kty")] 19 | key_type: String, 20 | #[serde(rename = "use")] 21 | public_key_use: String, 22 | #[serde(rename = "n")] 23 | modulus: String, 24 | #[serde(rename = "e")] 25 | exponent: String, 26 | #[serde(rename = "kid")] 27 | identifier: String, 28 | #[serde(rename = "x5t")] 29 | thumbprint: String, 30 | #[serde(rename = "x5c")] 31 | chain: Vec, 32 | } 33 | 34 | impl JsonWebKey { 35 | //TODO: cache the public key after it's been computed 36 | pub fn to_rsa256_public_key(self) -> Result { 37 | let n = BASE64_ENGINE.decode(self.modulus)?; 38 | let e = BASE64_ENGINE.decode(self.exponent)?; 39 | Ok(RS256PublicKey::from_components(&n, &e)?.with_key_id(self.identifier.as_str())) 40 | } 41 | } 42 | 43 | #[derive(Serialize, Deserialize, Debug)] 44 | pub(crate) struct JsonWebKeySet { 45 | keys: Vec, 46 | } 47 | 48 | impl JsonWebKeySet { 49 | pub fn get(url: String) -> Result { 50 | let res = outbound_http::send_request( 51 | http::Request::builder().method("GET").uri(url).body(None)?, 52 | )?; 53 | let res_body = match res.body().as_ref() { 54 | Some(bytes) => bytes.slice(..), 55 | None => bytes::Bytes::default(), 56 | }; 57 | Ok(serde_json::from_slice::(&res_body)?) 58 | } 59 | 60 | pub fn verify( 61 | self, 62 | token: &str, 63 | options: Option, 64 | ) -> Result> { 65 | for key in self.keys { 66 | let key = key.to_rsa256_public_key()?; 67 | 68 | // add a required key id verification to options 69 | let options = options.clone().map(|o| VerificationOptions { 70 | // ensure the token is validated by this key specifically 71 | required_key_id: key.key_id().to_owned(), 72 | ..o 73 | }); 74 | 75 | let claims = key.verify_token::(token, options); 76 | if claims.is_ok() { 77 | return claims; 78 | } 79 | } 80 | bail!("No key in the set was able to verify the token.") 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /api/post/middleware.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "net/http" 8 | "strconv" 9 | 10 | "github.com/MicahParks/keyfunc" 11 | "github.com/go-chi/chi/v5" 12 | "github.com/golang-jwt/jwt/v4" 13 | "github.com/golang-jwt/jwt/v4/request" 14 | ) 15 | 16 | // Authorization Middleware 17 | 18 | type claimsCtxKey struct{} 19 | 20 | func TokenVerification(next http.Handler) http.Handler { 21 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 22 | // ensure RS256 was used to sign the token 23 | parserOpts := []request.ParseFromRequestOption{ 24 | request.WithParser(jwt.NewParser(jwt.WithValidMethods([]string{ 25 | jwt.SigningMethodRS256.Alg(), 26 | }))), 27 | } 28 | token, err := request.ParseFromRequest(req, request.OAuth2Extractor, fetchAuthSigningKey, parserOpts...) 29 | if err != nil { 30 | if errors.Is(err, jwt.ValidationError{}) { 31 | // token parsed but was invalid 32 | renderUnauthorized(res, err) 33 | } else { 34 | // unable to parse or verify signing 35 | renderErrorResponse(res, err) 36 | } 37 | return 38 | } 39 | 40 | claims := token.Claims.(jwt.MapClaims) 41 | if !token.Valid { 42 | renderUnauthorized(res, fmt.Errorf("token not valid")) 43 | return 44 | } 45 | 46 | if !claims.VerifyIssuer(getIssuer(), true) { 47 | renderUnauthorized(res, jwt.ErrTokenInvalidIssuer) 48 | return 49 | } 50 | 51 | if !claims.VerifyAudience(getAudience(), true) { 52 | renderUnauthorized(res, jwt.ErrTokenInvalidAudience) 53 | return 54 | } 55 | 56 | ctx := context.WithValue(req.Context(), claimsCtxKey{}, claims) 57 | next.ServeHTTP(res, req.WithContext(ctx)) 58 | }) 59 | } 60 | 61 | func fetchAuthSigningKey(t *jwt.Token) (interface{}, error) { 62 | jwksUri := getJwksUri() 63 | if jwks, err := keyfunc.Get(jwksUri, keyfunc.Options{ 64 | Client: NewHttpClient(), 65 | }); err != nil { 66 | return nil, err 67 | } else { 68 | return jwks.Keyfunc(t) 69 | } 70 | } 71 | 72 | // Post Model Middleware 73 | 74 | var postIdCtxKey string = "id" 75 | 76 | type postCtxKey struct{} 77 | 78 | func PostCtx(next http.Handler) http.Handler { 79 | return http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { 80 | var post Post 81 | var err error 82 | 83 | if req.ContentLength > 0 && req.Header.Get("Content-Type") == "application/json" { 84 | if post, err = DecodePost(req.Body); err != nil { 85 | // parsing failed end the request here 86 | msg := fmt.Sprintf("Failed to parse the post from request body: %v\n", err) 87 | renderBadRequestResponse(res, msg) 88 | return 89 | } 90 | if err = post.Validate(); err != nil { 91 | msg := fmt.Sprintf("Request body failed validation: %v\n", err) 92 | renderBadRequestResponse(res, msg) 93 | return 94 | } 95 | } 96 | 97 | ctx := context.WithValue(req.Context(), postCtxKey{}, post) 98 | next.ServeHTTP(res, req.WithContext(ctx)) 99 | }) 100 | } 101 | 102 | func getPostId(r *http.Request) (int, error) { 103 | idStr := chi.URLParam(r, postIdCtxKey) 104 | return strconv.Atoi(idStr) 105 | } 106 | -------------------------------------------------------------------------------- /api/post/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strconv" 8 | "time" 9 | 10 | spinhttp "github.com/fermyon/spin/sdk/go/http" 11 | "github.com/golang-jwt/jwt/v4" 12 | ) 13 | 14 | // HTTP Response Helpers 15 | 16 | func renderBadRequestResponse(res http.ResponseWriter, msg string) { 17 | http.Error(res, msg, http.StatusBadRequest) 18 | } 19 | 20 | func renderErrorResponse(res http.ResponseWriter, err error) { 21 | //TODO: does this work if the response has already been partially written to? 22 | http.Error(res, err.Error(), http.StatusInternalServerError) 23 | } 24 | 25 | func renderForbiddenResponse(res http.ResponseWriter) { 26 | // intentionally make this one obscure in case of malicious intent 27 | http.Error(res, "Forbidden: You do not have permissions to perform this action", http.StatusForbidden) 28 | } 29 | 30 | func renderNotFound(res http.ResponseWriter) { 31 | http.Error(res, "Not found", http.StatusNotFound) 32 | } 33 | 34 | func renderUnauthorized(res http.ResponseWriter, err error) { 35 | http.Error(res, err.Error(), http.StatusUnauthorized) 36 | } 37 | 38 | func renderJsonResponse(res http.ResponseWriter, body any) { 39 | if err := json.NewEncoder(res).Encode(body); err != nil { 40 | renderErrorResponse(res, err) 41 | } else { 42 | res.Header().Add("Content-Type", "application/json") 43 | } 44 | } 45 | 46 | // Pagination Helpers 47 | 48 | func getPaginationParams(req *http.Request) (limit int, offset int) { 49 | // helper function to clamp the value 50 | clamp := func(val int, min int, max int) int { 51 | if val < min { 52 | return min 53 | } else if val > max { 54 | return max 55 | } else { 56 | return val 57 | } 58 | } 59 | 60 | // get the limit from the URL 61 | limit_param := req.URL.Query().Get("limit") 62 | if limit_val, err := strconv.Atoi(limit_param); err != nil { 63 | // error occurred, just use a default value 64 | fmt.Printf("Failed to parse the limit from URL: %v\n", err) 65 | limit = 5 66 | } else { 67 | // clamp the value in case of invalid parameters (intentional or otherwise) 68 | limit = clamp(limit_val, 0, 25) 69 | } 70 | 71 | // get the offset from the URL 72 | offset_param := req.URL.Query().Get("offset") 73 | if offset_val, err := strconv.Atoi(offset_param); err != nil { 74 | // error occurred, just use a default value 75 | fmt.Printf("Failed to parse the offset from URL: %v\n", err) 76 | offset = 0 77 | } else { 78 | // clamp the value in case of invalid parameters (intentional or otherwise) 79 | // limiting this one to 10,000 because I find it unlikely that anyone will post 10k times :) 80 | offset = clamp(offset_val, 0, 10000) 81 | } 82 | 83 | return limit, offset 84 | } 85 | 86 | // Auth Helpers 87 | 88 | func getUserId(req *http.Request) string { 89 | claims := req.Context().Value(claimsCtxKey{}).(jwt.MapClaims) 90 | return claims["sub"].(string) 91 | } 92 | 93 | // HTTP Client 94 | //TODO: this could be contributed to spin go sdk 95 | 96 | type spinRoundTripper struct{} 97 | 98 | func (t spinRoundTripper) RoundTrip(req *http.Request) (resp *http.Response, err error) { 99 | return spinhttp.Send(req) 100 | } 101 | 102 | func NewHttpClient() *http.Client { 103 | return &http.Client{ 104 | Transport: spinRoundTripper{}, 105 | Timeout: time.Duration(5) * time.Second, 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /api/post/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | spinhttp "github.com/fermyon/spin/sdk/go/http" 8 | "github.com/go-chi/chi/v5" 9 | ) 10 | 11 | func main() {} 12 | 13 | func init() { 14 | spinhttp.Handle(func(res http.ResponseWriter, req *http.Request) { 15 | // we need to setup the router inside spin handler 16 | router := chi.NewRouter() 17 | 18 | router.Use(TokenVerification) 19 | 20 | // mount our routes using the prefix 21 | routePrefix := req.Header.Get("Spin-Component-Route") 22 | router.Mount(routePrefix, PostRouter()) 23 | 24 | // hand the request/response off to chi 25 | router.ServeHTTP(res, req) 26 | }) 27 | } 28 | 29 | // TODO: create wrapper function to handle errors? 30 | func PostRouter() chi.Router { 31 | posts := chi.NewRouter() 32 | idParamPattern := fmt.Sprintf("/{%v:[0-9]+}", postIdCtxKey) 33 | posts.Use(PostCtx) 34 | posts.Post("/", createPost) 35 | posts.Get("/", listPosts) 36 | posts.Get(idParamPattern, readPost) 37 | posts.Put(idParamPattern, updatePost) 38 | posts.Delete(idParamPattern, deletePost) 39 | return posts 40 | } 41 | 42 | func createPost(res http.ResponseWriter, req *http.Request) { 43 | post := req.Context().Value(postCtxKey{}).(Post) 44 | 45 | if getUserId(req) != post.AuthorID { 46 | renderForbiddenResponse(res) 47 | return 48 | } 49 | 50 | if err := DbInsert(&post); err != nil { 51 | renderErrorResponse(res, err) 52 | return 53 | } 54 | 55 | res.WriteHeader(http.StatusCreated) 56 | res.Header().Add("location", fmt.Sprintf("/api/post/%v", post.ID)) 57 | renderJsonResponse(res, post) 58 | } 59 | 60 | func listPosts(res http.ResponseWriter, req *http.Request) { 61 | limit, offset := getPaginationParams(req) 62 | authorId := getUserId(req) 63 | 64 | if posts, err := DbReadAll(limit, offset, authorId); err != nil { 65 | renderErrorResponse(res, err) 66 | } else { 67 | renderJsonResponse(res, posts) 68 | } 69 | } 70 | 71 | func readPost(res http.ResponseWriter, req *http.Request) { 72 | id, err := getPostId(req) 73 | if err != nil { 74 | msg := fmt.Sprintf("Failed to parse URL param 'id': %v", id) 75 | renderBadRequestResponse(res, msg) 76 | return 77 | } 78 | 79 | authorId := getUserId(req) 80 | 81 | post, err := DbReadById(id, authorId) 82 | if err != nil { 83 | renderErrorResponse(res, err) 84 | return 85 | } 86 | if (post == Post{}) { 87 | renderNotFound(res) 88 | return 89 | } 90 | 91 | renderJsonResponse(res, post) 92 | } 93 | 94 | func updatePost(res http.ResponseWriter, req *http.Request) { 95 | post := req.Context().Value(postCtxKey{}).(Post) 96 | 97 | if id, err := getPostId(req); err != nil { 98 | msg := fmt.Sprintf("Failed to parse URL param 'id': %v", id) 99 | renderBadRequestResponse(res, msg) 100 | return 101 | } else { 102 | post.ID = id 103 | } 104 | 105 | if err := DbUpdate(post); err != nil { 106 | renderErrorResponse(res, err) 107 | } 108 | renderJsonResponse(res, post) 109 | } 110 | 111 | func deletePost(res http.ResponseWriter, req *http.Request) { 112 | id, err := getPostId(req) 113 | if err != nil { 114 | msg := fmt.Sprintf("Failed to parse URL param 'id': %v", id) 115 | renderBadRequestResponse(res, msg) 116 | return 117 | } 118 | 119 | authorId := getUserId(req) 120 | 121 | if err := DbDelete(id, authorId); err != nil { 122 | renderErrorResponse(res, err) 123 | } 124 | res.WriteHeader(http.StatusNoContent) 125 | } 126 | -------------------------------------------------------------------------------- /web/playwright.config.ts: -------------------------------------------------------------------------------- 1 | import type { PlaywrightTestConfig } from "@playwright/test"; 2 | import { devices } from "@playwright/test"; 3 | 4 | /** 5 | * Read environment variables from file. 6 | * https://github.com/motdotla/dotenv 7 | */ 8 | // require('dotenv').config(); 9 | 10 | /** 11 | * See https://playwright.dev/docs/test-configuration. 12 | */ 13 | const config: PlaywrightTestConfig = { 14 | testDir: "./e2e", 15 | /* Maximum time one test can run for. */ 16 | timeout: 30 * 1000, 17 | expect: { 18 | /** 19 | * Maximum time expect() should wait for the condition to be met. 20 | * For example in `await expect(locator).toHaveText();` 21 | */ 22 | timeout: 5000, 23 | }, 24 | /* Fail the build on CI if you accidentally left test.only in the source code. */ 25 | forbidOnly: !!process.env.CI, 26 | /* Retry on CI only */ 27 | retries: process.env.CI ? 2 : 0, 28 | /* Opt out of parallel tests on CI. */ 29 | workers: process.env.CI ? 1 : undefined, 30 | /* Reporter to use. See https://playwright.dev/docs/test-reporters */ 31 | reporter: "html", 32 | /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ 33 | use: { 34 | /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ 35 | actionTimeout: 0, 36 | /* Base URL to use in actions like `await page.goto('/')`. */ 37 | baseURL: "http://localhost:5173", 38 | 39 | /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ 40 | trace: "on-first-retry", 41 | 42 | /* Only on CI systems run the tests headless */ 43 | headless: !!process.env.CI, 44 | }, 45 | 46 | /* Configure projects for major browsers */ 47 | projects: [ 48 | { 49 | name: "chromium", 50 | use: { 51 | ...devices["Desktop Chrome"], 52 | }, 53 | }, 54 | { 55 | name: "firefox", 56 | use: { 57 | ...devices["Desktop Firefox"], 58 | }, 59 | }, 60 | { 61 | name: "webkit", 62 | use: { 63 | ...devices["Desktop Safari"], 64 | }, 65 | }, 66 | 67 | /* Test against mobile viewports. */ 68 | // { 69 | // name: 'Mobile Chrome', 70 | // use: { 71 | // ...devices['Pixel 5'], 72 | // }, 73 | // }, 74 | // { 75 | // name: 'Mobile Safari', 76 | // use: { 77 | // ...devices['iPhone 12'], 78 | // }, 79 | // }, 80 | 81 | /* Test against branded browsers. */ 82 | // { 83 | // name: 'Microsoft Edge', 84 | // use: { 85 | // channel: 'msedge', 86 | // }, 87 | // }, 88 | // { 89 | // name: 'Google Chrome', 90 | // use: { 91 | // channel: 'chrome', 92 | // }, 93 | // }, 94 | ], 95 | 96 | /* Folder for test artifacts such as screenshots, videos, traces, etc. */ 97 | // outputDir: 'test-results/', 98 | 99 | /* Run your local dev server before starting the tests */ 100 | webServer: { 101 | /** 102 | * Use the dev server by default for faster feedback loop. 103 | * Use the preview server on CI for more realistic testing. 104 | Playwright will re-use the local server if there is already a dev-server running. 105 | */ 106 | command: process.env.CI ? "vite preview --port 5173" : "vite dev", 107 | port: 5173, 108 | reuseExistingServer: !process.env.CI, 109 | }, 110 | }; 111 | 112 | export default config; 113 | -------------------------------------------------------------------------------- /web/src/views/posts/MyPostsView.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 103 | -------------------------------------------------------------------------------- /web/src/assets/logo-black.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 15 | 19 | 25 | 30 | 34 | 36 | 42 | 46 | 52 | 55 | 57 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /web/src/assets/logo-white.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | 10 | 15 | 19 | 25 | 30 | 34 | 36 | 42 | 46 | 52 | 55 | 57 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /api/post/model.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "strings" 9 | ) 10 | 11 | // Post model 12 | 13 | type Post struct { 14 | ID int `json:"id,omitempty"` // auto-incremented by postgres 15 | AuthorID string `json:"author_id,omitempty"` // foreign key to user's id 16 | Content string `json:"content,omitempty"` // anything the poster wants to say about a piece of code they're sharing 17 | Type PostType `json:"type,omitempty"` // post could be a permalink, pasted code, gist, etc. 18 | Data string `json:"data,omitempty"` // actual permalink, code, gist link, etc. 19 | Visibility PostVisibility `json:"visibility,omitempty"` // basic visibility of public, followers, etc. 20 | } 21 | 22 | func (p Post) Validate() error { 23 | var errs []error 24 | 25 | if p.AuthorID == "" { 26 | errs = append(errs, fmt.Errorf("field 'author_id' is required")) 27 | } 28 | 29 | if p.Content == "" { 30 | errs = append(errs, fmt.Errorf("field 'content' is required")) 31 | } 32 | 33 | if p.Type == 0 { 34 | errs = append(errs, fmt.Errorf("field 'type' contains unknown value")) 35 | } 36 | 37 | if p.Data == "" { 38 | errs = append(errs, fmt.Errorf("field 'data' is required")) 39 | } 40 | 41 | if p.Visibility == 0 { 42 | errs = append(errs, fmt.Errorf("field 'visibility' contains unknown value")) 43 | } 44 | 45 | return errors.Join(errs...) 46 | } 47 | 48 | // Post Type enum 49 | 50 | type PostType uint8 51 | 52 | const ( 53 | PostTypePermalinkRange PostType = iota + 1 54 | ) 55 | 56 | var ( 57 | PostType_name = map[PostType]string{ 58 | PostTypePermalinkRange: "permalink-range", 59 | } 60 | PostType_value = map[string]PostType{ 61 | "permalink-range": PostTypePermalinkRange, 62 | } 63 | ) 64 | 65 | func (t PostType) String() string { 66 | return PostType_name[t] 67 | } 68 | 69 | func ParsePostType(s string) (PostType, error) { 70 | s = strings.TrimSpace(strings.ToLower(s)) 71 | value, ok := PostType_value[s] 72 | if !ok { 73 | return PostType(0), fmt.Errorf("%q is not a valid post type", s) 74 | } 75 | return PostType(value), nil 76 | } 77 | 78 | func (t PostType) MarshalJSON() ([]byte, error) { 79 | return json.Marshal(t.String()) 80 | } 81 | 82 | func (t *PostType) UnmarshalJSON(data []byte) (err error) { 83 | var postType string 84 | if err := json.Unmarshal(data, &postType); err != nil { 85 | return err 86 | } 87 | if *t, err = ParsePostType(postType); err != nil { 88 | return err 89 | } 90 | return nil 91 | } 92 | 93 | // Post Visibility enum 94 | 95 | type PostVisibility uint8 96 | 97 | const ( 98 | PostVisibilityPublic PostVisibility = iota + 1 99 | PostVisibilityFollowers 100 | ) 101 | 102 | var ( 103 | PostVisibility_name = map[PostVisibility]string{ 104 | PostVisibilityPublic: "public", 105 | PostVisibilityFollowers: "followers", 106 | } 107 | PostVisibility_value = map[string]PostVisibility{ 108 | "public": PostVisibilityPublic, 109 | "followers": PostVisibilityFollowers, 110 | } 111 | ) 112 | 113 | func (v PostVisibility) String() string { 114 | return PostVisibility_name[v] 115 | } 116 | 117 | func ParsePostVisibility(s string) (PostVisibility, error) { 118 | s = strings.TrimSpace(strings.ToLower(s)) 119 | value, ok := PostVisibility_value[s] 120 | if !ok { 121 | return PostVisibility(0), fmt.Errorf("%q is not a valid post visibility", s) 122 | } 123 | return PostVisibility(value), nil 124 | } 125 | 126 | func (v PostVisibility) MarshalJSON() ([]byte, error) { 127 | return json.Marshal(v.String()) 128 | } 129 | 130 | func (v *PostVisibility) UnmarshalJSON(data []byte) (err error) { 131 | var visibility string 132 | if err := json.Unmarshal(data, &visibility); err != nil { 133 | return err 134 | } 135 | if *v, err = ParsePostVisibility(visibility); err != nil { 136 | return err 137 | } 138 | return nil 139 | } 140 | 141 | // JSON helpers 142 | 143 | func DecodePost(r io.ReadCloser) (Post, error) { 144 | decoder := json.NewDecoder(r) 145 | var p Post 146 | err := decoder.Decode(&p) 147 | return p, err 148 | } 149 | -------------------------------------------------------------------------------- /web/src/views/posts/NewPostView.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 |