├── .env.template ├── .github ├── dependabot.yml ├── linters │ └── .markdown-lint.yml └── workflows │ ├── combine.yml │ └── lint-test-fmt.yml ├── .gitignore ├── .markdownlintignore ├── CHANGELOG.md ├── README.md ├── client ├── .eslintignore ├── .eslintrc.cjs ├── .prettierignore ├── .prettierrc ├── globals.d.ts ├── index.html ├── package.json ├── postcss.config.js ├── prettier.config.js ├── public │ └── robots.txt ├── src │ ├── App.svelte │ ├── components │ │ ├── Footer.svelte │ │ ├── LazyRoute.svelte │ │ ├── LazyRouteGuard.svelte │ │ └── Nav.svelte │ ├── hmr.ts │ ├── index.css │ ├── index.ts │ ├── routes │ │ ├── About.svelte │ │ ├── Home.svelte │ │ └── NotFound.svelte │ └── tests │ │ └── example.ts ├── svelte.config.js ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts ├── server ├── .cargo │ └── config.toml ├── Cargo.toml ├── Rocket.toml ├── rustfmt.toml └── src │ ├── cache.rs │ ├── cors.rs │ ├── csp.rs │ ├── main.rs │ └── postgres.rs └── update.sh /.env.template: -------------------------------------------------------------------------------- 1 | # Client 2 | VITE_API_URL="http://127.0.0.1:3000/api" 3 | VITE_LOG_LEVEL=debug 4 | BUILD_PATH=../server/dist 5 | 6 | # Server 7 | RUST_LOG=debug 8 | CORS_ORIGIN=127.0.0.1:3000 9 | DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: cargo 4 | directory: /server 5 | schedule: 6 | interval: weekly 7 | commit-message: 8 | prefix: "build" 9 | prefix-development: "build" 10 | 11 | - package-ecosystem: npm 12 | directory: /client 13 | schedule: 14 | interval: weekly 15 | commit-message: 16 | prefix: "build" 17 | prefix-development: "build" 18 | 19 | - package-ecosystem: github-actions 20 | directory: / 21 | schedule: 22 | interval: weekly 23 | commit-message: 24 | prefix: "build" 25 | prefix-development: "build" 26 | -------------------------------------------------------------------------------- /.github/linters/.markdown-lint.yml: -------------------------------------------------------------------------------- 1 | { 2 | "MD013": false, 3 | } -------------------------------------------------------------------------------- /.github/workflows/combine.yml: -------------------------------------------------------------------------------- 1 | name: "Combine Dependabot PRs" 2 | on: 3 | workflow_dispatch: 4 | 5 | jobs: 6 | combine-prs: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v4.1.1 10 | - uses: maadhattah/combine-dependabot-prs@main 11 | with: 12 | branchPrefix: "dependabot" 13 | mustBeGreen: true 14 | combineBranchName: "combined-prs" 15 | ignoreLabel: "nocombine" 16 | baseBranch: "main" 17 | openPR: true 18 | -------------------------------------------------------------------------------- /.github/workflows/lint-test-fmt.yml: -------------------------------------------------------------------------------- 1 | name: 'TS Lint, Test, and Format' 2 | on: 3 | pull_request: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | lint-test-format: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: ./client 12 | steps: 13 | - uses: actions/checkout@v4.1.1 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 'latest' 17 | cache: 'npm' 18 | cache-dependency-path: '**/package-lock.json' 19 | - run: npm ci 20 | - run: npm run lint:fix 21 | - run: npm run tsc 22 | - run: npm run test 23 | - run: npm run fmt -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | .cargo 3 | .turbo 4 | assets 5 | build 6 | dist 7 | node_modules 8 | public 9 | target 10 | 11 | # Files 12 | .env 13 | .log 14 | Cargo.lock 15 | pnpm-lock.yaml 16 | 17 | # User Settings 18 | .idea 19 | .vscode -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | LICENSE 2 | LICENSE-MIT 3 | LICENSE-APACHE 4 | node_modules 5 | target -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Template: Rocket + Svelte SPA 2 | 3 | ## Backend 4 | 5 | - __[Rocket](https://rocket.rs)__ 6 | - __[PostgreSQL](https://www.postgresql.org)__ 7 | 8 | ## Frontend 9 | 10 | - __[Svelte](https://svelte.dev)__ 11 | - __[svelte-navigator](https://github.com/mefechoel/svelte-navigator)__ 12 | - __[TypeScript](https://www.typescriptlang.org)__ 13 | - __[Tailwind CSS](https://tailwindcss.com)__ 14 | - __[Vite](https://vitejs.dev/)__ + __[Vitest](https://vitest.dev/)__ 15 | 16 | ## Getting Started 17 | 18 | - Clone the repository: `git clone 19 | https://github.com/robertwayne/template-rocket-svelte-spa` 20 | - Change `.env.TEMPLATE` to `.env` and set your Postgres credentials _(if not 21 | using defaults)_. 22 | - Build the client with `pnpm run build` from inside the `/client` directory. 23 | _Alternatively, you can use `pnpm run dev` to run the client with vite dev 24 | server._ 25 | - Run the server with `cargo run` from inside the `/server` directory. 26 | - If you're serving from Rocket, visit `http://127.0.0.1:3000`. 27 | - If you're serving from vite, visit `http://127.0.0.1:8000`. 28 | 29 | ## Client Notes 30 | 31 | - Async, lazy loading route wrappers. 32 | - Responsive navigation menu built-in. 33 | 34 | ## Server Notes 35 | 36 | - Sets Cache Control headers for HTML, CSS, JS, WEBP, SVG, and WOFF2. 37 | - Sets CORS and CSP headers. 38 | - Includes a PostgreSQL fairing, which is disabled by default. Add this fairing 39 | in `main.rs` if you wish to use it. 40 | 41 | ## GitHub Action Notes 42 | 43 | - Runs _(client)_ tests, eslint, tsc, and prettier on PRs. 44 | - Runs dependabot weekly. You can manually run `combine` to squish all 45 | dependabot PRs into one PR. 46 | - Server tests/formatting are not run on PR _(yet)_. 47 | 48 | ## Misc Scripts 49 | 50 | | Command | Action | 51 | |---------|--------| 52 | | ./update.sh | Updates the dependencies of both the client and server projects. | 53 | 54 | ## Other Templates 55 | 56 | - __[Axum + SolidJS](https://github.com/robertwayne/template-axum-solidjs-spa)__ 57 | -------------------------------------------------------------------------------- /client/.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | dist/ -------------------------------------------------------------------------------- /client/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | node: true, 5 | }, 6 | parser: "@typescript-eslint/parser", 7 | plugins: ["svelte3", "@typescript-eslint"], 8 | overrides: [ 9 | { 10 | files: ["*.ts"], 11 | extends: [ 12 | "eslint:recommended", 13 | "plugin:@typescript-eslint/recommended", 14 | ], 15 | rules: { 16 | "@typescript-eslint/array-type": [ 17 | "error", 18 | { 19 | default: "generic", 20 | }, 21 | ], 22 | "no-undef": "off", 23 | "no-unused-vars": "off", 24 | "@typescript-eslint/no-unused-vars": [ 25 | "error", 26 | { 27 | argsIgnorePattern: "^_", 28 | varsIgnorePattern: "^_", 29 | }, 30 | ], 31 | "@typescript-eslint/no-empty-function": "warn", 32 | }, 33 | }, 34 | { 35 | files: ["*.svelte"], 36 | processor: "svelte3/svelte3", 37 | extends: [ 38 | "eslint:recommended", 39 | "plugin:@typescript-eslint/recommended", 40 | ], 41 | rules: { 42 | "@typescript-eslint/array-type": [ 43 | "error", 44 | { 45 | default: "generic", 46 | }, 47 | ], 48 | "no-undef": "off", 49 | "no-unused-vars": "off", 50 | }, 51 | }, 52 | ], 53 | rules: {}, 54 | settings: { 55 | "svelte3/typescript": require("typescript"), 56 | }, 57 | } 58 | -------------------------------------------------------------------------------- /client/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.min.css 3 | *.min.js 4 | *.webmanifest -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "tabWidth": 4, 3 | "semi": false, 4 | "svelteSortOrder" : "options-scripts-markup-styles", 5 | "svelteStrictMode": false, 6 | "svelteIndentScriptAndStyle": true, 7 | "svelteAllowShorthand": true 8 | } -------------------------------------------------------------------------------- /client/globals.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | Template: Rocket + Svelte 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "repository": "https://github.com/robertwayne/template-rocket-svelte-spa", 5 | "author": "Rob Wagner ", 6 | "license": "", 7 | "type": "module", 8 | "private": true, 9 | "scripts": { 10 | "dev": "vite", 11 | "build": "vite build", 12 | "serve": "vite preview", 13 | "fmt": "prettier --write --plugin-search-dir=. \"**/*.{ts,html,css,cjs,json,tsx}\"", 14 | "lint": "eslint \"src/**/*.{ts,svelte}\"", 15 | "lint:fix": "eslint --fix --fix-type problem,suggestion \"**/*.{ts,svelte}\"", 16 | "tsc": "tsc --noEmit", 17 | "tsc:watch": "tsc --noEmit --watch", 18 | "test": "vitest run --no-watch", 19 | "test:watch": "vitest" 20 | }, 21 | "devDependencies": { 22 | "@sveltejs/vite-plugin-svelte": "^3.1.0", 23 | "@tailwindcss/forms": "^0.5.7", 24 | "@tailwindcss/typography": "^0.5.13", 25 | "@typescript-eslint/eslint-plugin": "^7.8.0", 26 | "@typescript-eslint/parser": "^7.8.0", 27 | "autoprefixer": "^10.4.19", 28 | "dotenv": "^16.4.5", 29 | "eslint": "^9.2.0", 30 | "eslint-plugin-svelte3": "^4.0.0", 31 | "happy-dom": "^14.10.1", 32 | "postcss": "^8.4.38", 33 | "postcss-load-config": "^5.1.0", 34 | "prettier": "^3.2.5", 35 | "prettier-plugin-tailwindcss": "^0.5.14", 36 | "svelte": "^4.2.15", 37 | "svelte-language-server": "^0.16.9", 38 | "svelte-navigator": "^3.2.2", 39 | "svelte-preprocess": "^5.1.4", 40 | "tailwindcss": "^3.4.3", 41 | "typescript": "^5.4.5", 42 | "vite": "^5.2.11", 43 | "vitest": "^1.6.0" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/postcss.config.js: -------------------------------------------------------------------------------- 1 | import autoprefixer from "autoprefixer" 2 | import tailwindcss from "tailwindcss" 3 | 4 | export default { 5 | plugins: [tailwindcss(), autoprefixer], 6 | } 7 | -------------------------------------------------------------------------------- /client/prettier.config.js: -------------------------------------------------------------------------------- 1 | import prettier from "prettier-plugin-tailwindcss" 2 | 3 | export default { 4 | plugins: [prettier], 5 | } 6 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: -------------------------------------------------------------------------------- /client/src/App.svelte: -------------------------------------------------------------------------------- 1 | 22 | 23 |
27 | 28 |
45 | -------------------------------------------------------------------------------- /client/src/components/Footer.svelte: -------------------------------------------------------------------------------- 1 |
2 | 11 | 12 | © {new Date().getFullYear()} Name / Company 13 |
14 | -------------------------------------------------------------------------------- /client/src/components/LazyRoute.svelte: -------------------------------------------------------------------------------- 1 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /client/src/components/LazyRouteGuard.svelte: -------------------------------------------------------------------------------- 1 | 45 | 46 | {#if loadedComponent} 47 | 48 | {:else if showFallback} 49 | 50 | {/if} 51 | -------------------------------------------------------------------------------- /client/src/components/Nav.svelte: -------------------------------------------------------------------------------- 1 | 19 | 20 | 78 | -------------------------------------------------------------------------------- /client/src/hmr.ts: -------------------------------------------------------------------------------- 1 | import type { Writable } from "svelte/store" 2 | 3 | let stores: Record> = {} 4 | 5 | export function registerStore(id: string, store: Writable): void { 6 | stores[id] = store 7 | } 8 | 9 | // preserve the store across HMR updates 10 | if (import.meta.hot) { 11 | if (import.meta.hot.data.stores) { 12 | stores = import.meta.hot.data.stores 13 | } 14 | import.meta.hot.accept() 15 | import.meta.hot.dispose(() => { 16 | if (import.meta.hot) { 17 | import.meta.hot.data.stores = stores 18 | } 19 | }) 20 | } 21 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | *, 2 | *::before, 3 | *::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | * { 8 | margin: 0; 9 | padding: 0; 10 | font: inherit; 11 | } 12 | 13 | html, 14 | body, 15 | body > #app > div { 16 | font-family: "Roboto", sans-serif; 17 | font-display: swap; 18 | color-scheme: dark light; 19 | overflow-x: hidden; 20 | min-height: 100svh; 21 | } 22 | 23 | a { 24 | color: inherit; 25 | text-decoration: none; 26 | } 27 | 28 | img, 29 | picture, 30 | svg, 31 | video { 32 | display: block; 33 | max-width: 100%; 34 | } 35 | 36 | @tailwind base; 37 | @tailwind components; 38 | @tailwind utilities; 39 | -------------------------------------------------------------------------------- /client/src/index.ts: -------------------------------------------------------------------------------- 1 | import "./index.css" 2 | import "./hmr" 3 | 4 | import App from "./App.svelte" 5 | 6 | const app = new App({ 7 | target: document.body, 8 | }) 9 | 10 | export default app 11 | -------------------------------------------------------------------------------- /client/src/routes/About.svelte: -------------------------------------------------------------------------------- 1 |
2 |

About Page!

3 |

Welcome to the about page!

4 |
5 | -------------------------------------------------------------------------------- /client/src/routes/Home.svelte: -------------------------------------------------------------------------------- 1 |
2 |

Home Page!

3 |

Welcome to the home page!

4 |
5 | -------------------------------------------------------------------------------- /client/src/routes/NotFound.svelte: -------------------------------------------------------------------------------- 1 | 4 | 5 |
6 |

404 Not Found

7 |

The page you're looking for doesn't exist!

8 | 9 | 15 |
16 | -------------------------------------------------------------------------------- /client/src/tests/example.ts: -------------------------------------------------------------------------------- 1 | // This is just an example test. You should remove it. 2 | if (import.meta.vitest) { 3 | const { it, expect } = import.meta.vitest 4 | 5 | it("should work", () => { 6 | expect(true).toBe(true) 7 | }) 8 | } 9 | -------------------------------------------------------------------------------- /client/svelte.config.js: -------------------------------------------------------------------------------- 1 | import preprocess from "svelte-preprocess" 2 | 3 | export default { 4 | preprocess: preprocess({ postcss: true }), 5 | } 6 | -------------------------------------------------------------------------------- /client/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ["./src/**/*.{html,js,svelte,ts}"], 3 | theme: { 4 | extend: {}, 5 | }, 6 | darkMode: "class", 7 | plugins: [ 8 | require("@tailwindcss/typography"), 9 | require("@tailwindcss/forms"), 10 | ], 11 | } 12 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": ["esnext", "DOM"], 7 | "rootDir": "../", 8 | "outDir": "dist", 9 | "strict": true, 10 | "alwaysStrict": true, 11 | "strictFunctionTypes": true, 12 | "strictNullChecks": true, 13 | "strictPropertyInitialization": true, 14 | "esModuleInterop": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noImplicitAny": true, 17 | "noImplicitReturns": true, 18 | "noImplicitThis": true, 19 | "noFallthroughCasesInSwitch": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "emitDecoratorMetadata": true, 23 | "experimentalDecorators": true, 24 | "downlevelIteration": true, 25 | "declaration": true, 26 | "skipLibCheck": true, 27 | "pretty": true, 28 | "types": ["vitest/importMeta", "vite/client"] 29 | }, 30 | "include": ["src/**/*", "*.d.ts"], 31 | "exclude": ["node_modules"] 32 | } 33 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import * as dotenv from "dotenv" 2 | 3 | import { defineConfig } from "vitest/config" 4 | import { svelte } from "@sveltejs/vite-plugin-svelte" 5 | 6 | dotenv.config({ path: "../.env" }) 7 | 8 | export default defineConfig({ 9 | base: "/", 10 | plugins: [svelte()], 11 | build: { 12 | outDir: process.env.BUILD_PATH || "dist", 13 | emptyOutDir: true, 14 | }, 15 | optimizeDeps: { 16 | exclude: ["svelte-navigator"], 17 | }, 18 | server: { 19 | host: "127.0.0.1", 20 | port: 8000, 21 | }, 22 | define: { 23 | "import.meta.vitest": false, 24 | }, 25 | test: { 26 | includeSource: ["src/**/*.ts"], 27 | globals: true, 28 | environment: "happy-dom", 29 | }, 30 | }) 31 | -------------------------------------------------------------------------------- /server/.cargo/config.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | rustflags = ["-C", "target-cpu=native"] 3 | 4 | # Use the mold linker 5 | # [target.x86_64-unknown-linux-gnu] 6 | # linker = "clang" 7 | # rustflags = [ 8 | # "-C", 9 | # "link-arg=-fuse-ld=/usr/bin/mold", 10 | # "-C", 11 | # "target-cpu=native", 12 | # "-Z", 13 | # "share-generics=y", 14 | # ] 15 | 16 | [registries.crates-io] 17 | protocol = "sparse" -------------------------------------------------------------------------------- /server/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "server" 3 | authors = ["Rob Wagner "] 4 | license = "" 5 | repository = "https://github.com/robertwayne/template-rocket-svelte-spa" 6 | version = "0.1.0" 7 | edition = "2021" 8 | publish = false 9 | 10 | [dependencies] 11 | dotenvy = "0.15" 12 | rocket = { version = "0.5", features = ["json", "secrets"] } 13 | sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres"] } 14 | tracing = { version = "0.1", default-features = false, features = ["std"] } 15 | tracing-subscriber = { version = "0.3", features = ["env-filter"] } 16 | 17 | [profile.release] 18 | opt-level = 3 19 | codegen-units = 1 20 | lto = true 21 | strip = true 22 | -------------------------------------------------------------------------------- /server/Rocket.toml: -------------------------------------------------------------------------------- 1 | [default] 2 | port = 3000 3 | 4 | [release] 5 | secret_key = "" 6 | -------------------------------------------------------------------------------- /server/rustfmt.toml: -------------------------------------------------------------------------------- 1 | group_imports = "StdExternalCrate" 2 | imports_granularity = "Crate" 3 | reorder_imports = true 4 | -------------------------------------------------------------------------------- /server/src/cache.rs: -------------------------------------------------------------------------------- 1 | use rocket::{ 2 | fairing::{self, Fairing}, 3 | http::{ContentType, Header}, 4 | Request, Response, 5 | }; 6 | 7 | /// Attaches a Cache Control header to all responses. The default implementation 8 | /// caches CSS, JS, WOFF2, and WEBP files only, with a max age of 1 year. 9 | #[derive(Debug)] 10 | pub struct CacheControl { 11 | max_age: u32, 12 | cache_types: Vec, 13 | } 14 | 15 | impl Default for CacheControl { 16 | fn default() -> Self { 17 | CacheControl { 18 | max_age: 60 * 60 * 24 * 365, 19 | cache_types: vec![ 20 | ContentType::CSS, 21 | ContentType::JavaScript, 22 | ContentType::WOFF2, 23 | ContentType::WEBP, 24 | ], 25 | } 26 | } 27 | } 28 | 29 | #[rocket::async_trait] 30 | impl Fairing for CacheControl { 31 | fn info(&self) -> fairing::Info { 32 | fairing::Info { 33 | name: "Cache Control", 34 | kind: fairing::Kind::Response, 35 | } 36 | } 37 | 38 | async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) { 39 | if let Some(content_type) = response.content_type() { 40 | if self.cache_types.contains(&content_type) { 41 | response.set_header(Header::new( 42 | "Cache-Control", 43 | format!("public, max-age={}", self.max_age), 44 | )); 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /server/src/cors.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use rocket::{ 4 | fairing::{self, Fairing}, 5 | http::{Header, Method, Status}, 6 | Request, Response, 7 | }; 8 | 9 | /// Attaches a CORS policy header to defined responses. 10 | #[derive(Debug, Default)] 11 | pub struct CrossOriginResourceSharing; 12 | 13 | #[rocket::async_trait] 14 | impl Fairing for CrossOriginResourceSharing { 15 | fn info(&self) -> fairing::Info { 16 | fairing::Info { 17 | name: "Cross-Origin Resource Sharing", 18 | kind: fairing::Kind::Response, 19 | } 20 | } 21 | 22 | async fn on_response<'r>(&self, request: &'r Request<'_>, response: &mut Response<'r>) { 23 | response.set_header(Header::new( 24 | "Access-Control-Allow-Origin", 25 | env::var("CORS_ORIGIN").unwrap_or_else(|_| "http://localhost:5173".to_string()), 26 | )); 27 | response.set_header(Header::new( 28 | "Access-Control-Allow-Headers", 29 | "Accept, Content-Type", 30 | )); 31 | response.set_header(Header::new( 32 | "Access-Control-Allow-Methods", 33 | "DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUT", 34 | )); 35 | response.set_header(Header::new("Vary", "Origin")); 36 | response.set_header(Header::new("Access-Control-Allow-Credentials", "true")); 37 | response.set_header(Header::new("Access-Control-Max-Age", "86400")); 38 | 39 | if request.method() == Method::Options { 40 | response.set_status(Status::NoContent); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/src/csp.rs: -------------------------------------------------------------------------------- 1 | use rocket::{ 2 | fairing::{self, Fairing}, 3 | http::Header, 4 | Request, Response, 5 | }; 6 | 7 | /// Attaches a Content Security Policy header to all responses. 8 | #[derive(Debug, Default)] 9 | pub struct ContentSecurityPolicy; 10 | 11 | #[rocket::async_trait] 12 | impl Fairing for ContentSecurityPolicy { 13 | fn info(&self) -> fairing::Info { 14 | fairing::Info { 15 | name: "Content Security Policy", 16 | kind: fairing::Kind::Response, 17 | } 18 | } 19 | 20 | async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) { 21 | response.set_header( 22 | Header::new("Content-Security-Policy", 23 | "default-src 'self'; script-src 'self'; script-src-elem 'self'; script-src-attr 'self'; style-src 'self'; style-src-elem 'self' 'unsafe-inline'; style-src-attr 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self'; media-src 'self'; object-src 'none'; prefetch-src 'self'; child-src 'none'; frame-src 'none'; worker-src 'self'; frame-ancestors 'none'; form-action 'self'; upgrade-insecure-requests; base-uri 'self'; manifest-src 'self' data:" 24 | )); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/main.rs: -------------------------------------------------------------------------------- 1 | #![forbid(unsafe_code)] 2 | 3 | #[macro_use] 4 | extern crate rocket; 5 | 6 | mod cache; 7 | mod cors; 8 | mod csp; 9 | mod postgres; 10 | 11 | use std::{ 12 | env, 13 | path::{Path, PathBuf}, 14 | }; 15 | 16 | use dotenvy::dotenv; 17 | use rocket::{ 18 | fs::{relative, NamedFile}, 19 | shield::Shield, 20 | }; 21 | use tracing_subscriber::EnvFilter; 22 | 23 | use crate::{cache::CacheControl, cors::CrossOriginResourceSharing, csp::ContentSecurityPolicy}; 24 | 25 | const DIST: &str = relative!("dist"); 26 | 27 | /// Matches against the robots.txt within the /dist root directory. 28 | #[get("/<_..>", rank = 0)] 29 | async fn robots() -> Option { 30 | NamedFile::open(Path::new(DIST).join("robots.txt")) 31 | .await 32 | .ok() 33 | } 34 | 35 | /// Matches against any file within the /dist/assets directory. 36 | #[get("/", rank = 1)] 37 | async fn static_files(file: PathBuf) -> Option { 38 | NamedFile::open(Path::new(DIST).join("assets/").join(file)) 39 | .await 40 | .ok() 41 | } 42 | 43 | /// Matches against the index.html file within the /dist directory. This is the 44 | /// entry point to your SPA, dynamically populated by Svelte and Vite at build 45 | /// time. 46 | #[get("/<_..>", rank = 2)] 47 | async fn index() -> Option { 48 | NamedFile::open(Path::new(DIST).join("index.html")) 49 | .await 50 | .ok() 51 | } 52 | 53 | #[launch] 54 | fn rocket() -> _ { 55 | dotenv().ok(); 56 | tracing_subscriber::fmt() 57 | .with_env_filter(EnvFilter::from_default_env()) 58 | .init(); 59 | 60 | rocket::build() 61 | .attach(CacheControl::default()) 62 | .attach(ContentSecurityPolicy::default()) 63 | .attach(CrossOriginResourceSharing::default()) 64 | .attach(Shield::default()) 65 | .mount("/robots.txt", routes![robots]) 66 | .mount("/assets", routes![static_files]) 67 | .mount("/", routes![index]) 68 | } 69 | -------------------------------------------------------------------------------- /server/src/postgres.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | 3 | use rocket::{ 4 | fairing::{self, Fairing}, 5 | Build, Rocket, 6 | }; 7 | use sqlx::PgPool; 8 | 9 | /// Creates and attaches a Postgres connection pool to the global &State in 10 | /// Rocket. 11 | /// 12 | /// FIXME: Replace with built-in Rocket DB pools. 13 | #[derive(Debug, Default)] 14 | pub struct Postgres; 15 | 16 | #[rocket::async_trait] 17 | impl Fairing for Postgres { 18 | fn info(&self) -> fairing::Info { 19 | fairing::Info { 20 | name: "Postgres", 21 | kind: fairing::Kind::Ignite, 22 | } 23 | } 24 | 25 | async fn on_ignite(&self, rocket: Rocket) -> fairing::Result { 26 | let url = env::var("DATABASE_URL").expect("DATABASE_URL must be set"); 27 | let pool = PgPool::connect(url.as_str()) 28 | .await 29 | .expect("Failed to connect to PostgreSQL database."); 30 | 31 | Ok(rocket.manage(pool)) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /update.sh: -------------------------------------------------------------------------------- 1 | echo "Updating client dependencies..." 2 | cd ./client 3 | pnpm upgrade &> /dev/null 4 | 5 | echo "Updating server dependencies..." 6 | cd ../server 7 | cargo update --quiet 8 | 9 | echo "All up to date! ✨" --------------------------------------------------------------------------------