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

Configure Feed

Select the types of activity you want to include in your feed.

added 'add friend' feature

azomDev a3e3bc52 678794cf

+353 -63
+1 -2
app/package.json
··· 6 6 "scripts": { 7 7 "dev": "vite", 8 8 "build": "tsc && vite build", 9 - "preview": "vite preview", 10 - "tauri": "WEBKIT_DISABLE_DMABUF_RENDERER=1 tauri" 9 + "preview": "vite preview" 11 10 }, 12 11 "dependencies": { 13 12 "@tauri-apps/api": "^2.9.0",
+91
app/src/add-friend-page/add-friend.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 + min-height: 100vh; 13 + } 14 + 15 + header { 16 + background: #fff; 17 + border-bottom: 1px solid #e5e7eb; 18 + padding: 0.75rem 1rem; 19 + display: flex; 20 + justify-content: space-between; 21 + align-items: center; 22 + } 23 + 24 + header h1 { 25 + font-size: 1rem; 26 + margin: 0; 27 + } 28 + 29 + header .icon-btn { 30 + border: none; 31 + background: none; 32 + cursor: pointer; 33 + padding: 0.4rem; 34 + font-size: 1rem; 35 + } 36 + 37 + .content { 38 + padding: 1rem; 39 + display: grid; 40 + gap: 0.75rem; 41 + } 42 + 43 + .card { 44 + background: #fff; 45 + border: 1px solid #e5e7eb; 46 + border-radius: 8px; 47 + padding: 1rem; 48 + } 49 + 50 + .row { 51 + display: flex; 52 + justify-content: space-between; 53 + gap: 1rem; 54 + align-items: center; 55 + } 56 + 57 + label { 58 + display: block; 59 + margin-top: 0.5rem; 60 + margin-bottom: 0.25rem; 61 + font-size: 0.9rem; 62 + color: #374151; 63 + } 64 + 65 + input { 66 + width: 100%; 67 + box-sizing: border-box; 68 + padding: 0.55rem 0.6rem; 69 + border-radius: 8px; 70 + border: 1px solid #d1d5db; 71 + outline: none; 72 + } 73 + 74 + input:focus { 75 + border-color: #3b82f6; 76 + } 77 + 78 + button.primary { 79 + margin-top: 0.75rem; 80 + width: 100%; 81 + padding: 0.6rem 0.75rem; 82 + border: none; 83 + border-radius: 8px; 84 + background: #3b82f6; 85 + color: white; 86 + cursor: pointer; 87 + } 88 + 89 + button.primary:hover { 90 + background: #2563eb; 91 + }
+65
app/src/add-friend-page/add-friend.html
··· 1 + <!doctype html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8" /> 5 + <script type="module" src="./add-friend.ts"></script> 6 + <link rel="stylesheet" href="./add-friend.css" /> 7 + </head> 8 + 9 + <body> 10 + <div class="app" x-data="addFriendPageState"> 11 + <header> 12 + <button class="icon-btn" @click="goBack()">←</button> 13 + <h1>Add Friend</h1> 14 + <span style="width: 32px"></span> 15 + </header> 16 + 17 + <div class="content"> 18 + <div class="card"> 19 + <div class="row"> 20 + <span>Your user id</span> 21 + <strong x-text="my_user_id"></strong> 22 + </div> 23 + <div class="row"> 24 + <span>Your key</span> 25 + <strong x-text="my_rand_key"></strong> 26 + </div> 27 + </div> 28 + 29 + <div class="card"> 30 + <label for="friendUserId">Friend user id</label> 31 + <input 32 + id="friendUserId" 33 + type="text" 34 + x-model="friend_id" 35 + placeholder="Enter their user id" 36 + /> 37 + 38 + <label for="friendKey">Friend key</label> 39 + <input 40 + id="friendKey" 41 + type="text" 42 + x-model="friend_rand_key" 43 + placeholder="Enter their key" 44 + /> 45 + 46 + <label for="friendName">Friend Name</label> 47 + <input 48 + id="friendName" 49 + type="text" 50 + x-model="friend_name" 51 + placeholder="Enter their name" 52 + /> 53 + 54 + <button 55 + class="primary" 56 + x-bind:disabled="is_doing_stuff" 57 + @click="sendFriendRequest()" 58 + > 59 + Send friend request 60 + </button> 61 + </div> 62 + </div> 63 + </div> 64 + </body> 65 + </html>
+66 -1
app/src/add-friend-page/add-friend.ts
··· 12 12 // 13 13 // to start, we'll have a static AES-GCM friend key that never rotates 14 14 15 - // we first need to merge ui with auth tho 15 + import Alpine from "alpinejs"; 16 + import { goto } from "../utils/tools.ts"; 17 + import { Store } from "../utils/store.ts"; 18 + import * as api from "../utils/api.ts"; 19 + 20 + // temp until we have qr codes and actual crng 21 + function generateKey(len = 5): string { 22 + return Math.random() 23 + .toString(36) 24 + .slice(2, 2 + len) 25 + .toUpperCase(); 26 + } 27 + 28 + Alpine.data("addFriendPageState", () => ({ 29 + my_user_id: "", 30 + my_rand_key: generateKey(5), 31 + 32 + friend_id: "", 33 + friend_rand_key: "", 34 + friend_name: "", 35 + is_doing_stuff: false, 36 + 37 + async init() { 38 + this.my_user_id = await Store.get("user_id"); 39 + }, 40 + 41 + goBack() { 42 + goto("home"); 43 + }, 44 + 45 + async sendFriendRequest() { 46 + this.is_doing_stuff = true; 47 + try { 48 + api.requestFriendRequest(this.friend_id); 49 + const timeoutMs = 60_000; 50 + const intervalMs = 1_000; 51 + const start = Date.now(); 52 + let friend_request_accepted = false; 53 + 54 + while (Date.now() - start < timeoutMs && friend_request_accepted === false) { 55 + friend_request_accepted = await api.isFriendRequestAccepted(this.friend_id); 56 + await new Promise((r) => setTimeout(r, intervalMs)); 57 + } 58 + 59 + if (friend_request_accepted) { 60 + const friends = await Store.get("friends"); 61 + friends.push({ name: this.friend_name, id: this.friend_id }); 62 + await Store.set("friends", friends); 63 + } 64 + 65 + // even if it is false, we should have a longer thing that runs well after 66 + // we are sure that the request was deleted to be absolutely sure they did not accept it 67 + // without us knowing 68 + // TODO: to fix this, later, add an api like IsMyFriendRequestStillActive 69 + // that checks if a friend request with a single person is still stored. If not, 70 + // we can forget about it, if it is still there, we keep in storage the fact that it 71 + // is possible we get an accepted friend request after. We can check like on each app startup or whatnot 72 + } catch (e) { 73 + const err = e instanceof Error ? e.message : e; 74 + alert(`Sign-up failed: ${err}`); 75 + } 76 + this.is_doing_stuff = false; 77 + }, 78 + })); 79 + 80 + Alpine.start();
+8 -30
app/src/home-page/home.html
··· 37 37 <div class="content"> 38 38 <div class="friends-header"> 39 39 <h2 style="font-size: 1rem; margin: 0">Friends</h2> 40 - <span style="color: #6b7280; font-size: 0.9rem" 41 - >(<span x-text="friends.length"></span>)</span 42 - > 40 + <span style="color: #6b7280; font-size: 0.9rem">(<span x-text="friends.length"></span>)</span> 43 41 </div> 44 - 42 + <!--TODO idk why but sometimes when i launch the app I see the stuff when you have no friends for a split second before I see them appear, even tho we get them in init --> 45 43 <template x-if="friends.length > 0"> 46 44 <div> 47 45 <template x-for="friend in friends" :key="friend.id"> 48 46 <div class="friend-card"> 49 47 <strong x-text="friend.name"></strong> 50 48 <div class="friend-actions"> 51 - <button 52 - class="view-btn" 53 - @click="viewLocation(friend.id)" 54 - > 55 - <img 56 - src="/src/assets/pin.svg" 57 - alt="Pin Icon" 58 - />View 59 - </button> 60 - <span 61 - class="menu-icon" 62 - @click="friendOptions(friend.id)" 63 - ></span> 64 - <img 65 - class="menu-icon" 66 - src="/src/assets/three-dots.svg" 67 - alt="Pin Icon" 68 - /> 49 + <button class="view-btn" @click="viewLocation(friend.id)"><img src="/src/assets/pin.svg" alt="Pin Icon" />View</button> 50 + <span class="menu-icon" @click="friendOptions(friend.id)"></span> 51 + <img class="menu-icon" src="/src/assets/three-dots.svg" alt="Pin Icon" /> 69 52 </div> 70 53 </div> 71 54 </template> ··· 83 66 </div> 84 67 85 68 <!-- Admin --> 86 - <div x-if="isAdmin()"> 69 + <div x-show="is_admin"> 87 70 <div class="content" style="text-align: center"> 88 71 <h4>Admin Controls:</h4> 89 72 <div style="display: flex; justify-content: center"> 90 - <button @click="generateSignupKey()"> 91 - Generate signup key 92 - </button> 73 + <button @click="generateSignupKey()">Generate signup key</button> 93 74 </div> 94 - <div 95 - style="display: flex; justify-content: center" 96 - x-show="newSignupKey != ''" 97 - > 75 + <div style="display: flex; justify-content: center" x-show="newSignupKey != ''"> 98 76 <p>New signup key: <a x-text="newSignupKey"></a></p> 99 77 </div> 100 78 </div>
+13 -11
app/src/home-page/home.ts
··· 4 4 import * as api from "../utils/api.ts"; 5 5 6 6 Alpine.data("homePageState", () => ({ 7 - friends: [ 8 - { id: "123", name: "Alice Johnson" }, 9 - { id: "456", name: "Bob Smith" }, 10 - { id: "789", name: "Carol Davis" }, 11 - ], 7 + is_admin: false, 8 + friends: [] as { name: string; id: string }[], 9 + 10 + async init() { 11 + this.is_admin = await Store.get("is_admin"); 12 + this.friends = await Store.get("friends"); 13 + }, 14 + 12 15 newSignupKey: "", 13 16 14 17 timeAgo() { ··· 24 27 }, 25 28 26 29 async updateServer() { 27 - await api.sendPings("123", "3.14159N 3.14159W"); 30 + // temporarly, just use the friend id of the first in the list 31 + const friends = await Store.get("friends"); 32 + 33 + await api.sendPings(friends[0].id, "3.14159N 3.14159W"); 28 34 }, 29 35 30 36 addFriend() { 31 - alert("Add friend functionality would open here"); 37 + goto("add-friend"); 32 38 }, 33 39 34 40 openSettings() { ··· 37 43 38 44 async generateSignupKey() { 39 45 this.newSignupKey = await api.generateSignupKey(); 40 - }, 41 - 42 - isAdmin() { 43 - return Store.get("is_admin"); 44 46 }, 45 47 })); 46 48
+1 -2
app/src/signup-page/signup.ts
··· 8 8 9 9 async signup() { 10 10 this.isDoingStuff = true; 11 - await new Promise((resolve) => setTimeout(resolve, 1000)); // temp 12 11 try { 13 12 await createAccount(this.serverAddress, this.signupKey); 14 13 window.location.href = "/src/home-page/home.html"; ··· 21 20 22 21 async scanQR() { 23 22 this.isDoingStuff = true; 24 - await new Promise((resolve) => setTimeout(resolve, 1000)); 23 + await new Promise((resolve) => setTimeout(resolve, 100)); // temp to simulate async 25 24 this.serverAddress = "http://127.0.0.1:3000"; 26 25 this.signupKey = "dummy signup key"; 27 26 this.isDoingStuff = false;
+8 -2
app/src/utils/api.ts
··· 7 7 /** 8 8 * This function can throw an error 9 9 */ 10 - export async function createAccount(server_url: string, signup_key: string): Promise<{ user_id: string; is_admin: boolean }> { 10 + export async function createAccount( 11 + server_url: string, 12 + signup_key: string, 13 + ): Promise<{ user_id: string; is_admin: boolean }> { 11 14 const keyPair = await crypto.subtle.generateKey("Ed25519", true, ["sign", "verify"]); 12 15 const pubKeyRaw = await crypto.subtle.exportKey("raw", keyPair.publicKey); 13 16 const privKeyRaw = await crypto.subtle.exportKey("pkcs8", keyPair.privateKey); ··· 28 31 await Store.set("user_id", json.user_id); 29 32 await Store.set("is_admin", json.is_admin); 30 33 await Store.set("priv_key", bufToBase64(privKeyRaw)); 34 + await Store.set("friends", []); 31 35 32 36 return json; 33 37 } ··· 68 72 69 73 const privKeyBytes = Uint8Array.fromBase64(privKey_b64); 70 74 71 - const privKey = await crypto.subtle.importKey("pkcs8", privKeyBytes.buffer, "Ed25519", false, ["sign"]); 75 + const privKey = await crypto.subtle.importKey("pkcs8", privKeyBytes.buffer, "Ed25519", false, [ 76 + "sign", 77 + ]); 72 78 73 79 const signature = await crypto.subtle.sign("Ed25519", privKey, bodyBytes); 74 80 const signature_b64 = bufToBase64(signature);
+60
justfile
··· 1 + set shell := ["bash", "-cu"] 2 + 3 + APP_DIR := "app" 4 + SERVER_DIR := "server" 5 + 6 + alias srv := server 7 + alias s := server 8 + alias a := app 9 + 10 + default: 11 + @just --list 12 + 13 + server: 14 + cd "{{SERVER_DIR}}" && cargo run 15 + 16 + app: 17 + cd "{{APP_DIR}}" && WEBKIT_DISABLE_DMABUF_RENDERER=1 bun run tauri dev 18 + 19 + vite: 20 + cd "{{APP_DIR}}" && bun run vite 21 + 22 + duo: 23 + #!/usr/bin/env bash 24 + set -euo pipefail 25 + 26 + VITE_HOST="127.0.0.1" 27 + VITE_PORT="5173" 28 + 29 + mkdir -p /tmp/ppA /tmp/ppB 30 + 31 + VITE_PID="" 32 + TAURI_A_PID="" 33 + TAURI_B_PID="" 34 + 35 + cleanup() { 36 + for pid in "$TAURI_A_PID" "$TAURI_B_PID" "$VITE_PID"; do 37 + [[ -n "$pid" ]] && kill "$pid" 2>/dev/null || true 38 + done 39 + } 40 + trap cleanup INT TERM EXIT 41 + 42 + # Start Vite 43 + (cd app && bun run vite) & 44 + VITE_PID=$! 45 + 46 + # Start Tauri instance A 47 + (cd app/src-tauri && \ 48 + WEBKIT_DISABLE_DMABUF_RENDERER=1 \ 49 + XDG_DATA_HOME=/tmp/ppA \ 50 + cargo run) & 51 + TAURI_A_PID=$! 52 + 53 + # Start Tauri instance B 54 + (cd app/src-tauri && \ 55 + WEBKIT_DISABLE_DMABUF_RENDERER=1 \ 56 + XDG_DATA_HOME=/tmp/ppB \ 57 + cargo run) & 58 + TAURI_B_PID=$! 59 + 60 + wait
+14 -8
server/src/handlers.rs
··· 12 12 let key_used = { state.signup_keys.lock().await.remove(&payload.signup_key) }; 13 13 14 14 if !key_used { 15 - ReqBail!("Signup key was not there"); 15 + ReqBail!("Invalid signup key"); 16 16 } 17 17 18 18 // todo check ··· 66 66 } 67 67 68 68 let mut friend_requests = state.friend_requests.lock().await; 69 - let link = Link::new(friend_id, user_id); 69 + let mut friend_request_link = DirectedFriendRequestLink { 70 + sender_id: friend_id, 71 + accepter_id: user_id, 72 + }; 70 73 71 74 // if we remove sucessfully the link, it means a request already existed 72 75 // so we are making the friendship official 73 - let friend_request_accepted = friend_requests.remove(&link); 76 + let friend_request_accepted = friend_requests.remove(&friend_request_link); 77 + 74 78 if friend_request_accepted { 75 79 drop(friend_requests); 76 80 77 81 let mut pings_state = state.positions.lock().await; 82 + let link = UndirectedLink::from(friend_request_link); 78 83 pings_state.insert(link.clone(), RingBuffer::new(state.ring_buffer_cap)); 79 84 drop(pings_state); 80 85 ··· 82 87 links.insert(link); 83 88 drop(links); 84 89 } else { 85 - friend_requests.insert(link); 90 + friend_request_link.swap_direction(); 91 + friend_requests.insert(friend_request_link); 86 92 drop(friend_requests); 87 93 } 88 94 ··· 94 100 Extension(user_id): Extension<String>, 95 101 friend_id: String, 96 102 ) -> Result<PlainBool, SrvErr> { 97 - let link = Link::new(friend_id, user_id); 103 + let link = UndirectedLink::new(friend_id, user_id); 98 104 let links = state.links.lock().await; 99 105 let accepted = links.contains(&link); 100 106 return Ok(PlainBool(accepted)); ··· 107 113 ) -> Result<(), SrvErr> { 108 114 let links = state.links.lock().await; 109 115 for ping in &pings { 110 - let link = Link::new(user_id.clone(), ping.receiver_id.clone()); 116 + let link = UndirectedLink::new(user_id.clone(), ping.receiver_id.clone()); 111 117 if !links.contains(&link) { 112 118 ReqBail!("Ping receiver is not linked to sender"); 113 119 } ··· 117 123 let mut pings_state = state.positions.lock().await; 118 124 119 125 for ping in pings { 120 - let link = Link::new(user_id.clone(), ping.receiver_id.clone()); 126 + let link = UndirectedLink::new(user_id.clone(), ping.receiver_id.clone()); 121 127 pings_state 122 128 .get_mut(&link) 123 129 .unwrap() ··· 132 138 Extension(user_id): Extension<String>, 133 139 sender_id: String, 134 140 ) -> Result<EncryptedPingVec, SrvErr> { 135 - let link = Link::new(user_id, sender_id); 141 + let link = UndirectedLink::new(user_id, sender_id); 136 142 let links = state.links.lock().await; 137 143 138 144 if !links.contains(&link) {
+26 -7
server/src/types.rs
··· 15 15 pub struct AppState { 16 16 pub users: Arc<Mutex<Vec<User>>>, 17 17 pub signup_keys: Arc<Mutex<HashSet<String>>>, 18 - pub friend_requests: Arc<Mutex<HashSet<Link>>>, 19 - pub links: Arc<Mutex<HashSet<Link>>>, 20 - pub positions: Arc<Mutex<HashMap<Link, RingBuffer>>>, 18 + pub friend_requests: Arc<Mutex<HashSet<DirectedFriendRequestLink>>>, 19 + pub links: Arc<Mutex<HashSet<UndirectedLink>>>, 20 + pub positions: Arc<Mutex<HashMap<UndirectedLink, RingBuffer>>>, 21 21 pub admin_id: Arc<Mutex<Option<String>>>, 22 22 pub ring_buffer_cap: usize, 23 23 } ··· 88 88 } 89 89 90 90 #[derive(Debug, Clone, PartialEq, Eq, Hash)] 91 - pub struct Link(String, String); 91 + pub struct UndirectedLink(String, String); 92 92 93 - impl Link { 93 + impl UndirectedLink { 94 94 pub fn new(a: String, b: String) -> Self { 95 - if a < b { Link(a, b) } else { Link(b, a) } // normalize order 95 + if a < b { 96 + UndirectedLink(a, b) 97 + } else { 98 + UndirectedLink(b, a) 99 + } // normalize order 100 + } 101 + 102 + pub fn from(dfrl: DirectedFriendRequestLink) -> Self { 103 + return UndirectedLink::new(dfrl.accepter_id, dfrl.sender_id); 104 + } 105 + } 106 + 107 + #[derive(Debug, PartialEq, Eq, Hash)] 108 + pub struct DirectedFriendRequestLink { 109 + pub sender_id: String, 110 + pub accepter_id: String, 111 + } 112 + 113 + impl DirectedFriendRequestLink { 114 + pub fn swap_direction(&mut self) { 115 + std::mem::swap(&mut self.sender_id, &mut self.accepter_id); 96 116 } 97 117 } 98 118 ··· 133 153 134 154 impl IntoResponse for SrvErr { 135 155 fn into_response(self) -> Response { 136 - // Log once here (this runs only for real errors) 137 156 match &self.cause { 138 157 Some(c) => eprintln!("[ERR] {} | cause: {}", self.msg, c), 139 158 None => eprintln!("[ERR] {}", self.msg),