Self-hosted, federated location sharing app and server that prioritizes user privacy and security
end-to-end-encryption location-sharing privacy self-hosted federated

Compare changes

Choose any two refs to compare.

+26 -2
.gitignore
··· 1 - node_modules/ 2 output/ 3 - .idea/ 4 server/target
··· 1 + # custom 2 output/ 3 server/target 4 + 5 + # Logs 6 + logs 7 + *.log 8 + npm-debug.log* 9 + yarn-debug.log* 10 + yarn-error.log* 11 + pnpm-debug.log* 12 + lerna-debug.log* 13 + 14 + node_modules 15 + dist 16 + dist-ssr 17 + *.local 18 + 19 + # Editor directories and files 20 + .vscode/* 21 + !.vscode/extensions.json 22 + .idea 23 + .DS_Store 24 + *.suo 25 + *.ntvs* 26 + *.njsproj 27 + *.sln 28 + *.sw?
+29 -19
app/.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 dist 12 - dist-ssr 13 - *.local 14 15 - # Editor directories and files 16 - .vscode/* 17 - !.vscode/extensions.json 18 .idea 19 .DS_Store 20 - *.suo 21 - *.ntvs* 22 - *.njsproj 23 - *.sln 24 - *.sw?
··· 1 + # dependencies (bun install) 2 node_modules 3 + 4 + # output 5 + out 6 dist 7 + *.tgz 8 9 + # code coverage 10 + coverage 11 + *.lcov 12 + 13 + # logs 14 + logs 15 + _.log 16 + report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json 17 + 18 + # dotenv environment variable files 19 + .env 20 + .env.development.local 21 + .env.test.local 22 + .env.production.local 23 + .env.local 24 + 25 + # caches 26 + .eslintcache 27 + .cache 28 + *.tsbuildinfo 29 + 30 + # IntelliJ based IDEs 31 .idea 32 + 33 + # Finder (MacOS) folder config 34 .DS_Store
+15
app/README.md
···
··· 1 + # privacypin 2 + 3 + To install dependencies: 4 + 5 + ```bash 6 + bun install 7 + ``` 8 + 9 + To run: 10 + 11 + ```bash 12 + bun run 13 + ``` 14 + 15 + This project was created using `bun init` in bun v1.3.3. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
+10
app/bun.lock
··· 1 { 2 "lockfileVersion": 1, 3 "workspaces": { 4 "": { 5 "name": "privacypin", ··· 12 "devDependencies": { 13 "@tauri-apps/cli": "^2", 14 "@types/alpinejs": "^3.13.11", 15 "typescript": "~5.6.2", 16 "vite": "^6.0.3", 17 }, ··· 146 147 "@types/alpinejs": ["@types/alpinejs@3.13.11", "", {}, "sha512-3KhGkDixCPiLdL3Z/ok1GxHwLxEWqQOKJccgaQL01wc0EVM2tCTaqlC3NIedmxAXkVzt/V6VTM8qPgnOHKJ1MA=="], 148 149 "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 150 151 "@vue/reactivity": ["@vue/reactivity@3.1.5", "", { "dependencies": { "@vue/shared": "3.1.5" } }, "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg=="], 152 153 "@vue/shared": ["@vue/shared@3.1.5", "", {}, "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="], 154 155 "alpinejs": ["alpinejs@3.15.1", "", { "dependencies": { "@vue/reactivity": "~3.1.1" } }, "sha512-HLO1TtiE92VajFHtLLPK8BWaK1YepV/uj31UrfoGnQ00lyFOJZ+oVY3F0DghPAwvg8sLU79pmjGQSytERa2gEg=="], 156 157 "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], 158 ··· 175 "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], 176 177 "typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], 178 179 "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], 180 }
··· 1 { 2 "lockfileVersion": 1, 3 + "configVersion": 0, 4 "workspaces": { 5 "": { 6 "name": "privacypin", ··· 13 "devDependencies": { 14 "@tauri-apps/cli": "^2", 15 "@types/alpinejs": "^3.13.11", 16 + "@types/bun": "latest", 17 "typescript": "~5.6.2", 18 "vite": "^6.0.3", 19 }, ··· 148 149 "@types/alpinejs": ["@types/alpinejs@3.13.11", "", {}, "sha512-3KhGkDixCPiLdL3Z/ok1GxHwLxEWqQOKJccgaQL01wc0EVM2tCTaqlC3NIedmxAXkVzt/V6VTM8qPgnOHKJ1MA=="], 150 151 + "@types/bun": ["@types/bun@1.3.5", "", { "dependencies": { "bun-types": "1.3.5" } }, "sha512-RnygCqNrd3srIPEWBd5LFeUYG7plCoH2Yw9WaZGyNmdTEei+gWaHqydbaIRkIkcbXwhBT94q78QljxN0Sk838w=="], 152 + 153 "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], 154 155 + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], 156 + 157 "@vue/reactivity": ["@vue/reactivity@3.1.5", "", { "dependencies": { "@vue/shared": "3.1.5" } }, "sha512-1tdfLmNjWG6t/CsPldh+foumYFo3cpyCHgBYQ34ylaMsJ+SNHQ1kApMIa8jN+i593zQuaw3AdWH0nJTARzCFhg=="], 158 159 "@vue/shared": ["@vue/shared@3.1.5", "", {}, "sha512-oJ4F3TnvpXaQwZJNF3ZK+kLPHKarDmJjJ6jyzVNDKH9md1dptjC7lWR//jrGuLdek/U6iltWxqAnYOu8gCiOvA=="], 160 161 "alpinejs": ["alpinejs@3.15.1", "", { "dependencies": { "@vue/reactivity": "~3.1.1" } }, "sha512-HLO1TtiE92VajFHtLLPK8BWaK1YepV/uj31UrfoGnQ00lyFOJZ+oVY3F0DghPAwvg8sLU79pmjGQSytERa2gEg=="], 162 + 163 + "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], 164 165 "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], 166 ··· 183 "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], 184 185 "typescript": ["typescript@5.6.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw=="], 186 + 187 + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], 188 189 "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], 190 }
-58
app/index copy.html
··· 1 - <!doctype html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8" /> 5 - <title>Position Share โ€“ Signup</title> 6 - <script type="module" src="/src/main.ts"></script> 7 - <link rel="stylesheet" href="/src/styles.css" /> 8 - </head> 9 - 10 - <body> 11 - <div class="card"> 12 - <div class="header"> 13 - <div class="icon-circle"> 14 - <img src="./src/assets/pin.svg" alt="Pin Icon" /> 15 - </div> 16 - <h1>Position Share</h1> 17 - <p>Connect with your server to start sharing</p> 18 - </div> 19 - 20 - <!-- x-data connects this element to the signupState Alpine component, enabling its data (serverAddress and signupKey) and functions (signup and scanQR) to work within it :) --> 21 - <div class="actions" x-data="signupState"> 22 - <div> 23 - <label for="server">Server Address</label> 24 - <input id="server" type="url" placeholder="https://your-server.com" x-model="serverAddress" required /> 25 - </div> 26 - 27 - <div> 28 - <label for="key">Signup Key</label> 29 - <input id="key" type="password" placeholder="Enter your signup key" x-model="signupKey" required /> 30 - </div> 31 - 32 - <p class="hint">Scan a QR code to automatically fill both server address and signup key</p> 33 - <button type="button" class="btn-qr" @click="scanQR"> 34 - <img src="./src/assets/qr.svg" alt="QR Icon" /> 35 - Scan QR Code 36 - </button> 37 - 38 - <button class="btn-primary" @click="signup">Connect</button> 39 - </div> 40 - </div> 41 - 42 - <script> 43 - function signupState() { 44 - return { 45 - serverAddress: "", 46 - signupKey: "", 47 - // we have the functions within a bigger function because this is how we can access the variables we define (by using this.variableWeWant) 48 - signup() { 49 - alert(this.serverAddress); 50 - }, 51 - scanQR() { 52 - alert(this.signupKey); 53 - }, 54 - }; 55 - } 56 - </script> 57 - </body> 58 - </html>
···
-61
app/index.html
··· 1 - <!doctype html> 2 - <html lang="en"> 3 - <head> 4 - <meta charset="UTF-8" /> 5 - <script type="module"> 6 - import Alpine from "alpinejs"; 7 - window.Alpine = Alpine; 8 - Alpine.start(); 9 - </script> 10 - <link rel="stylesheet" href="/src/styles.css" /> 11 - </head> 12 - 13 - <body> 14 - <div class="card"> 15 - <div class="header"> 16 - <div class="icon-circle"> 17 - <img src="./src/assets/pin.svg" alt="Pin Icon" /> 18 - </div> 19 - <h1>Position Share</h1> 20 - <p>Connect with your server to start sharing</p> 21 - </div> 22 - 23 - <!-- x-data connects this element to the signupState Alpine component, enabling its data (serverAddress and signupKey) and functions (signup and scanQR) to work within it :) --> 24 - <div class="actions" x-data="signupState"> 25 - <div> 26 - <label for="server">Server Address</label> 27 - <input id="server" type="url" placeholder="https://your-server.com" x-model="serverAddress" required /> 28 - </div> 29 - 30 - <div> 31 - <label for="key">Signup Key</label> 32 - <input id="key" type="password" placeholder="Enter your signup key" x-model="signupKey" required /> 33 - </div> 34 - 35 - <p class="hint">Scan a QR code to automatically fill both server address and signup key</p> 36 - <button type="button" class="btn-qr" @click="scanQR"> 37 - <img src="./src/assets/qr.svg" alt="QR Icon" /> 38 - Scan QR Code 39 - </button> 40 - 41 - <button class="btn-primary" @click="signup">Connect</button> 42 - </div> 43 - </div> 44 - 45 - <script> 46 - function signupState() { 47 - return { 48 - serverAddress: "", 49 - signupKey: "", 50 - // we have the functions within a bigger function because this is how we can access the variables we define (by using this.variableWeWant) 51 - signup() { 52 - alert(this.serverAddress); 53 - }, 54 - scanQR() { 55 - alert(this.signupKey); 56 - }, 57 - }; 58 - } 59 - </script> 60 - </body> 61 - </html>
···
+23 -22
app/package.json
··· 1 { 2 - "name": "privacypin", 3 - "private": true, 4 - "version": "0.1.0", 5 - "type": "module", 6 - "scripts": { 7 - "dev": "vite", 8 - "build": "tsc && vite build", 9 - "preview": "vite preview", 10 - "tauri": "WEBKIT_DISABLE_DMABUF_RENDERER=1 tauri" 11 - }, 12 - "dependencies": { 13 - "@tauri-apps/api": "^2", 14 - "@tauri-apps/plugin-opener": "^2", 15 - "@tauri-apps/plugin-store": "^2.4.1", 16 - "alpinejs": "^3.15.1" 17 - }, 18 - "devDependencies": { 19 - "@tauri-apps/cli": "^2", 20 - "@types/alpinejs": "^3.13.11", 21 - "typescript": "~5.6.2", 22 - "vite": "^6.0.3" 23 - } 24 }
··· 1 { 2 + "name": "privacypin", 3 + "private": true, 4 + "version": "0.1.0", 5 + "type": "module", 6 + "scripts": { 7 + "dev": "vite", 8 + "build": "tsc && vite build", 9 + "preview": "vite preview", 10 + "tauri": "WEBKIT_DISABLE_DMABUF_RENDERER=1 tauri" 11 + }, 12 + "dependencies": { 13 + "@tauri-apps/api": "^2", 14 + "@tauri-apps/plugin-opener": "^2", 15 + "@tauri-apps/plugin-store": "^2.4.1", 16 + "alpinejs": "^3.15.1" 17 + }, 18 + "devDependencies": { 19 + "@tauri-apps/cli": "^2", 20 + "@types/alpinejs": "^3.13.11", 21 + "typescript": "~5.6.2", 22 + "vite": "^6.0.3", 23 + "@types/bun": "latest" 24 + } 25 }
-69
app/src/api.ts
··· 1 - import { Store } from "./store.ts"; 2 - 3 - // TODO: test if this is still needed: 4 - // Don't mind this piece of code, it's a polyfill until chromium decides to merge it (it's been so long) 5 - // @ts-ignore 6 - // Uint8Array.prototype.toBase64 = function () { 7 - // let binary = ""; 8 - // for (let i = 0; i < this.length; i++) { 9 - // const byte = this[i]; 10 - // if (byte !== undefined) { 11 - // binary += String.fromCharCode(byte); 12 - // } 13 - // } 14 - // return btoa(binary); 15 - // }; 16 - 17 - export async function createAccount(signup_key: string): Promise<{ user_id: string; is_admin: boolean }> { 18 - try { 19 - const server_url = await Store.get("server_url"); 20 - 21 - const response = await fetch(server_url + "/create-account", { 22 - method: "POST", 23 - body: signup_key, 24 - }); 25 - 26 - if (!response.ok) { 27 - throw new Error(`${await response.text()}`); 28 - } 29 - return await response.json(); 30 - } catch (err) { 31 - alert(`${err}`); 32 - throw err; 33 - } 34 - } 35 - 36 - export async function post(endpoint: string, data: Object | string | undefined): Promise<any> { 37 - try { 38 - const user_id = await Store.get("user_id"); 39 - const server_url = await Store.get("server_url"); 40 - 41 - const headers: Record<string, string> = { 42 - "x-auth": JSON.stringify({ user_id }), 43 - }; 44 - 45 - let stringified_data: string | undefined; 46 - 47 - if (typeof data === "object") { 48 - stringified_data = JSON.stringify(data); 49 - headers["Content-Type"] = "application/json"; 50 - } else { 51 - stringified_data = data; 52 - } 53 - 54 - const res = await fetch(`${server_url}/${endpoint}`, { 55 - method: "POST", 56 - headers, 57 - body: stringified_data, 58 - }); 59 - 60 - if (!res.ok) { 61 - throw new Error(`${await res.text()}`); 62 - } 63 - 64 - return await res.text(); 65 - } catch (err) { 66 - alert(`${err}`); 67 - throw err; 68 - } 69 - }
···
+6
app/src/assets/three-dots.svg
···
··· 1 + <?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> 2 + <svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> 3 + <path d="M8 12C9.10457 12 10 12.8954 10 14C10 15.1046 9.10457 16 8 16C6.89543 16 6 15.1046 6 14C6 12.8954 6.89543 12 8 12Z" fill="#000000"/> 4 + <path d="M8 6C9.10457 6 10 6.89543 10 8C10 9.10457 9.10457 10 8 10C6.89543 10 6 9.10457 6 8C6 6.89543 6.89543 6 8 6Z" fill="#000000"/> 5 + <path d="M10 2C10 0.89543 9.10457 -4.82823e-08 8 0C6.89543 4.82823e-08 6 0.895431 6 2C6 3.10457 6.89543 4 8 4C9.10457 4 10 3.10457 10 2Z" fill="#000000"/> 6 + </svg>
+128
app/src/home-page/home.css
···
··· 1 + body { 2 + font-family: sans-serif; 3 + background: #f9fafb; 4 + margin: 0; 5 + display: flex; 6 + justify-content: center; 7 + } 8 + 9 + .app { 10 + width: 100%; 11 + background: #f9fafb; 12 + } 13 + 14 + header { 15 + background: #fff; 16 + border-bottom: 1px solid #e5e7eb; 17 + padding: 0.75rem 1rem; 18 + display: flex; 19 + justify-content: space-between; 20 + } 21 + 22 + header h1 { 23 + font-size: 1rem; 24 + margin: 0; 25 + display: flex; 26 + align-items: center; 27 + } 28 + 29 + header .icon-btn { 30 + border: none; 31 + background: none; 32 + cursor: pointer; 33 + padding: 0.4rem; 34 + } 35 + 36 + .status { 37 + background: #fff; 38 + border-bottom: 1px solid #f3f4f6; 39 + padding: 0.75rem 1rem; 40 + display: flex; 41 + justify-content: space-between; 42 + align-items: center; 43 + font-size: 0.9rem; 44 + } 45 + 46 + .content { 47 + flex: 1; 48 + overflow-y: auto; 49 + padding: 1rem; 50 + } 51 + 52 + .friends-header { 53 + display: flex; 54 + align-items: center; 55 + gap: 0.5rem; 56 + margin-bottom: 1rem; 57 + } 58 + 59 + .friend-card { 60 + background: #fff; 61 + border: 1px solid #e5e7eb; 62 + border-radius: 8px; 63 + padding: 1rem; 64 + margin-bottom: 0.75rem; 65 + display: flex; 66 + justify-content: space-between; 67 + align-items: center; 68 + } 69 + 70 + .friend-actions { 71 + display: flex; 72 + align-items: center; 73 + gap: 0.1rem; 74 + } 75 + 76 + .friend-actions .view-btn { 77 + cursor: pointer; 78 + border-radius: 6px; 79 + border: 1px solid #d1d5db; 80 + background: #fff; 81 + padding: 0.3rem 0.5rem; 82 + font-size: 0.8rem; 83 + } 84 + 85 + .friend-actions .view-btn:hover { 86 + background: #f3f4f6; 87 + } 88 + 89 + .menu-icon { 90 + width: 16px; 91 + height: 16px; 92 + margin: 0; 93 + } 94 + 95 + .friend-actions { 96 + cursor: pointer; 97 + font-size: 1.2rem; 98 + color: #6b7280; 99 + padding: 0 0.3rem; 100 + user-select: none; 101 + } 102 + 103 + .friend-actions { 104 + color: #111827; 105 + } 106 + 107 + .empty-state { 108 + text-align: center; 109 + padding: 3rem 1rem; 110 + color: #6b7280; 111 + } 112 + 113 + .empty-state button { 114 + margin-top: 0.75rem; 115 + padding: 0.5rem 1rem; 116 + border: none; 117 + border-radius: 6px; 118 + background: #3b82f6; 119 + color: white; 120 + cursor: pointer; 121 + } 122 + 123 + .friend-actions button img { 124 + width: 16px; 125 + height: 16px; 126 + vertical-align: middle; 127 + margin-right: 4px; 128 + }
+98
app/src/home-page/home.html
···
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <script type="module" src="./home.ts"></script> 6 + <link rel="stylesheet" href="./home.css" /> 7 + </head> 8 + 9 + <body> 10 + <div class="app" x-data="homePageState"> 11 + <!-- Header --> 12 + <header> 13 + <h1>PrivacyPin</h1> 14 + <div> 15 + <!-- somehow the "+" emoji does not display in the code for me, but it's temporary anyways --> 16 + <button class="icon-btn" @click="addFriend()">โž•</button> 17 + <button class="icon-btn" @click="goto('settings')"> 18 + โš™๏ธ 19 + </button> 20 + </div> 21 + </header> 22 + 23 + <!-- Status --> 24 + <div class="status"> 25 + <span>Last ping sent:</span> 26 + <!-- later, figure out how to update this cleanly when we will have the actual data --> 27 + <span x-text="timeAgo()"></span> 28 + </div> 29 + 30 + <!-- Friends --> 31 + <div class="content"> 32 + <div class="friends-header"> 33 + <h2 style="font-size: 1rem; margin: 0">Friends</h2> 34 + <span style="color: #6b7280; font-size: 0.9rem" 35 + >(<span x-text="friends.length"></span>)</span 36 + > 37 + </div> 38 + 39 + <template x-if="friends.length > 0"> 40 + <div> 41 + <template x-for="friend in friends" :key="friend.id"> 42 + <div class="friend-card"> 43 + <strong x-text="friend.name"></strong> 44 + <div class="friend-actions"> 45 + <button 46 + class="view-btn" 47 + @click="viewLocation(friend.id)" 48 + > 49 + <img 50 + src="/src/assets/pin.svg" 51 + alt="Pin Icon" 52 + />View 53 + </button> 54 + <span 55 + class="menu-icon" 56 + @click="friendOptions(friend.id)" 57 + ></span> 58 + <img 59 + class="menu-icon" 60 + src="/src/assets/three-dots.svg" 61 + alt="Pin Icon" 62 + /> 63 + </div> 64 + </div> 65 + </template> 66 + </div> 67 + </template> 68 + 69 + <template x-if="friends.length === 0"> 70 + <div class="empty-state"> 71 + <div style="font-size: 2rem">๐Ÿ‘ฅ</div> 72 + <h3>No friends yet</h3> 73 + <p>Add friends to start sharing locations</p> 74 + <button @click="addFriend()">Add Friend</button> 75 + </div> 76 + </template> 77 + </div> 78 + 79 + <!-- Admin --> 80 + <div x-if="isAdmin()"> 81 + <div class="content" style="text-align: center"> 82 + <h4>Admin Controls:</h4> 83 + <div style="display: flex; justify-content: center"> 84 + <button @click="generateSignupKey()"> 85 + Generate signup key 86 + </button> 87 + </div> 88 + <div 89 + style="display: flex; justify-content: center" 90 + x-show="newSignupKey != ''" 91 + > 92 + <p>New signup key: <a x-text="newSignupKey"></a></p> 93 + </div> 94 + </div> 95 + </div> 96 + </div> 97 + </body> 98 + </html>
+49
app/src/home-page/home.ts
···
··· 1 + import Alpine from "alpinejs"; 2 + import { Store } from "../utils/store.ts"; 3 + import { post } from "../utils/api.ts"; 4 + 5 + Alpine.data("homePageState", () => ({ 6 + friends: [ 7 + { id: "123", name: "Alice Johnson" }, 8 + { id: "456", name: "Bob Smith" }, 9 + { id: "789", name: "Carol Davis" }, 10 + ], 11 + newSignupKey: "", 12 + 13 + timeAgo() { 14 + return "2m ago"; 15 + }, 16 + 17 + viewLocation(friend_id: string) { 18 + alert(`Opening the location for the friend with id ${friend_id}`); 19 + }, 20 + 21 + friendOptions(friend_id: string) { 22 + alert(`Options for friend id ${friend_id}`); 23 + }, 24 + 25 + addFriend() { 26 + alert("Add friend functionality would open here"); 27 + }, 28 + 29 + openSettings() { 30 + alert("Settings would open here"); 31 + }, 32 + 33 + async generateSignupKey() { 34 + const res = await post("generate-signup-key", undefined); 35 + this.newSignupKey = res; 36 + console.log(res); 37 + }, 38 + 39 + isAdmin() { 40 + return Store.get("is_admin"); 41 + }, 42 + 43 + goto(newLocation: string) { 44 + window.location.href = 45 + "/src/" + newLocation + "-page/" + newLocation + ".html"; 46 + }, 47 + })); 48 + 49 + Alpine.start();
-1
app/src/main.ts
··· 1 - // idk empty for now, might use later
···
+116
app/src/settings-page/settings.css
···
··· 1 + body { 2 + font-family: system-ui, sans-serif; 3 + background: #f9fafb; 4 + display: flex; 5 + align-items: center; 6 + justify-content: center; 7 + height: 100vh; 8 + margin: 0; 9 + } 10 + 11 + .card { 12 + max-width: 90%; 13 + background: white; 14 + border: 1px solid #d1d5db; 15 + border-radius: 8px; 16 + padding: 1.5rem; 17 + box-sizing: border-box; 18 + } 19 + 20 + .header { 21 + text-align: center; 22 + margin-bottom: 1.5rem; 23 + } 24 + 25 + .icon-circle { 26 + width: 64px; 27 + height: 64px; 28 + background: #dbeafe; 29 + border-radius: 50%; 30 + display: flex; 31 + align-items: center; 32 + justify-content: center; 33 + margin: 0 auto 1rem; 34 + } 35 + 36 + .icon-circle img { 37 + width: 32px; 38 + height: 32px; 39 + } 40 + 41 + h1 { 42 + font-size: 1.5rem; 43 + } 44 + 45 + p { 46 + font-size: 0.9rem; 47 + color: #6b7280; 48 + } 49 + 50 + .actions { 51 + display: flex; 52 + flex-direction: column; 53 + gap: 1rem; 54 + } 55 + 56 + label { 57 + display: block; 58 + font-size: 0.85rem; 59 + font-weight: 600; 60 + margin-bottom: 0.25rem; 61 + } 62 + 63 + input { 64 + width: 100%; 65 + padding: 0.5rem 0.75rem; 66 + border: 1px solid #d1d5db; 67 + border-radius: 4px; 68 + font-size: 0.95rem; 69 + box-sizing: border-box; 70 + } 71 + 72 + input:focus { 73 + outline: none; 74 + border-color: #2563eb; 75 + } 76 + 77 + button { 78 + width: 100%; 79 + padding: 0.6rem; 80 + font-size: 0.95rem; 81 + border-radius: 4px; 82 + cursor: pointer; 83 + /*transition: background 0.2s ease;*/ 84 + display: flex; 85 + align-items: center; 86 + justify-content: center; 87 + } 88 + 89 + .btn-primary { 90 + background: #2563eb; 91 + color: white; 92 + border: none; 93 + } 94 + 95 + .btn-primary:hover { 96 + background: #1d4ed8; 97 + } 98 + 99 + .btn-qr { 100 + background: white; 101 + gap: 0.5rem; 102 + border: 1px solid #d1d5db; 103 + } 104 + 105 + .btn-qr:hover { 106 + background: #f3f4f6; 107 + } 108 + 109 + .btn-qr img { 110 + width: 16px; 111 + height: 16px; 112 + } 113 + 114 + .hint { 115 + font-size: 0.75rem; 116 + }
+26
app/src/settings-page/settings.html
···
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <script type="module" src="./settings.ts"></script> 6 + <link rel="stylesheet" href="./settings.css" /> 7 + </head> 8 + 9 + <body> 10 + <div class="card"> 11 + <!-- x-data connects this element to the settingsPageState Alpine component, enabling its data (serverAddress and signupKey) and functions (signup and scanQR) to work within it :) --> 12 + <!-- TODO: make this a form instead? --> 13 + <div class="actions" x-data="settingsPageState"> 14 + <h3>Settings</h3> 15 + 16 + <button class="btn-secondary" @click="goto('home')"> 17 + Back to Home 18 + </button> 19 + 20 + <button class="btn-secondary" @click="resetStore()"> 21 + Signout 22 + </button> 23 + </div> 24 + </div> 25 + </body> 26 + </html>
+16
app/src/settings-page/settings.ts
···
··· 1 + import Alpine from "alpinejs"; 2 + import { Store } from "../utils/store.ts"; 3 + import { goto } from "../utils/tools.ts"; 4 + 5 + Alpine.data("settingsPageState", () => ({ 6 + resetStore() { 7 + Store.reset(); 8 + alert("Store reset"); 9 + goto("signup"); 10 + }, 11 + goto(newLocation: string) { 12 + goto(newLocation); 13 + }, 14 + })); 15 + 16 + Alpine.start();
+116
app/src/signup-page/signup.css
···
··· 1 + body { 2 + font-family: system-ui, sans-serif; 3 + background: #f9fafb; 4 + display: flex; 5 + align-items: center; 6 + justify-content: center; 7 + height: 100vh; 8 + margin: 0; 9 + } 10 + 11 + .card { 12 + max-width: 90%; 13 + background: white; 14 + border: 1px solid #d1d5db; 15 + border-radius: 8px; 16 + padding: 1.5rem; 17 + box-sizing: border-box; 18 + } 19 + 20 + .header { 21 + text-align: center; 22 + margin-bottom: 1.5rem; 23 + } 24 + 25 + .icon-circle { 26 + width: 64px; 27 + height: 64px; 28 + background: #dbeafe; 29 + border-radius: 50%; 30 + display: flex; 31 + align-items: center; 32 + justify-content: center; 33 + margin: 0 auto 1rem; 34 + } 35 + 36 + .icon-circle img { 37 + width: 32px; 38 + height: 32px; 39 + } 40 + 41 + h1 { 42 + font-size: 1.5rem; 43 + } 44 + 45 + p { 46 + font-size: 0.9rem; 47 + color: #6b7280; 48 + } 49 + 50 + .actions { 51 + display: flex; 52 + flex-direction: column; 53 + gap: 1rem; 54 + } 55 + 56 + label { 57 + display: block; 58 + font-size: 0.85rem; 59 + font-weight: 600; 60 + margin-bottom: 0.25rem; 61 + } 62 + 63 + input { 64 + width: 100%; 65 + padding: 0.5rem 0.75rem; 66 + border: 1px solid #d1d5db; 67 + border-radius: 4px; 68 + font-size: 0.95rem; 69 + box-sizing: border-box; 70 + } 71 + 72 + input:focus { 73 + outline: none; 74 + border-color: #2563eb; 75 + } 76 + 77 + button { 78 + width: 100%; 79 + padding: 0.6rem; 80 + font-size: 0.95rem; 81 + border-radius: 4px; 82 + cursor: pointer; 83 + /*transition: background 0.2s ease;*/ 84 + display: flex; 85 + align-items: center; 86 + justify-content: center; 87 + } 88 + 89 + .btn-primary { 90 + background: #2563eb; 91 + color: white; 92 + border: none; 93 + } 94 + 95 + .btn-primary:hover { 96 + background: #1d4ed8; 97 + } 98 + 99 + .btn-qr { 100 + background: white; 101 + gap: 0.5rem; 102 + border: 1px solid #d1d5db; 103 + } 104 + 105 + .btn-qr:hover { 106 + background: #f3f4f6; 107 + } 108 + 109 + .btn-qr img { 110 + width: 16px; 111 + height: 16px; 112 + } 113 + 114 + .hint { 115 + font-size: 0.75rem; 116 + }
+42
app/src/signup-page/signup.html
···
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <script type="module" src="./signup.ts"></script> 6 + <link rel="stylesheet" href="./signup.css" /> 7 + </head> 8 + 9 + <body> 10 + <div class="card"> 11 + <div class="header"> 12 + <div class="icon-circle"> 13 + <img src="/src/assets/pin.svg" alt="Pin Icon" /> 14 + </div> 15 + <h1>PrivacyPin</h1> 16 + <p>Connect with a server to start sharing</p> 17 + </div> 18 + 19 + <!-- x-data connects this element to the signupPageState Alpine component, enabling its data (serverAddress and signupKey) and functions (signup and scanQR) to work within it :) --> 20 + <!-- TODO: make this a form instead? --> 21 + <div class="actions" x-data="signupPageState"> 22 + <div> 23 + <label for="server">Server Address</label> 24 + <input id="server" type="url" placeholder="https://your-server.com" x-model="serverAddress" required /> 25 + </div> 26 + 27 + <div> 28 + <label for="key">Signup Key</label> 29 + <input id="key" type="password" placeholder="Enter your signup key" x-model="signupKey" required /> 30 + </div> 31 + 32 + <p class="hint">Scan a QR code to automatically fill both server address and signup key</p> 33 + <button type="button" x-bind:disabled="isDoingStuff" class="btn-qr" @click="await scanQR()"> 34 + <img src="/src/assets/qr.svg" alt="QR Icon" /> 35 + Scan QR Code 36 + </button> 37 + 38 + <button class="btn-primary" x-bind:disabled="isDoingStuff" @click="await signup()"><span x-show="isDoingStuff">Connecting...</span> <span x-show="!isDoingStuff">Connect</span></button> 39 + </div> 40 + </div> 41 + </body> 42 + </html>
+32
app/src/signup-page/signup.ts
···
··· 1 + import Alpine from "alpinejs"; 2 + import { createAccount } from "../utils/api.ts"; 3 + import { Store } from "../utils/store.ts"; 4 + 5 + Alpine.data("signupPageState", () => ({ 6 + serverAddress: "", 7 + signupKey: "", 8 + isDoingStuff: false, 9 + 10 + async signup() { 11 + this.isDoingStuff = true; 12 + await new Promise((resolve) => setTimeout(resolve, 2000)); // temp 13 + try { 14 + const res = await createAccount(this.serverAddress, this.signupKey); 15 + Store.set("is_admin", res.is_admin); 16 + Store.set("user_id", res.user_id); 17 + window.location.href = "/src/home-page/home.html"; 18 + } catch (e) { 19 + this.isDoingStuff = false; 20 + } 21 + }, 22 + 23 + async scanQR() { 24 + this.isDoingStuff = true; 25 + await new Promise((resolve) => setTimeout(resolve, 1000)); 26 + this.serverAddress = "http://127.0.0.1:3000"; 27 + this.signupKey = "dummy signup key"; 28 + this.isDoingStuff = false; 29 + }, 30 + })); 31 + 32 + Alpine.start();
-33
app/src/store.ts
··· 1 - import { Store as TauriStore } from "@tauri-apps/plugin-store"; 2 - 3 - type Settings = { 4 - server_url: string; 5 - user_id: string; 6 - private_key: JsonWebKey; 7 - friends: ClientFriend[]; 8 - is_admin: boolean; 9 - }; 10 - 11 - export const Store = { 12 - async get<T extends keyof Settings>(key: T): Promise<Settings[T]> { 13 - const store = await TauriStore.load("settings.json"); 14 - const value = await store.get<Settings[T]>(key); 15 - if (value === undefined) { 16 - alert(`Key ${key} not found in store`); 17 - throw new Error(`Key ${key} not found in store`); 18 - } 19 - return value; 20 - }, 21 - 22 - async set<T extends keyof Settings>(key: T, value: Settings[T]): Promise<void> { 23 - const store = await TauriStore.load("settings.json"); 24 - await store.set(key, value); 25 - await store.save(); 26 - }, 27 - 28 - async isLoggedIn(): Promise<boolean> { 29 - const store = await TauriStore.load("settings.json"); 30 - const user_id = await store.get<string>("user_id"); 31 - return user_id !== undefined; 32 - }, 33 - };
···
-124
app/src/styles.css
··· 1 - body { 2 - font-family: system-ui, sans-serif; 3 - background: #f9fafb; 4 - display: flex; 5 - align-items: center; 6 - justify-content: center; 7 - height: 100vh; 8 - margin: 0; 9 - } 10 - 11 - .card { 12 - width: 100%; 13 - max-width: 360px; 14 - background: white; 15 - border: 1px solid #d1d5db; 16 - border-radius: 8px; 17 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08); 18 - padding: 1.5rem; 19 - box-sizing: border-box; 20 - } 21 - 22 - .header { 23 - text-align: center; 24 - margin-bottom: 1.5rem; 25 - } 26 - 27 - .icon-circle { 28 - width: 64px; 29 - height: 64px; 30 - background: #dbeafe; 31 - border-radius: 50%; 32 - display: flex; 33 - align-items: center; 34 - justify-content: center; 35 - margin: 0 auto 1rem; 36 - } 37 - 38 - .icon-circle img { 39 - width: 32px; 40 - height: 32px; 41 - } 42 - 43 - h1 { 44 - font-size: 1.5rem; 45 - margin: 0; 46 - font-weight: 700; 47 - } 48 - 49 - p { 50 - font-size: 0.9rem; 51 - color: #6b7280; 52 - } 53 - 54 - .actions { 55 - display: flex; 56 - flex-direction: column; 57 - gap: 1rem; 58 - } 59 - 60 - label { 61 - display: block; 62 - font-size: 0.85rem; 63 - font-weight: 600; 64 - margin-bottom: 0.25rem; 65 - } 66 - 67 - input { 68 - width: 100%; 69 - padding: 0.5rem 0.75rem; 70 - border: 1px solid #d1d5db; 71 - border-radius: 4px; 72 - font-size: 0.95rem; 73 - box-sizing: border-box; 74 - } 75 - 76 - input:focus { 77 - outline: none; 78 - border-color: #2563eb; 79 - box-shadow: 0 0 0 2px rgba(37, 99, 235, 0.2); 80 - } 81 - 82 - button { 83 - width: 100%; 84 - padding: 0.6rem; 85 - font-size: 0.95rem; 86 - border-radius: 4px; 87 - cursor: pointer; 88 - transition: background 0.2s ease; 89 - display: flex; 90 - align-items: center; 91 - justify-content: center; 92 - gap: 0.5rem; 93 - } 94 - 95 - .btn-primary { 96 - background: #2563eb; 97 - color: white; 98 - border: none; 99 - } 100 - 101 - .btn-primary:hover { 102 - background: #1d4ed8; 103 - } 104 - 105 - .btn-qr { 106 - background: white; 107 - border: 1px solid #d1d5db; 108 - color: #374151; 109 - } 110 - 111 - .btn-qr:hover { 112 - background: #f3f4f6; 113 - } 114 - 115 - .btn-qr img { 116 - width: 16px; 117 - height: 16px; 118 - } 119 - 120 - .hint { 121 - font-size: 0.75rem; 122 - color: #6b7280; 123 - margin-top: 0.25rem; 124 - }
···
+102
app/src/utils/api.ts
···
··· 1 + import { Store } from "./store.ts"; 2 + 3 + function bufToBase64(buf: ArrayBuffer): string { 4 + return btoa(String.fromCharCode(...new Uint8Array(buf))); 5 + } 6 + 7 + function strToBytes(str: string): Uint8Array { 8 + return new TextEncoder().encode(str); 9 + } 10 + 11 + export async function createAccount( 12 + server_url: string, 13 + signup_key: string, 14 + ): Promise<{ user_id: string; is_admin: boolean }> { 15 + try { 16 + await Store.set("server_url", server_url); 17 + const keyPair = await crypto.subtle.generateKey("Ed25519", true, [ 18 + "sign", 19 + "verify", 20 + ]); 21 + const pubKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey); 22 + const privKeyRaw = await crypto.subtle.exportKey( 23 + "pkcs8", 24 + keyPair.privateKey, 25 + ); 26 + const pub_key_b64 = bufToBase64(pubKeyRaw); 27 + 28 + const response = await fetch(server_url + "/create-account", { 29 + method: "POST", 30 + headers: { "Content-Type": "application/json" }, 31 + body: JSON.stringify({ signup_key, pub_key_b64 }), 32 + }); 33 + 34 + if (!response.ok) throw new Error(await response.text()); 35 + const json = await response.json(); 36 + 37 + await Store.set("user_id", json.user_id); 38 + await Store.set("priv_key", bufToBase64(privKeyRaw)); 39 + 40 + return json; 41 + } catch (err) { 42 + alert(`${err}`); 43 + throw err; 44 + } 45 + } 46 + 47 + export async function post( 48 + endpoint: string, 49 + data: object | string | undefined, 50 + ): Promise<any> { 51 + try { 52 + const user_id = await Store.get("user_id"); 53 + const server_url = await Store.get("server_url"); 54 + console.log(`Exhibit B: ${server_url}`); 55 + const privKey_b64 = await Store.get("priv_key"); 56 + 57 + if (!user_id || !privKey_b64) throw new Error("Missing user credentials"); 58 + 59 + // Prepare request body bytes 60 + let bodyStr = ""; 61 + if (typeof data === "object") bodyStr = JSON.stringify(data); 62 + else if (typeof data === "string") bodyStr = data; 63 + const bodyBytes = strToBytes(bodyStr); 64 + 65 + // Import private key and sign 66 + const privKeyBytes = Uint8Array.from(atob(privKey_b64), (c) => 67 + c.charCodeAt(0), 68 + ); 69 + 70 + const privKey = await crypto.subtle.importKey( 71 + "pkcs8", 72 + privKeyBytes.buffer, 73 + { name: "Ed25519" }, 74 + false, 75 + ["sign"], 76 + ); 77 + 78 + const signature = await crypto.subtle.sign("Ed25519", privKey, bodyBytes); 79 + const signature_b64 = bufToBase64(signature); 80 + 81 + // Encode header JSON to base64 82 + const authJson = JSON.stringify({ user_id, signature: signature_b64 }); 83 + const authHeader = btoa(authJson); 84 + 85 + const headers: Record<string, string> = { 86 + "x-auth": authHeader, 87 + }; 88 + if (typeof data === "object") headers["Content-Type"] = "application/json"; 89 + 90 + const res = await fetch(`${server_url}/${endpoint}`, { 91 + method: "POST", 92 + headers, 93 + body: bodyStr.length > 0 ? bodyStr : undefined, 94 + }); 95 + 96 + if (!res.ok) throw new Error(await res.text()); 97 + return await res.text(); 98 + } catch (err) { 99 + alert(`${err}`); 100 + throw err; 101 + } 102 + }
+40
app/src/utils/store.ts
···
··· 1 + import { Store as TauriStore } from "@tauri-apps/plugin-store"; 2 + 3 + type Settings = { 4 + server_url: string; 5 + user_id: string; 6 + friends: { name: string; id: string }[]; 7 + is_admin: boolean; 8 + priv_key: string; 9 + }; 10 + 11 + export const Store = { 12 + async get<T extends keyof Settings>(key: T): Promise<Settings[T]> { 13 + const store = await TauriStore.load("settings.json"); 14 + const value = await store.get<Settings[T]>(key); 15 + if (value === undefined) { 16 + alert(`Key ${key} not found in store`); 17 + throw new Error(`Key ${key} not found in store`); 18 + } 19 + return value; 20 + }, 21 + 22 + async set<T extends keyof Settings>(key: T, value: Settings[T]): Promise<void> { 23 + const store = await TauriStore.load("settings.json"); 24 + await store.set(key, value); 25 + await store.save(); 26 + }, 27 + 28 + async isLoggedIn(): Promise<boolean> { 29 + const store = await TauriStore.load("settings.json"); 30 + const user_id = await store.get<string>("user_id"); 31 + return user_id !== undefined; 32 + }, 33 + 34 + // FOR TESTING ONLY 35 + async reset(): Promise<void> { 36 + const store = await TauriStore.load("settings.json"); 37 + await store.reset(); 38 + await store.save(); 39 + }, 40 + };
+48
app/src/utils/tools.ts
···
··· 1 + export function goto(newLocation: string) { 2 + window.location.href = 3 + "/src/" + newLocation + "-page/" + newLocation + ".html"; 4 + } 5 + 6 + /* 7 + 8 + Use this type of function to toggle dark mode. It CAN be modified to your needs. copy the function, and fix the end comment(be sure to put this in the alpine section) 9 + 10 + toggleDarkMode() { 11 + /* 12 + This toggles darkmode for 'body' in the css file | use for only document types 13 + document.body.classList.toggle("dark-theme"); 14 + 15 + this toggles darkmode for '.app' in the css file | use if it isn't a document type 16 + toggleStyle("app", "dark-theme"); 17 + 18 + * / 19 + 20 + document.body.classList.toggle("dark-theme"); 21 + toggleStyle("header", "dark-theme"); 22 + toggleStyle([".app", ".friend-card", ".content"], "dark-theme"); 23 + }, 24 + */ 25 + 26 + export function toggleStyle(classNames: string | string[], newClass: string) { 27 + if (typeof classNames === "string") { 28 + for ( 29 + let i = 0; 30 + i < document.getElementsByClassName(classNames).length; 31 + i++ 32 + ) { 33 + document.getElementsByClassName(classNames)[i].classList.toggle(newClass); 34 + } 35 + } else { 36 + for (let i = 0; i < classNames.length; i++) { 37 + for ( 38 + let j = 0; 39 + j < document.getElementsByClassName(classNames[i]).length; 40 + j++ 41 + ) { 42 + document 43 + .getElementsByClassName(classNames[i]) 44 + [j].classList.toggle(newClass); 45 + } 46 + } 47 + } 48 + }
+5 -8
app/src-tauri/capabilities/default.json
··· 1 { 2 - "$schema": "../gen/schemas/desktop-schema.json", 3 - "identifier": "default", 4 - "description": "Capability for the main window", 5 - "windows": ["main"], 6 - "permissions": [ 7 - "core:default", 8 - "opener:default" 9 - ] 10 }
··· 1 { 2 + "$schema": "../gen/schemas/desktop-schema.json", 3 + "identifier": "default", 4 + "description": "Capability for the main window", 5 + "windows": ["main"], 6 + "permissions": ["core:default", "opener:default", "store:default"] 7 }
+27 -1
app/src-tauri/src/lib.rs
··· 4 format!("Hello, {}! You've been greeted from Rust!", name) 5 } 6 7 #[cfg_attr(mobile, tauri::mobile_entry_point)] 8 pub fn run() { 9 tauri::Builder::default() 10 - .plugin(tauri_plugin_opener::init()) 11 .invoke_handler(tauri::generate_handler![greet]) 12 .run(tauri::generate_context!()) 13 .expect("error while running tauri application");
··· 4 format!("Hello, {}! You've been greeted from Rust!", name) 5 } 6 7 + use std::path::PathBuf; 8 + use tauri::{WebviewUrl, WebviewWindowBuilder}; 9 + use tauri_plugin_store::StoreBuilder; 10 + 11 #[cfg_attr(mobile, tauri::mobile_entry_point)] 12 pub fn run() { 13 tauri::Builder::default() 14 + .setup(|app| { 15 + let app_handle = app.handle(); 16 + let store_path = PathBuf::from("settings.json"); 17 + let store = StoreBuilder::new(app_handle, store_path).build()?; 18 + 19 + let user_id = store.get("user_id"); 20 + 21 + let page = if user_id.is_some() { 22 + "/src/home-page/home.html" 23 + } else { 24 + "/src/signup-page/signup.html" 25 + }; 26 + 27 + // create and open window directly at the correct page 28 + WebviewWindowBuilder::new(app_handle, "main", WebviewUrl::App(page.into())) 29 + .title("privacypin") 30 + .inner_size(412.0, 715.0) 31 + .resizable(false) 32 + .build()?; 33 + 34 + Ok(()) 35 + }) 36 + .plugin(tauri_plugin_store::Builder::default().build()) 37 .invoke_handler(tauri::generate_handler![greet]) 38 .run(tauri::generate_context!()) 39 .expect("error while running tauri application");
+1 -8
app/src-tauri/tauri.conf.json
··· 11 }, 12 "app": { 13 "withGlobalTauri": true, 14 - "windows": [ 15 - { 16 - "title": "privacypin", 17 - "width": 412, 18 - "height": 915, 19 - "resizable": false 20 - } 21 - ], 22 "security": { 23 "csp": null 24 }
··· 11 }, 12 "app": { 13 "withGlobalTauri": true, 14 + "windows": [], 15 "security": { 16 "csp": null 17 }
-6
app/tauri.svg
··· 1 - <svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg"> 2 - <path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/> 3 - <ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/> 4 - <path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/> 5 - <path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/> 6 - </svg>
···
+7 -1
app/tsconfig.json
··· 17 "strict": true, 18 "noUnusedLocals": true, 19 "noUnusedParameters": true, 20 - "noFallthroughCasesInSwitch": true 21 }, 22 "include": ["src"] 23 }
··· 17 "strict": true, 18 "noUnusedLocals": true, 19 "noUnusedParameters": true, 20 + "noFallthroughCasesInSwitch": true, 21 + 22 + // TODO: does that break stuff? 23 + "baseUrl": "./src", 24 + "paths": { 25 + "@utils/*": ["utils/*"] 26 + } 27 }, 28 "include": ["src"] 29 }
-25
app/typescript.svg
··· 1 - <?xml version="1.0" standalone="no"?> 2 - <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 20010904//EN" 3 - "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd"> 4 - <svg version="1.0" xmlns="http://www.w3.org/2000/svg" 5 - width="512.000000pt" height="512.000000pt" viewBox="0 0 512.000000 512.000000" 6 - preserveAspectRatio="xMidYMid meet"> 7 - 8 - <g transform="translate(0.000000,512.000000) scale(0.100000,-0.100000)" 9 - fill="#2D79C7" stroke="none"> 10 - <path d="M430 5109 c-130 -19 -248 -88 -325 -191 -53 -71 -83 -147 -96 -247 11 - -6 -49 -9 -813 -7 -2166 l3 -2090 22 -65 c54 -159 170 -273 328 -323 l70 -22 12 - 2140 0 2140 0 66 23 c160 55 272 169 322 327 l22 70 0 2135 0 2135 -22 70 13 - c-49 157 -155 265 -319 327 l-59 23 -2115 1 c-1163 1 -2140 -2 -2170 -7z 14 - m3931 -2383 c48 -9 120 -26 160 -39 l74 -23 3 -237 c1 -130 0 -237 -2 -237 -3 15 - 0 -26 14 -53 30 -61 38 -197 84 -310 106 -110 20 -293 15 -368 -12 -111 -39 16 - -175 -110 -175 -193 0 -110 97 -197 335 -300 140 -61 309 -146 375 -189 30 17 - -20 87 -68 126 -107 119 -117 164 -234 164 -426 0 -310 -145 -518 -430 -613 18 - -131 -43 -248 -59 -445 -60 -243 -1 -405 24 -577 90 l-68 26 0 242 c0 175 -3 19 - 245 -12 254 -9 9 -9 12 0 12 7 0 12 -4 12 -9 0 -17 139 -102 223 -138 136 -57 20 - 233 -77 382 -76 145 0 224 19 295 68 75 52 100 156 59 242 -41 84 -135 148 21 - -374 253 -367 161 -522 300 -581 520 -23 86 -23 253 -1 337 73 275 312 448 22 - 682 492 109 13 401 6 506 -13z m-1391 -241 l0 -205 -320 0 -320 0 0 -915 0 23 - -915 -255 0 -255 0 0 915 0 915 -320 0 -320 0 0 205 0 205 895 0 895 0 0 -205z"/> 24 - </g> 25 - </svg>
···
+15
server/Cargo.lock
··· 542 "serde", 543 "serde_json", 544 "tokio", 545 ] 546 547 [[package]] ··· 753 "tower-layer", 754 "tower-service", 755 "tracing", 756 ] 757 758 [[package]]
··· 542 "serde", 543 "serde_json", 544 "tokio", 545 + "tower-http", 546 ] 547 548 [[package]] ··· 754 "tower-layer", 755 "tower-service", 756 "tracing", 757 + ] 758 + 759 + [[package]] 760 + name = "tower-http" 761 + version = "0.6.6" 762 + source = "registry+https://github.com/rust-lang/crates.io-index" 763 + checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" 764 + dependencies = [ 765 + "bitflags", 766 + "bytes", 767 + "http", 768 + "pin-project-lite", 769 + "tower-layer", 770 + "tower-service", 771 ] 772 773 [[package]]
+1
server/Cargo.toml
··· 11 serde = { version = "1.0.228", features = ["derive"] } 12 serde_json = "1.0.145" 13 tokio = { version = "1.48.0", features = ["full"] }
··· 11 serde = { version = "1.0.228", features = ["derive"] } 12 serde_json = "1.0.145" 13 tokio = { version = "1.48.0", features = ["full"] } 14 + tower-http = {version="0.6.6", features=["cors"]}
+6 -20
server/src/main.rs
··· 12 use ed25519_dalek::Signature; 13 use nanoid::nanoid; 14 use serde::Deserialize; 15 - use tokio::sync::Mutex; 16 - 17 use std::collections::HashSet; 18 19 mod handlers; 20 mod types; ··· 24 25 #[tokio::main] 26 async fn main() { 27 - // initialize tracing 28 - // tracing_subscriber::fmt::init(); 29 - 30 // TODO: should this be inside an Arc? 31 let state = AppState { 32 users: Arc::new(Mutex::new(Vec::new())), ··· 41 // Until we have disk saves, always generate a admin signup key since there will be no admin set at launch 42 let admin_signup_key = nanoid!(5); 43 println!("Admin signup key: {admin_signup_key}"); 44 state.signup_keys.lock().await.insert(admin_signup_key); 45 46 // build our application with a route ··· 57 .route("/send-pings", post(send_pings)) 58 .route("/get-pings", post(get_pings)) 59 .with_state(state.clone()) 60 - .layer(axum::middleware::from_fn_with_state(state, auth_test)); 61 62 - // run our app with hyper, listening globally on port 3000 63 let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); 64 axum::serve(listener, app).await.unwrap(); 65 } ··· 74 let mut req = Request::from_parts(parts, new_body); 75 // CURSED STUFF END 76 77 - // let auth_header = match req.headers().get("x-auth").and_then(|v| v.to_str().ok()) { 78 - // Some(h) => h, 79 - // None => todo!("header issues"), 80 - // }; 81 - // println!("Headers before from_str: {auth_header}"); 82 - 83 - // let auth_data: Auth = match serde_json::from_str(&auth_header) { 84 - // Ok(v) => v, 85 - // Err(_) => todo!("parsing json issues"), 86 - // }; 87 let auth_header = req 88 .headers() 89 .get("x-auth") ··· 120 if let Err(err) = verifying_key.verify_strict(&body_bytes, &signature) { 121 panic!("Signature verification failed: {err}"); 122 } 123 - // println!("Signature verified!"); 124 125 //////////////////////////////////// 126 //////////////////////////////////// ··· 142 return next.run(req).await; 143 } 144 145 - // For now, only user_id for identification 146 #[derive(Debug, Deserialize, Clone)] 147 struct Auth { 148 user_id: String, 149 - signature: String, // base 64 150 }
··· 12 use ed25519_dalek::Signature; 13 use nanoid::nanoid; 14 use serde::Deserialize; 15 use std::collections::HashSet; 16 + use tokio::sync::Mutex; 17 + use tower_http::cors::{Any, CorsLayer}; 18 19 mod handlers; 20 mod types; ··· 24 25 #[tokio::main] 26 async fn main() { 27 // TODO: should this be inside an Arc? 28 let state = AppState { 29 users: Arc::new(Mutex::new(Vec::new())), ··· 38 // Until we have disk saves, always generate a admin signup key since there will be no admin set at launch 39 let admin_signup_key = nanoid!(5); 40 println!("Admin signup key: {admin_signup_key}"); 41 + println!("http://127.0.0.1:3000"); 42 state.signup_keys.lock().await.insert(admin_signup_key); 43 44 // build our application with a route ··· 55 .route("/send-pings", post(send_pings)) 56 .route("/get-pings", post(get_pings)) 57 .with_state(state.clone()) 58 + .layer(axum::middleware::from_fn_with_state(state, auth_test)) 59 + .layer(CorsLayer::permissive()); 60 61 let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap(); 62 axum::serve(listener, app).await.unwrap(); 63 } ··· 72 let mut req = Request::from_parts(parts, new_body); 73 // CURSED STUFF END 74 75 let auth_header = req 76 .headers() 77 .get("x-auth") ··· 108 if let Err(err) = verifying_key.verify_strict(&body_bytes, &signature) { 109 panic!("Signature verification failed: {err}"); 110 } 111 112 //////////////////////////////////// 113 //////////////////////////////////// ··· 129 return next.run(req).await; 130 } 131 132 #[derive(Debug, Deserialize, Clone)] 133 struct Auth { 134 user_id: String, 135 + signature: String, 136 }
+3 -10
server/test/autils.ts
··· 1 import { expect } from "bun:test"; 2 3 - export const URL = "http://localhost:3000"; 4 5 - export async function generateUser( 6 - signup_key: string | undefined, 7 - should_be_admin: boolean = false, 8 - ): Promise<string> { 9 if (signup_key === undefined) { 10 throw new Error("signup_key was not provided or captured from server output"); 11 } ··· 24 return json.user_id; 25 } 26 27 - export async function post( 28 - endpoint: string, 29 - user_id: string, 30 - data: Object | string | undefined, 31 - ): Promise<any> { 32 const headers: Record<string, string> = { 33 "x-auth": JSON.stringify({ user_id }), 34 };
··· 1 import { expect } from "bun:test"; 2 3 + export const URL = "http://127.0.0.1:3000"; 4 5 + export async function generateUser(signup_key: string | undefined, should_be_admin: boolean = false): Promise<string> { 6 if (signup_key === undefined) { 7 throw new Error("signup_key was not provided or captured from server output"); 8 } ··· 21 return json.user_id; 22 } 23 24 + export async function post(endpoint: string, user_id: string, data: Object | string | undefined): Promise<any> { 25 const headers: Record<string, string> = { 26 "x-auth": JSON.stringify({ user_id }), 27 };
+12 -2
server/test/test.test.ts
··· 1 import { SERVER_DIR, startOrRestartServer, stopServer } from "./srv.ts"; 2 import { rm } from "node:fs/promises"; 3 - import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test"; 4 import { generateUser, post, URL } from "./utils.ts"; 5 6 console.log(`SERVER_DIR: ${SERVER_DIR}`); ··· 47 48 const res3 = await post("get-pings", user, admin.user_id); 49 50 - expect(JSON.parse(res3)).toEqual(["this is definitely encrypted trust #2", "this is definitely encrypted trust #1"]); 51 }); 52 });
··· 1 import { SERVER_DIR, startOrRestartServer, stopServer } from "./srv.ts"; 2 import { rm } from "node:fs/promises"; 3 + import { 4 + describe, 5 + test, 6 + expect, 7 + beforeAll, 8 + afterAll, 9 + beforeEach, 10 + } from "bun:test"; 11 import { generateUser, post, URL } from "./utils.ts"; 12 13 console.log(`SERVER_DIR: ${SERVER_DIR}`); ··· 54 55 const res3 = await post("get-pings", user, admin.user_id); 56 57 + expect(JSON.parse(res3)).toEqual([ 58 + "this is definitely encrypted trust #2", 59 + "this is definitely encrypted trust #1", 60 + ]); 61 }); 62 });
+1 -1
server/test/utils.ts
··· 1 import { expect } from "bun:test"; 2 3 - export const URL = "http://localhost:3000"; 4 5 // Generate an Ed25519 keypair and register it with the server. 6 export async function generateUser(signup_key: string | undefined, should_be_admin: boolean = false): Promise<{ user_id: string; pubKey: Uint8Array; privKey: Uint8Array }> {
··· 1 import { expect } from "bun:test"; 2 3 + export const URL = "http://127.0.0.1:3000"; 4 5 // Generate an Ed25519 keypair and register it with the server. 6 export async function generateUser(signup_key: string | undefined, should_be_admin: boolean = false): Promise<{ user_id: string; pubKey: Uint8Array; privKey: Uint8Array }> {