๐Ÿ“๐Ÿ–ผ๏ธ๐Ÿน A small thing where I can upload a file and get a link back. https://media.strawmelonjuice.com/
0
fork

Configure Feed

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

auto fmt


Signed-off-by: MLC Bloeiman <mar@strawmelonjuice.com>

+1365 -1442
+1 -4
.sqlx/query-04c6e15fe956565cc574b5fd9250fce7c3bc9289c542918b8d445333e52436d8.json
··· 17 17 "parameters": { 18 18 "Right": 1 19 19 }, 20 - "nullable": [ 21 - false, 22 - false 23 - ] 20 + "nullable": [false, false] 24 21 }, 25 22 "hash": "04c6e15fe956565cc574b5fd9250fce7c3bc9289c542918b8d445333e52436d8" 26 23 }
+1 -3
.sqlx/query-079e3705c8dd388552d4b9613b20c544440eb3ae3db9f63827e4e8e9b9092414.json
··· 12 12 "parameters": { 13 13 "Right": 1 14 14 }, 15 - "nullable": [ 16 - false 17 - ] 15 + "nullable": [false] 18 16 }, 19 17 "hash": "079e3705c8dd388552d4b9613b20c544440eb3ae3db9f63827e4e8e9b9092414" 20 18 }
+1 -5
.sqlx/query-17fce9ddd017f0bbb006dcde882f8d51f16ea08377d54ccb070e3be6eb56b739.json
··· 22 22 "parameters": { 23 23 "Right": 0 24 24 }, 25 - "nullable": [ 26 - false, 27 - false, 28 - false 29 - ] 25 + "nullable": [false, false, false] 30 26 }, 31 27 "hash": "17fce9ddd017f0bbb006dcde882f8d51f16ea08377d54ccb070e3be6eb56b739" 32 28 }
+1 -4
.sqlx/query-19e4c0a30a8fa7daec04de70e1536424d5d6efc7d6bc7e62e6817b23251c5133.json
··· 17 17 "parameters": { 18 18 "Right": 1 19 19 }, 20 - "nullable": [ 21 - false, 22 - false 23 - ] 20 + "nullable": [false, false] 24 21 }, 25 22 "hash": "19e4c0a30a8fa7daec04de70e1536424d5d6efc7d6bc7e62e6817b23251c5133" 26 23 }
+1 -5
.sqlx/query-55cdc86205af3f03b3cff9dbc25158f02c53dd14f9ad98f00102a2d715e202f8.json
··· 22 22 "parameters": { 23 23 "Right": 0 24 24 }, 25 - "nullable": [ 26 - false, 27 - false, 28 - true 29 - ] 25 + "nullable": [false, false, true] 30 26 }, 31 27 "hash": "55cdc86205af3f03b3cff9dbc25158f02c53dd14f9ad98f00102a2d715e202f8" 32 28 }
+1 -3
.sqlx/query-6837d2c9f9ed1c8b7e78cdd52cbae1a79d4e84b3edf419fbdbf47f17e60a2be4.json
··· 12 12 "parameters": { 13 13 "Right": 1 14 14 }, 15 - "nullable": [ 16 - false 17 - ] 15 + "nullable": [false] 18 16 }, 19 17 "hash": "6837d2c9f9ed1c8b7e78cdd52cbae1a79d4e84b3edf419fbdbf47f17e60a2be4" 20 18 }
+1 -3
.sqlx/query-7aff488a4e7e7bef59132cebc22b21c525302b96d67c94169cc89c17f1743bc4.json
··· 12 12 "parameters": { 13 13 "Right": 1 14 14 }, 15 - "nullable": [ 16 - false 17 - ] 15 + "nullable": [false] 18 16 }, 19 17 "hash": "7aff488a4e7e7bef59132cebc22b21c525302b96d67c94169cc89c17f1743bc4" 20 18 }
+1 -5
.sqlx/query-8807ddf479b83d395d7d442786b43c01e1018f98e5e05757d6f1d5675263dc41.json
··· 22 22 "parameters": { 23 23 "Right": 0 24 24 }, 25 - "nullable": [ 26 - false, 27 - false, 28 - false 29 - ] 25 + "nullable": [false, false, false] 30 26 }, 31 27 "hash": "8807ddf479b83d395d7d442786b43c01e1018f98e5e05757d6f1d5675263dc41" 32 28 }
+1 -6
.sqlx/query-ace5da0bc6eabaf15af75d667a6bb32110d8e17f65a41d87d63ab3138788a889.json
··· 27 27 "parameters": { 28 28 "Right": 1 29 29 }, 30 - "nullable": [ 31 - false, 32 - false, 33 - false, 34 - false 35 - ] 30 + "nullable": [false, false, false, false] 36 31 }, 37 32 "hash": "ace5da0bc6eabaf15af75d667a6bb32110d8e17f65a41d87d63ab3138788a889" 38 33 }
+1 -4
.sqlx/query-b7aee89059fcb605e23e86a68a65b651d9d9b1e9992ae287a4c18627e557d91f.json
··· 17 17 "parameters": { 18 18 "Right": 1 19 19 }, 20 - "nullable": [ 21 - false, 22 - false 23 - ] 20 + "nullable": [false, false] 24 21 }, 25 22 "hash": "b7aee89059fcb605e23e86a68a65b651d9d9b1e9992ae287a4c18627e557d91f" 26 23 }
+1 -3
.sqlx/query-d7b174621a692187cb0b02f61ccf2ad6866536d2eb66b594fe597a4e6d5e6115.json
··· 12 12 "parameters": { 13 13 "Right": 0 14 14 }, 15 - "nullable": [ 16 - false 17 - ] 15 + "nullable": [false] 18 16 }, 19 17 "hash": "d7b174621a692187cb0b02f61ccf2ad6866536d2eb66b594fe597a4e6d5e6115" 20 18 }
+75 -75
README.md
··· 5 5 This project is a lightweight and user-friendly file-sharing application. It allows users to upload files, manage their 6 6 storage, and share files or artifacts via unique links. 7 7 8 - 9 8 ## v2.0.0 roadmap 10 9 11 10 Version 2.0.0 will no longer ship with a server written in TypeScript, instead, Rust with the Rocket framework should make it both easier to maintain and stronger for future trials. 12 11 13 12 ### โœ… **Implemented in Rust:** 13 + 14 14 - Dashboard (`/`) serving static HTML 15 15 - File serving (`/file/<id>`) with filename validation 16 - - Authentication (`/api/auth`) with session management 16 + - Authentication (`/api/auth`) with session management 17 17 - Session destruction (`/api/session/destroy`) 18 18 - Simple file upload (`/api/upload`) with quota checking 19 19 - Simple redirect from (`/favicon.ico`) to the strawmelonjuice.png file 20 20 - User info retrieval from (`/api/user/fetch`) 21 21 22 - 23 22 ### โŒ **Missing from Rust:** 24 23 25 - 1. **`/api/files` - File Listing** 24 + 1. **`/api/files` - File Listing** 26 25 - [x] Lists all files for authenticated users 27 26 - [x] Admins see all files, regular users see only their own 28 27 - [ ] Returns file sizes in MB ··· 50 49 - Should display file info with embed support for media files 51 50 52 51 7. **Admin panel** (multiple api's missing, frontend missing) 53 - - An admin panel showable to users who are admin according to `/api/user/fetch`. Allows management, disabling (not removal), reset (deletes all files in ownership) or creation of other users, and server configuration. File management is still done on the file list, as admins can still see all files and their owners. 52 + - An admin panel showable to users who are admin according to `/api/user/fetch`. Allows management, disabling (not removal), reset (deletes all files in ownership) or creation of other users, and server configuration. File management is still done on the file list, as admins can still see all files and their owners. 54 53 55 54 8. **Repository files** (`/release/<packagename>/`, `/api/upload-streamed`) 56 - - Users claim a repository and own it, only owners and admins can alter (upload/remove/etc.) claimed repository. 57 - - Allows for 'versions' including two featured versions which are displayed at the top. 58 - - Claiming is done by uploading a file with filename `<repositoryname>/<version>/<filename>` to the server. Repository uploads might be unavailable for the web panel until a next release, but I am also thinking of a CLI, which would be very suitable and might even support doing this from the <https://forge.strawmelonjuice.com> CI. 59 - - Files with the same version and filename within a repository are overwritten. 60 - - Repository file uploads do not close when 100% is transmitted, instead they send a `DONE`, and await the server's response. 55 + - Users claim a repository and own it, only owners and admins can alter (upload/remove/etc.) claimed repository. 56 + - Allows for 'versions' including two featured versions which are displayed at the top. 57 + - Claiming is done by uploading a file with filename `<repositoryname>/<version>/<filename>` to the server. Repository uploads might be unavailable for the web panel until a next release, but I am also thinking of a CLI, which would be very suitable and might even support doing this from the <https://forge.strawmelonjuice.com> CI. 58 + - Files with the same version and filename within a repository are overwritten. 59 + - Repository file uploads do not close when 100% is transmitted, instead they send a `DONE`, and await the server's response. 61 60 9. **Deploy** (`/pages/<username>/<repositoryname>`-> might be aliased, `/api/upload-streamed`) 62 - - Builds on earlier statements from repository files. 63 - - Uploading `<repositoryname>/deploy.zip` turns an upload into a deployment. The deploy.zip is then unpacked, it must contain an `./index.html` file, regardless of file content. If not, the upload is rejected. 64 - 61 + - Builds on earlier statements from repository files. 62 + - Uploading `<repositoryname>/deploy.zip` turns an upload into a deployment. The deploy.zip is then unpacked, it must contain an `./index.html` file, regardless of file content. If not, the upload is rejected. 63 + 65 64 <!-- 66 65 ## Features 67 66 ··· 86 85 ## Getting Started (dev mode, use the docker image for real use cases!) 87 86 88 87 1. Clone the repository: 89 - ```bash 90 - git clone https://git.strawmelonjuice.com/strawmelonjuice/strawmediajuice.git 91 - cd strawmediajuice 92 - ``` 88 + 89 + ```bash 90 + git clone https://git.strawmelonjuice.com/strawmelonjuice/strawmediajuice.git 91 + cd strawmediajuice 92 + ``` 93 93 94 94 2. Get the dependencies. `mise i` or `nix development` work, but if you'd want to install them in other ways, see the `mise.toml` file for your list of dependencies. 95 95 96 96 3. Start the application: 97 - ```bash 98 - just dev 99 - ``` 97 + ```bash 98 + just dev 99 + ``` 100 100 4. Access the dashboard at [http://localhost:3000](http://localhost:3000). 101 - 102 101 103 102 ## Environment Variables 104 103 105 104 To configure the application, set the following environment variables: 106 105 107 106 | Variable name | Description | If not set | 108 - |-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------| 107 + | ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | 109 108 | `PORT` | The port on which the server will run. | default: `3000` | 110 109 | `INIT_USERNAME` | The username for the initial admin account. | Will prompt on first run. | 111 110 | `INIT_PASSWORD` | The password for the initial admin account. | will prompt on first run. | ··· 124 123 125 124 ```yaml 126 125 volumes: 127 - - ./data:/app/data 126 + - ./data:/app/data 128 127 ``` 129 128 130 129 ### Kubernetes Example 131 130 132 131 ```yaml 133 132 volumeMounts: 134 - - name: data-volume 135 - mountPath: /app/data 133 + - name: data-volume 134 + mountPath: /app/data 136 135 volumes: 137 - - name: data-volume 138 - persistentVolumeClaim: 139 - claimName: data-pvc 136 + - name: data-volume 137 + persistentVolumeClaim: 138 + claimName: data-pvc 140 139 ``` 141 140 142 141 ## Docker Deployment ··· 144 143 The application is available as a Docker image on Docker Hub: `strawmelonjuice/strawmediajuice`. 145 144 146 145 ### Running with Docker CLI 146 + 147 147 ```bash 148 148 docker run -d \ 149 149 -p 3000:3000 \ ··· 159 159 ```yaml 160 160 version: "3.8" 161 161 services: 162 - strawmediajuice: 163 - image: strawmelonjuice/strawmediajuice:latest 164 - ports: 165 - - "3000:3000" 166 - environment: 167 - - PORT=3000 168 - - INIT_USERNAME=admin 169 - - INIT_PASSWORD=securepassword 170 - volumes: 171 - - ./data:/app/data 162 + strawmediajuice: 163 + image: strawmelonjuice/strawmediajuice:latest 164 + ports: 165 + - "3000:3000" 166 + environment: 167 + - PORT=3000 168 + - INIT_USERNAME=admin 169 + - INIT_PASSWORD=securepassword 170 + volumes: 171 + - ./data:/app/data 172 172 ``` 173 173 174 174 ### Kubernetes Deployment ··· 179 179 apiVersion: apps/v1 180 180 kind: Deployment 181 181 metadata: 182 - name: strawmediajuice 182 + name: strawmediajuice 183 183 spec: 184 - replicas: 1 185 - selector: 186 - matchLabels: 187 - app: strawmediajuice 188 - template: 189 - metadata: 190 - labels: 191 - app: strawmediajuice 192 - spec: 193 - containers: 194 - - name: strawmediajuice 195 - image: strawmelonjuice/strawmediajuice:latest 196 - ports: 197 - - containerPort: 3000 198 - env: 199 - - name: PORT 200 - value: "3000" 201 - - name: INIT_USERNAME 202 - value: "admin" 203 - - name: INIT_PASSWORD 204 - value: "securepassword" 205 - volumeMounts: 206 - - name: data-volume 207 - mountPath: /app/data 208 - volumes: 209 - - name: data-volume 210 - persistentVolumeClaim: 211 - claimName: data-pvc 184 + replicas: 1 185 + selector: 186 + matchLabels: 187 + app: strawmediajuice 188 + template: 189 + metadata: 190 + labels: 191 + app: strawmediajuice 192 + spec: 193 + containers: 194 + - name: strawmediajuice 195 + image: strawmelonjuice/strawmediajuice:latest 196 + ports: 197 + - containerPort: 3000 198 + env: 199 + - name: PORT 200 + value: "3000" 201 + - name: INIT_USERNAME 202 + value: "admin" 203 + - name: INIT_PASSWORD 204 + value: "securepassword" 205 + volumeMounts: 206 + - name: data-volume 207 + mountPath: /app/data 208 + volumes: 209 + - name: data-volume 210 + persistentVolumeClaim: 211 + claimName: data-pvc 212 212 --- 213 213 apiVersion: v1 214 214 kind: Service 215 215 metadata: 216 - name: strawmediajuice 216 + name: strawmediajuice 217 217 spec: 218 - selector: 219 - app: strawmediajuice 220 - ports: 221 - - protocol: TCP 222 - port: 80 223 - targetPort: 3000 224 - type: LoadBalancer 218 + selector: 219 + app: strawmediajuice 220 + ports: 221 + - protocol: TCP 222 + port: 80 223 + targetPort: 3000 224 + type: LoadBalancer 225 225 ``` 226 226 227 227 ## API Endpoints
+6 -6
dashboard/app.css
··· 3 3 @source "./src/**/*.gleam"; 4 4 5 5 @font-face { 6 - font-family: "Josefin Sans"; 7 - src: url("https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap"); 6 + font-family: "Josefin Sans"; 7 + src: url("https://fonts.googleapis.com/css2?family=Josefin+Sans:wght@400;700&display=swap"); 8 8 } 9 9 10 10 @theme { 11 - --font-display: "Josefin Sans"; 11 + --font-display: "Josefin Sans"; 12 12 } 13 13 @plugin "daisyui" { 14 - themes: 15 - lemonade --default, 16 - coffee --prefersdark; 14 + themes: 15 + lemonade --default, 16 + coffee --prefersdark; 17 17 }
+2 -2
dashboard/src/ffi.ts
··· 4 4 import appManifest from "../../../../gleam.toml"; 5 5 6 6 export function app_version(): string { 7 - return appManifest.version 7 + return appManifest.version; 8 8 } 9 9 export function srv_version(): string { 10 10 //@ts-ignore 11 11 let v: string = internalServerVersion; 12 12 if (v == "old") { 13 - return "Old TS version (version undocumented)" 13 + return "Old TS version (version undocumented)"; 14 14 } 15 15 return v; 16 16 }
+29 -25
dashboard/src/strawmediajuice_fe.gleam
··· 1306 1306 Some(_) -> view_home(model) 1307 1307 None -> view_login_form(model) 1308 1308 }, 1309 - footer() 1309 + footer(), 1310 1310 ]) 1311 1311 } 1312 1312 1313 - @external(javascript,"./ffi", "srv_version") 1313 + @external(javascript, "./ffi", "srv_version") 1314 1314 fn server_version() -> String 1315 1315 1316 - @external(javascript,"./ffi", "app_version") 1316 + @external(javascript, "./ffi", "app_version") 1317 1317 fn client_version() -> String 1318 1318 1319 1319 fn footer() -> Element(Msg) { 1320 1320 html.footer( 1321 - [ 1322 - attribute.class("footer sm:footer-horizontal bg-neutral text-neutral-content items-center p-4"), 1323 - ], 1324 - [ 1325 - html.aside( 1326 - [attribute.class("grid-flow-col items-center")], 1327 - [ 1328 - html.p( 1329 - [], 1330 - [ 1331 - html.text("Client: " <> client_version() <> "; Server: " <> server_version()<>";"), 1332 - ], 1333 - ), 1334 - ], 1335 - ), 1336 - html.nav( 1337 - [ 1338 - attribute.class("grid-flow-col gap-4 md:place-self-center md:justify-self-end"), 1339 - ], 1340 - [], 1341 - ), 1342 - ], 1321 + [ 1322 + attribute.class( 1323 + "footer sm:footer-horizontal bg-neutral text-neutral-content items-center p-4", 1324 + ), 1325 + ], 1326 + [ 1327 + html.aside([attribute.class("grid-flow-col items-center")], [ 1328 + html.p([], [ 1329 + html.text( 1330 + "Client: " 1331 + <> client_version() 1332 + <> "; Server: " 1333 + <> server_version() 1334 + <> ";", 1335 + ), 1336 + ]), 1337 + ]), 1338 + html.nav( 1339 + [ 1340 + attribute.class( 1341 + "grid-flow-col gap-4 md:place-self-center md:justify-self-end", 1342 + ), 1343 + ], 1344 + [], 1345 + ), 1346 + ], 1343 1347 ) 1344 1348 } 1345 1349
+1
dashboard/src/strawmediajuice_fe/files.gleam
··· 1 +
+28 -17
scripts/user_add.ts
··· 1 - import {Database} from "bun:sqlite"; 1 + import { Database } from "bun:sqlite"; 2 2 3 3 const db = new Database("./data/app.db"); 4 4 db.exec(`PRAGMA journal_mode = WAL`); 5 5 6 6 // create tables if not existent 7 7 db.exec( 8 - `CREATE TABLE IF NOT EXISTS file_mapping (id TEXT PRIMARY KEY, filename TEXT, owner TEXT)`, 8 + `CREATE TABLE IF NOT EXISTS file_mapping (id TEXT PRIMARY KEY, filename TEXT, owner TEXT)`, 9 9 ); 10 10 db.exec( 11 - `CREATE TABLE IF NOT EXISTS users (uid TEXT PRIMARY KEY,username TEXT, password_hash TEXT NOT NULL, max_megabytes INTEGER, used_megabytes REAL, is_admin BOOL);`, 11 + `CREATE TABLE IF NOT EXISTS users (uid TEXT PRIMARY KEY,username TEXT, password_hash TEXT NOT NULL, max_megabytes INTEGER, used_megabytes REAL, is_admin BOOL);`, 12 12 ); 13 13 14 14 // Check if the users table is empty 15 15 let newuser = { 16 - username: prompt("Enter a username:"), 17 - password: prompt("Enter a password:"), 18 - "max megabytes": parseInt(prompt("Enter max megabytes for this user, or set to 0 for unlimited: [0]") || "0"), 19 - "is admin": confirm("Is this user an admin?") 16 + username: prompt("Enter a username:"), 17 + password: prompt("Enter a password:"), 18 + "max megabytes": parseInt( 19 + prompt( 20 + "Enter max megabytes for this user, or set to 0 for unlimited: [0]", 21 + ) || "0", 22 + ), 23 + "is admin": confirm("Is this user an admin?"), 20 24 }; 21 25 console.table(newuser); 22 26 const ok = confirm("Are you sure you want to create this user?"); 23 - const {username, password} = newuser; 27 + const { username, password } = newuser; 24 28 25 29 if (username && password && ok) { 26 - const passwordHash = await Bun.password.hash(password); 27 - const uuid = Bun.randomUUIDv7(); 28 - db.exec( 29 - `INSERT INTO users (uid, username, password_hash, max_megabytes, used_megabytes, is_admin) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, 30 - [uuid,username, passwordHash, newuser["max megabytes"], 0, newuser["is admin"]], 31 - ); 32 - console.log("User created successfully."); 30 + const passwordHash = await Bun.password.hash(password); 31 + const uuid = Bun.randomUUIDv7(); 32 + db.exec( 33 + `INSERT INTO users (uid, username, password_hash, max_megabytes, used_megabytes, is_admin) VALUES (?1, ?2, ?3, ?4, ?5, ?6)`, 34 + [ 35 + uuid, 36 + username, 37 + passwordHash, 38 + newuser["max megabytes"], 39 + 0, 40 + newuser["is admin"], 41 + ], 42 + ); 43 + console.log("User created successfully."); 33 44 } else { 34 - console.error("Exiting."); 35 - process.exit(1); 45 + console.error("Exiting."); 46 + process.exit(1); 36 47 } 37 48 db.close();
+6 -6
scripts/user_delete.ts
··· 1 - import {Database} from "bun:sqlite"; 1 + import { Database } from "bun:sqlite"; 2 2 3 3 const db = new Database("./data/app.db"); 4 4 db.exec(`PRAGMA journal_mode = WAL`); ··· 7 7 const user = db.query(`SELECT * FROM users WHERE username = ?`).get(username); 8 8 9 9 if (!user) { 10 - console.error("User not found."); 11 - process.exit(1); 10 + console.error("User not found."); 11 + process.exit(1); 12 12 } 13 13 14 14 console.table(user); 15 15 const confirmDelete = confirm("Are you sure you want to delete this user?"); 16 16 17 17 if (confirmDelete) { 18 - db.exec(`DELETE FROM users WHERE username = ?`, [username]); 19 - console.log("User deleted successfully."); 18 + db.exec(`DELETE FROM users WHERE username = ?`, [username]); 19 + console.log("User deleted successfully."); 20 20 } else { 21 - console.log("Operation canceled."); 21 + console.log("Operation canceled."); 22 22 } 23 23 24 24 db.close();
+3 -3
scripts/user_demote.ts
··· 1 - import {Database} from "bun:sqlite"; 1 + import { Database } from "bun:sqlite"; 2 2 3 3 const db = new Database("./data/app.db"); 4 4 db.exec(`PRAGMA journal_mode = WAL`); ··· 7 7 const user = db.query(`SELECT * FROM users WHERE username = ?`).get(username); 8 8 9 9 if (!user) { 10 - console.error("User not found."); 11 - process.exit(1); 10 + console.error("User not found."); 11 + process.exit(1); 12 12 } 13 13 14 14 db.exec(`UPDATE users SET is_admin = 0 WHERE username = ?`, [username]);
+3 -3
scripts/user_elevate.ts
··· 1 - import {Database} from "bun:sqlite"; 1 + import { Database } from "bun:sqlite"; 2 2 3 3 const db = new Database("./data/app.db"); 4 4 db.exec(`PRAGMA journal_mode = WAL`); ··· 7 7 const user = db.query(`SELECT * FROM users WHERE username = ?`).get(username); 8 8 9 9 if (!user) { 10 - console.error("User not found."); 11 - process.exit(1); 10 + console.error("User not found."); 11 + process.exit(1); 12 12 } 13 13 14 14 db.exec(`UPDATE users SET is_admin = 1 WHERE username = ?`, [username]);
+9 -6
scripts/user_rename.ts
··· 1 - import {Database} from "bun:sqlite"; 1 + import { Database } from "bun:sqlite"; 2 2 3 3 const db = new Database("./data/app.db"); 4 4 db.exec(`PRAGMA journal_mode = WAL`); ··· 7 7 const user = db.query(`SELECT * FROM users WHERE username = ?`).get(username); 8 8 9 9 if (!user) { 10 - console.error("User not found."); 11 - process.exit(1); 10 + console.error("User not found."); 11 + process.exit(1); 12 12 } 13 13 14 14 const newUsername = prompt("Enter the new username:"); 15 15 if (!newUsername) { 16 - console.error("New username cannot be empty."); 17 - process.exit(1); 16 + console.error("New username cannot be empty."); 17 + process.exit(1); 18 18 } 19 19 20 - db.exec(`UPDATE users SET username = ? WHERE username = ?`, [newUsername, username]); 20 + db.exec(`UPDATE users SET username = ? WHERE username = ?`, [ 21 + newUsername, 22 + username, 23 + ]); 21 24 console.log("Username updated successfully."); 22 25 db.close();
+9 -5
scripts/user_set_quota.ts
··· 1 - import {Database} from "bun:sqlite"; 1 + import { Database } from "bun:sqlite"; 2 2 3 3 const db = new Database("./data/app.db"); 4 4 db.exec(`PRAGMA journal_mode = WAL`); ··· 7 7 const user = db.query(`SELECT * FROM users WHERE username = ?`).get(username); 8 8 9 9 if (!user) { 10 - console.error("User not found."); 11 - process.exit(1); 10 + console.error("User not found."); 11 + process.exit(1); 12 12 } 13 13 14 - const maxMegabytes = prompt("Enter the new max megabytes (leave blank for unlimited):") || "0"; 15 - db.exec(`UPDATE users SET max_megabytes = ? WHERE username = ?`, [parseInt(maxMegabytes), username]); 14 + const maxMegabytes = 15 + prompt("Enter the new max megabytes (leave blank for unlimited):") || "0"; 16 + db.exec(`UPDATE users SET max_megabytes = ? WHERE username = ?`, [ 17 + parseInt(maxMegabytes), 18 + username, 19 + ]); 16 20 console.log("User quota updated successfully."); 17 21 db.close();
+4 -2
server-rs/src/routes.rs
··· 1 1 use crate::{App, Database}; 2 2 use rocket::fs::NamedFile; 3 - use rocket::{State, http::Status, response::content::RawHtml}; 4 3 use rocket::response::Redirect; 4 + use rocket::{State, http::Status, response::content::RawHtml}; 5 5 use sqlx::query; 6 6 7 7 #[get("/")] ··· 29 29 None => "", 30 30 }; 31 31 if file_id == "00000000-0000-0000-0000-000000000001" { 32 - return Err(Err(Redirect::to("/file/9962109f-2d98-4c51-a8ac-8f71d45a41ab/strawmelonjuice.png"))); 32 + return Err(Err(Redirect::to( 33 + "/file/9962109f-2d98-4c51-a8ac-8f71d45a41ab/strawmelonjuice.png", 34 + ))); 33 35 } 34 36 let filename = if id.iter().count() > 1 { 35 37 match id.iter().nth(1) {
-1
server-rs/src/routes/api.rs
··· 193 193 } 194 194 }; 195 195 196 - // i hope this is how thats done o-o 197 196 return Ok(Json(files)); 198 197 } 199 198
+363 -369
server/app.class.ts
··· 6 6 import type { WebConnectionEvent } from "./logging/webconnectionevent"; 7 7 8 8 export default class StrawmediaJuiceServerApplication { 9 - public readonly db: Database; 10 - public readonly logging: Logging; 11 - port: number; 9 + public readonly db: Database; 10 + public readonly logging: Logging; 11 + port: number; 12 12 13 - constructor() { 14 - console.log("Initialising logging features..."); 15 - this.logging = new Logging(); 16 - this.logging.info("Initialising database connection..."); 17 - this.db = new Database("./data/app.db"); 18 - if (Bun.env.PORT) { 19 - this.port = parseInt(Bun.env.PORT, 10); 20 - } else { 21 - this.port = 3000; 22 - } 23 - this.db.exec(`PRAGMA journal_mode = WAL`); 13 + constructor() { 14 + console.log("Initialising logging features..."); 15 + this.logging = new Logging(); 16 + this.logging.info("Initialising database connection..."); 17 + this.db = new Database("./data/app.db"); 18 + if (Bun.env.PORT) { 19 + this.port = parseInt(Bun.env.PORT, 10); 20 + } else { 21 + this.port = 3000; 22 + } 23 + this.db.exec(`PRAGMA journal_mode = WAL`); 24 24 25 - // create tables if not existent 26 - this.db.exec( 27 - `CREATE TABLE IF NOT EXISTS file_mapping (id TEXT PRIMARY KEY, filename TEXT, owner TEXT, owner_uid TEXT)`, 28 - ); 29 - this.db.exec( 30 - `CREATE TABLE IF NOT EXISTS sessions (sid TEXT PRIMARY KEY, uid TEXT, created_at INTEGER)`, 31 - ); 32 - this.db.exec( 33 - `CREATE TABLE IF NOT EXISTS users (uid TEXT PRIMARY KEY,username TEXT, password_hash TEXT NOT NULL, max_megabytes INTEGER, used_megabytes REAL, is_admin BOOL);`, 34 - ); 25 + // create tables if not existent 26 + this.db.exec( 27 + `CREATE TABLE IF NOT EXISTS file_mapping (id TEXT PRIMARY KEY, filename TEXT, owner TEXT, owner_uid TEXT)`, 28 + ); 29 + this.db.exec( 30 + `CREATE TABLE IF NOT EXISTS sessions (sid TEXT PRIMARY KEY, uid TEXT, created_at INTEGER)`, 31 + ); 32 + this.db.exec( 33 + `CREATE TABLE IF NOT EXISTS users (uid TEXT PRIMARY KEY,username TEXT, password_hash TEXT NOT NULL, max_megabytes INTEGER, used_megabytes REAL, is_admin BOOL);`, 34 + ); 35 35 36 - // Check if the users table is empty 37 - const userCount = this.db 38 - .query(`SELECT COUNT(*) as count FROM users`) 39 - .get() as { 40 - count: number; 41 - } | null; 42 - if (userCount && userCount.count === 0) { 43 - const username = (() => { 44 - if (Bun.env.INIT_USERNAME) { 45 - this.logging.info( 46 - "Creating user from env vars: " + Bun.env.INIT_USERNAME, 47 - ); 48 - return Bun.env.INIT_USERNAME; 49 - } else { 50 - return prompt("Enter a username:"); 51 - } 52 - })(); 36 + // Check if the users table is empty 37 + const userCount = this.db 38 + .query(`SELECT COUNT(*) as count FROM users`) 39 + .get() as { 40 + count: number; 41 + } | null; 42 + if (userCount && userCount.count === 0) { 43 + const username = (() => { 44 + if (Bun.env.INIT_USERNAME) { 45 + this.logging.info( 46 + "Creating user from env vars: " + Bun.env.INIT_USERNAME, 47 + ); 48 + return Bun.env.INIT_USERNAME; 49 + } else { 50 + return prompt("Enter a username:"); 51 + } 52 + })(); 53 53 54 - const password = (() => { 55 - if (Bun.env.INIT_PASSWORD) { 56 - return Bun.env.INIT_PASSWORD; 57 - } else { 58 - return prompt("Enter a password:"); 59 - } 60 - })(); 54 + const password = (() => { 55 + if (Bun.env.INIT_PASSWORD) { 56 + return Bun.env.INIT_PASSWORD; 57 + } else { 58 + return prompt("Enter a password:"); 59 + } 60 + })(); 61 61 62 - if (username && password) { 63 - const uid = Bun.randomUUIDv7(); 64 - const passwordHash = Bun.password.hashSync(password); 65 - this.db.exec( 66 - `INSERT INTO users (uid,username, password_hash, max_megabytes, used_megabytes, is_admin) VALUES (?1, ?2, ?3, 0, 0, TRUE)`, 67 - [uid, username, passwordHash], 68 - ); 69 - this.logging.success("Admin user created successfully."); 70 - } else { 71 - this.logging.error( 72 - "Username or password not provided. Exiting.", 73 - ); 74 - process.exit(1); 75 - } 62 + if (username && password) { 63 + const uid = Bun.randomUUIDv7(); 64 + const passwordHash = Bun.password.hashSync(password); 65 + this.db.exec( 66 + `INSERT INTO users (uid,username, password_hash, max_megabytes, used_megabytes, is_admin) VALUES (?1, ?2, ?3, 0, 0, TRUE)`, 67 + [uid, username, passwordHash], 68 + ); 69 + this.logging.success("Admin user created successfully."); 70 + } else { 71 + this.logging.error("Username or password not provided. Exiting."); 72 + process.exit(1); 73 + } 76 74 77 - // always create a sample file under id 00000000-0000-0000-0000-000000000000, being just sample.txt with a hello world message 78 - { 79 - const sampleId = "00000000-0000-0000-0000-000000000000"; 80 - const sampleFilename = "sample.txt"; 81 - this.db.exec( 82 - `INSERT OR IGNORE INTO file_mapping (id, filename) VALUES (?, ?)`, 83 - [sampleId, sampleFilename], 84 - ); 85 - fs.mkdirSync("./data/files/" + sampleId, { recursive: true }); 86 - fs.writeFileSync( 87 - `./data/files/${sampleId}/${sampleFilename}`, 88 - "Hello, World!", 89 - ); 90 - } 91 - } 75 + // always create a sample file under id 00000000-0000-0000-0000-000000000000, being just sample.txt with a hello world message 76 + { 77 + const sampleId = "00000000-0000-0000-0000-000000000000"; 78 + const sampleFilename = "sample.txt"; 79 + this.db.exec( 80 + `INSERT OR IGNORE INTO file_mapping (id, filename) VALUES (?, ?)`, 81 + [sampleId, sampleFilename], 82 + ); 83 + fs.mkdirSync("./data/files/" + sampleId, { recursive: true }); 84 + fs.writeFileSync( 85 + `./data/files/${sampleId}/${sampleFilename}`, 86 + "Hello, World!", 87 + ); 88 + } 89 + } 92 90 93 - // log all rows from the database to the console at startup 94 - const allFiles = this.db.query(`SELECT * FROM file_mapping`).all(); 91 + // log all rows from the database to the console at startup 92 + const allFiles = this.db.query(`SELECT * FROM file_mapping`).all(); 95 93 96 - this.logging.info_described( 97 - "All files in database:", 98 - Bun.inspect.table(allFiles), 99 - ); 100 - // for users, log without passwords 101 - const allUsersRaw = this.db.query(`SELECT * FROM users`).all(); 102 - const allUsers = allUsersRaw.map((u: any) => ({ 103 - username: u.username, 104 - password: u.password_hash, 105 - "megabyte quota": u.max_megabytes, 106 - "used megabytes": u.used_megabytes, 107 - "is admin": u.is_admin, 108 - })); 109 - this.logging.info_described( 110 - "All users in database:", 111 - Bun.inspect.table( 112 - allUsers 113 - .map( 114 - (u: { 115 - password: string; 116 - username: string; 117 - "megabyte quota": any; 118 - "used megabytes": any; 119 - "is admin": any; 120 - }) => { 121 - return { 122 - ...u, 123 - password: "*".repeat(u.password.length), 124 - }; 125 - }, 126 - ) 127 - .map((u) => ({ 128 - ...u, 129 - "is admin": u["is admin"] ? "yes" : "no", 130 - })) 131 - .map((u) => ({ 132 - ...u, 133 - "megabyte quota": 134 - u["megabyte quota"] === 0 135 - ? "unlimited" 136 - : u["megabyte quota"], 137 - })), 138 - ), 139 - ); 140 - } 94 + this.logging.info_described( 95 + "All files in database:", 96 + Bun.inspect.table(allFiles), 97 + ); 98 + // for users, log without passwords 99 + const allUsersRaw = this.db.query(`SELECT * FROM users`).all(); 100 + const allUsers = allUsersRaw.map((u: any) => ({ 101 + username: u.username, 102 + password: u.password_hash, 103 + "megabyte quota": u.max_megabytes, 104 + "used megabytes": u.used_megabytes, 105 + "is admin": u.is_admin, 106 + })); 107 + this.logging.info_described( 108 + "All users in database:", 109 + Bun.inspect.table( 110 + allUsers 111 + .map( 112 + (u: { 113 + password: string; 114 + username: string; 115 + "megabyte quota": any; 116 + "used megabytes": any; 117 + "is admin": any; 118 + }) => { 119 + return { 120 + ...u, 121 + password: "*".repeat(u.password.length), 122 + }; 123 + }, 124 + ) 125 + .map((u) => ({ 126 + ...u, 127 + "is admin": u["is admin"] ? "yes" : "no", 128 + })) 129 + .map((u) => ({ 130 + ...u, 131 + "megabyte quota": 132 + u["megabyte quota"] === 0 ? "unlimited" : u["megabyte quota"], 133 + })), 134 + ), 135 + ); 136 + } 141 137 142 - public helper() { 143 - const me: StrawmediaJuiceServerApplication = this; 144 - return { 145 - createSession: (uid: string) => { 146 - const sid = Bun.randomUUIDv7(); 147 - const created_at = Date.now(); 148 - me.db.exec( 149 - `INSERT INTO sessions (sid, uid, created_at) VALUES (?1, ?2, ?3)`, 150 - [sid, uid, created_at], 151 - ); 152 - return sid; 153 - }, 154 - destroySession: (sid: string) => { 155 - me.db.exec(`DELETE FROM sessions WHERE sid = ?1`, [sid]); 156 - }, 157 - isSessionValid: (sid: string) => { 158 - const statement = me.db.query( 159 - "SELECT uid FROM sessions WHERE sid = ?1", 160 - ); 161 - const result = statement.get(sid) as { uid: string } | null; 162 - if (result !== null) { 163 - return result.uid; 164 - } 165 - return null; 166 - }, 167 - // Retrieve user information from session ID (sid) while also preventing SQL injection and ensuring authentication. 168 - getUserFromSession: (sid: string) => { 169 - const statement = me.db.query( 170 - `SELECT users.uid, users.username, users.max_megabytes, users.used_megabytes, users.is_admin 138 + public helper() { 139 + const me: StrawmediaJuiceServerApplication = this; 140 + return { 141 + createSession: (uid: string) => { 142 + const sid = Bun.randomUUIDv7(); 143 + const created_at = Date.now(); 144 + me.db.exec( 145 + `INSERT INTO sessions (sid, uid, created_at) VALUES (?1, ?2, ?3)`, 146 + [sid, uid, created_at], 147 + ); 148 + return sid; 149 + }, 150 + destroySession: (sid: string) => { 151 + me.db.exec(`DELETE FROM sessions WHERE sid = ?1`, [sid]); 152 + }, 153 + isSessionValid: (sid: string) => { 154 + const statement = me.db.query( 155 + "SELECT uid FROM sessions WHERE sid = ?1", 156 + ); 157 + const result = statement.get(sid) as { uid: string } | null; 158 + if (result !== null) { 159 + return result.uid; 160 + } 161 + return null; 162 + }, 163 + // Retrieve user information from session ID (sid) while also preventing SQL injection and ensuring authentication. 164 + getUserFromSession: (sid: string) => { 165 + const statement = me.db.query( 166 + `SELECT users.uid, users.username, users.max_megabytes, users.used_megabytes, users.is_admin 171 167 FROM users 172 168 INNER JOIN sessions ON users.uid = sessions.uid 173 169 WHERE sessions.sid = ?1`, 174 - ); 175 - const result = statement.get(sid) as { 176 - uid: string; 177 - username: string; 178 - max_megabytes: number; 179 - used_megabytes: number; 180 - is_admin: boolean; 181 - } | null; 182 - if (result !== null) { 183 - return result; 184 - } 185 - return null; 186 - }, 187 - assertUnreachable: (x: never) => { 188 - throw new Error( 189 - "Didn't expect to get here, let alone get the param: ", 190 - x, 191 - ); 192 - }, 193 - getIdfromUsername: (username: string) => { 194 - const statement = me.db.query( 195 - "SELECT uid FROM users WHERE username = ?1", 196 - ); 197 - const result = statement.get(username) as { 198 - uid: string; 199 - } | null; 200 - if (result !== null) { 201 - return result.uid; 202 - } 203 - return null; 204 - }, 205 - getUsernameFromId: (uid: string) => { 206 - const statement = me.db.query( 207 - "SELECT username FROM users WHERE uid = ?1", 208 - ); 209 - const result = statement.get(uid) as { 210 - username: string; 211 - } | null; 212 - if (result) { 213 - return result.username; 214 - } 215 - return null; 216 - }, 217 - }; 218 - } 170 + ); 171 + const result = statement.get(sid) as { 172 + uid: string; 173 + username: string; 174 + max_megabytes: number; 175 + used_megabytes: number; 176 + is_admin: boolean; 177 + } | null; 178 + if (result !== null) { 179 + return result; 180 + } 181 + return null; 182 + }, 183 + assertUnreachable: (x: never) => { 184 + throw new Error( 185 + "Didn't expect to get here, let alone get the param: ", 186 + x, 187 + ); 188 + }, 189 + getIdfromUsername: (username: string) => { 190 + const statement = me.db.query( 191 + "SELECT uid FROM users WHERE username = ?1", 192 + ); 193 + const result = statement.get(username) as { 194 + uid: string; 195 + } | null; 196 + if (result !== null) { 197 + return result.uid; 198 + } 199 + return null; 200 + }, 201 + getUsernameFromId: (uid: string) => { 202 + const statement = me.db.query( 203 + "SELECT username FROM users WHERE uid = ?1", 204 + ); 205 + const result = statement.get(uid) as { 206 + username: string; 207 + } | null; 208 + if (result) { 209 + return result.username; 210 + } 211 + return null; 212 + }, 213 + }; 214 + } 219 215 } 220 216 221 217 class Logging { 222 - protected readonly log_to_file: boolean = true; 223 - protected readonly filewriter: Bun.FileSink | null = null; 224 - // Prefix is held to a minimum, but is allowed to overflow 225 - private readonly prefix_minimum = 12; 226 - // Descript is not allowed to overflow or be too short. 227 - private readonly descript_size: number = 35; 228 - // Message is split into multiple lines if too long. 229 - private readonly message_maximum_length: number = 72; // --with the |: 12 + 20 + 1 + 47 = 80 characters per line.-- with the |: 12 + 35 + 1 + 72 = 120 characters per line 218 + protected readonly log_to_file: boolean = true; 219 + protected readonly filewriter: Bun.FileSink | null = null; 220 + // Prefix is held to a minimum, but is allowed to overflow 221 + private readonly prefix_minimum = 12; 222 + // Descript is not allowed to overflow or be too short. 223 + private readonly descript_size: number = 35; 224 + // Message is split into multiple lines if too long. 225 + private readonly message_maximum_length: number = 72; // --with the |: 12 + 20 + 1 + 47 = 80 characters per line.-- with the |: 12 + 35 + 1 + 72 = 120 characters per line 230 226 231 - constructor() { 232 - if ( 233 - Bun.env.LOG_TO_FILE != undefined && 234 - (Bun.env.LOG_TO_FILE.toUpperCase() == "FALSE" || 235 - Bun.env.LOG_TO_FILE.toUpperCase() == "0") 236 - ) { 237 - this.log_to_file = false; 238 - } 239 - if (this.log_to_file) { 240 - this.filewriter = Bun.file( 241 - join(process.cwd(), "./data/server.log"), 242 - ).writer(); 243 - } 227 + constructor() { 228 + if ( 229 + Bun.env.LOG_TO_FILE != undefined && 230 + (Bun.env.LOG_TO_FILE.toUpperCase() == "FALSE" || 231 + Bun.env.LOG_TO_FILE.toUpperCase() == "0") 232 + ) { 233 + this.log_to_file = false; 234 + } 235 + if (this.log_to_file) { 236 + this.filewriter = Bun.file( 237 + join(process.cwd(), "./data/server.log"), 238 + ).writer(); 239 + } 244 240 245 - if ( 246 - Bun.env.EXPAND_TERMSIZE && 247 - !( 248 - Bun.env.EXPAND_TERMSIZE.toLowerCase() == "false" || 249 - Bun.env.EXPAND_TERMSIZE == "0" 250 - ) 251 - ) { 252 - let out = Bun.env.EXPAND_TERMSIZE; 253 - const cols = parseInt(out, 10); 254 - if (!isNaN(cols) && cols > 80) { 255 - // console.log(cols); 256 - const available_cols = Math.floor( 257 - cols - (this.prefix_minimum + 1), 258 - ); 259 - const adjusted_msg_width = Math.floor(available_cols * 0.8); 260 - // console.log(adjusted_msg_width); 261 - const adjusted_desc_width = Math.ceil(available_cols * 0.2); 262 - // console.log(adjusted_desc_width); 241 + if ( 242 + Bun.env.EXPAND_TERMSIZE && 243 + !( 244 + Bun.env.EXPAND_TERMSIZE.toLowerCase() == "false" || 245 + Bun.env.EXPAND_TERMSIZE == "0" 246 + ) 247 + ) { 248 + let out = Bun.env.EXPAND_TERMSIZE; 249 + const cols = parseInt(out, 10); 250 + if (!isNaN(cols) && cols > 80) { 251 + // console.log(cols); 252 + const available_cols = Math.floor(cols - (this.prefix_minimum + 1)); 253 + const adjusted_msg_width = Math.floor(available_cols * 0.8); 254 + // console.log(adjusted_msg_width); 255 + const adjusted_desc_width = Math.ceil(available_cols * 0.2); 256 + // console.log(adjusted_desc_width); 263 257 264 - this.info( 265 - `EXPAND_TERMSIZE, adjusting log width from ${available_cols}`, 266 - ); 258 + this.info( 259 + `EXPAND_TERMSIZE, adjusting log width from ${available_cols}`, 260 + ); 267 261 268 - this.descript_size = adjusted_desc_width; 269 - this.message_maximum_length = adjusted_msg_width; 270 - } 271 - } 272 - // show printed sizes 273 - console.log("Logging configuration:"); 274 - console.log( 275 - "prefix".padEnd(this.prefix_minimum, " ") + 276 - "descript".padEnd(this.descript_size, " ") + 277 - "โ”‚" + 278 - "message".padEnd(this.message_maximum_length, " "), 279 - ); 280 - if (this.log_to_file && this.filewriter) { 281 - this.info("Logging initialized, writing to ./data/server.log"); 282 - } else { 283 - this.info("Logging initialized"); 284 - } 285 - } 262 + this.descript_size = adjusted_desc_width; 263 + this.message_maximum_length = adjusted_msg_width; 264 + } 265 + } 266 + // show printed sizes 267 + console.log("Logging configuration:"); 268 + console.log( 269 + "prefix".padEnd(this.prefix_minimum, " ") + 270 + "descript".padEnd(this.descript_size, " ") + 271 + "โ”‚" + 272 + "message".padEnd(this.message_maximum_length, " "), 273 + ); 274 + if (this.log_to_file && this.filewriter) { 275 + this.info("Logging initialized, writing to ./data/server.log"); 276 + } else { 277 + this.info("Logging initialized"); 278 + } 279 + } 286 280 287 - public info(...message: any): void { 288 - this.glues("INFO", "", message, console.info); 289 - } 281 + public info(...message: any): void { 282 + this.glues("INFO", "", message, console.info); 283 + } 290 284 291 - public info_described(descript = "", ...message: any): void { 292 - this.glues("INFO", descript, message, console.info); 293 - } 285 + public info_described(descript = "", ...message: any): void { 286 + this.glues("INFO", descript, message, console.info); 287 + } 294 288 295 - public error(...message: any): void { 296 - this.glues("ERROR", "", message, console.error); 297 - } 289 + public error(...message: any): void { 290 + this.glues("ERROR", "", message, console.error); 291 + } 298 292 299 - public error_described(descript = "", ...message: any): void { 300 - this.glues("ERROR", descript, message, console.error); 301 - } 293 + public error_described(descript = "", ...message: any): void { 294 + this.glues("ERROR", descript, message, console.error); 295 + } 302 296 303 - public success(...message: any): void { 304 - this.glues("SUCCESS", "", message, console.log); 305 - } 297 + public success(...message: any): void { 298 + this.glues("SUCCESS", "", message, console.log); 299 + } 306 300 307 - public warn(...message: any): void { 308 - this.glues("WARN", "", message, console.warn); 309 - } 301 + public warn(...message: any): void { 302 + this.glues("WARN", "", message, console.warn); 303 + } 310 304 311 - public debug(...message: any): void { 312 - this.glues("DEBUG", "", message, console.debug); 313 - } 305 + public debug(...message: any): void { 306 + this.glues("DEBUG", "", message, console.debug); 307 + } 314 308 315 - public console(...message: any): void { 316 - this.glues("LOG", "", message, console.log); 317 - } 309 + public console(...message: any): void { 310 + this.glues("LOG", "", message, console.log); 311 + } 318 312 319 - public webevent( 320 - over: webconnection, 321 - what: WebConnectionEvent, 322 - message: string | any, 323 - ): void { 324 - let prefix: string = ""; 325 - let descript: string = what.description; 326 - const prefix_codes: [string, string] = (() => { 327 - return [what.http_code.toString(), what.websocket_code.toString()]; 328 - })(); 329 - switch (over.value) { 330 - case "HTTP": { 331 - prefix = "HTTP/" + prefix_codes[0]; 332 - break; 333 - } 334 - case "Websocket": { 335 - prefix = "SOCK/" + prefix_codes[1]; 336 - break; 337 - } 338 - default: 339 - throw new Error(over); 340 - } 341 - this.glues(prefix, descript, message, console.info); 342 - } 313 + public webevent( 314 + over: webconnection, 315 + what: WebConnectionEvent, 316 + message: string | any, 317 + ): void { 318 + let prefix: string = ""; 319 + let descript: string = what.description; 320 + const prefix_codes: [string, string] = (() => { 321 + return [what.http_code.toString(), what.websocket_code.toString()]; 322 + })(); 323 + switch (over.value) { 324 + case "HTTP": { 325 + prefix = "HTTP/" + prefix_codes[0]; 326 + break; 327 + } 328 + case "Websocket": { 329 + prefix = "SOCK/" + prefix_codes[1]; 330 + break; 331 + } 332 + default: 333 + throw new Error(over); 334 + } 335 + this.glues(prefix, descript, message, console.info); 336 + } 343 337 344 - private glues( 345 - prefix: string, 346 - descript: string, 347 - message: string | any, 348 - printer: typeof console.log, 349 - ) { 350 - let msg: string; 351 - if (typeof message != "string") { 352 - msg = message.toString() || inspect(message); 353 - } else { 354 - msg = message; 355 - } 356 - while (Bun.stringWidth(prefix) < this.prefix_minimum) { 357 - prefix += " "; 358 - } 359 - if (Bun.stringWidth(descript) > this.descript_size) { 360 - descript = descript.slice(0, this.descript_size); 361 - } else { 362 - while (Bun.stringWidth(descript) < this.descript_size) { 363 - descript += " "; 364 - } 365 - } 338 + private glues( 339 + prefix: string, 340 + descript: string, 341 + message: string | any, 342 + printer: typeof console.log, 343 + ) { 344 + let msg: string; 345 + if (typeof message != "string") { 346 + msg = message.toString() || inspect(message); 347 + } else { 348 + msg = message; 349 + } 350 + while (Bun.stringWidth(prefix) < this.prefix_minimum) { 351 + prefix += " "; 352 + } 353 + if (Bun.stringWidth(descript) > this.descript_size) { 354 + descript = descript.slice(0, this.descript_size); 355 + } else { 356 + while (Bun.stringWidth(descript) < this.descript_size) { 357 + descript += " "; 358 + } 359 + } 366 360 367 - let prologue = prefix + descript + "โ”‚"; 368 - if (msg.includes("\n")) { 369 - // We do not even try to split lines nicely here, give them their own lines. 370 - printer( 371 - prologue + 372 - "\n" + 373 - "โ”€".repeat(Bun.stringWidth(prologue) - 1) + 374 - "โ”ค\n" + 375 - msg.trimEnd() + 376 - "\n" + 377 - "โ”€".repeat(Bun.stringWidth(prologue) - 1) + 378 - "โ”ค", 379 - ); 380 - if (this.log_to_file && this.filewriter) { 381 - this.filewriter.write( 382 - prologue + 383 - "\n" + 384 - "\n" + 385 - msg + 386 - "\n" + 387 - "โ”€".repeat(Bun.stringWidth(prologue) - 1) + 388 - "โ”ค\n", 389 - ); 390 - } 391 - return; 392 - } 393 - while (Bun.stringWidth(msg) > this.message_maximum_length) { 394 - let part: string = msg.slice(0, this.message_maximum_length); 361 + let prologue = prefix + descript + "โ”‚"; 362 + if (msg.includes("\n")) { 363 + // We do not even try to split lines nicely here, give them their own lines. 364 + printer( 365 + prologue + 366 + "\n" + 367 + "โ”€".repeat(Bun.stringWidth(prologue) - 1) + 368 + "โ”ค\n" + 369 + msg.trimEnd() + 370 + "\n" + 371 + "โ”€".repeat(Bun.stringWidth(prologue) - 1) + 372 + "โ”ค", 373 + ); 374 + if (this.log_to_file && this.filewriter) { 375 + this.filewriter.write( 376 + prologue + 377 + "\n" + 378 + "\n" + 379 + msg + 380 + "\n" + 381 + "โ”€".repeat(Bun.stringWidth(prologue) - 1) + 382 + "โ”ค\n", 383 + ); 384 + } 385 + return; 386 + } 387 + while (Bun.stringWidth(msg) > this.message_maximum_length) { 388 + let part: string = msg.slice(0, this.message_maximum_length); 395 389 396 - msg = msg.slice(this.message_maximum_length); 390 + msg = msg.slice(this.message_maximum_length); 397 391 398 - printer(prologue, part); 399 - if (this.log_to_file && this.filewriter) { 400 - this.filewriter.write(prologue + " " + part + "\n"); 401 - } 402 - prologue = " ".repeat(Bun.stringWidth(prologue) - 1) + "|"; 403 - } 404 - printer(prologue, msg); 405 - if (this.log_to_file && this.filewriter) { 406 - this.filewriter.write(prologue + " " + msg + "\n"); 407 - this.filewriter.flush(); 408 - } 409 - } 392 + printer(prologue, part); 393 + if (this.log_to_file && this.filewriter) { 394 + this.filewriter.write(prologue + " " + part + "\n"); 395 + } 396 + prologue = " ".repeat(Bun.stringWidth(prologue) - 1) + "|"; 397 + } 398 + printer(prologue, msg); 399 + if (this.log_to_file && this.filewriter) { 400 + this.filewriter.write(prologue + " " + msg + "\n"); 401 + this.filewriter.flush(); 402 + } 403 + } 410 404 }
+22 -25
server/index.ts
··· 1 1 import fetch_routes from "./routes/fetch"; 2 2 import routes from "./routes"; 3 - import {websocket} from "./routes/websocket"; 3 + import { websocket } from "./routes/websocket"; 4 4 import StrawmediaJuiceServerApplication from "./app.class"; 5 5 import * as Bun from "bun"; 6 6 ··· 8 8 // And download http://strawmelonjuice.com/assets/site_icon.png as strawmelonjuice.png to 00000000-0000-0000-0000-000000000001 9 9 // We do this after initialisation because it may run async :) 10 10 { 11 - if ( 12 - !(await Bun.file( 13 - "./data/files/00000000-0000-0000-0000-000000000001/strawmelonjuice.png", 14 - ).exists()) 15 - ) { 16 - const sampleId = "00000000-0000-0000-0000-000000000001"; 17 - const sampleFilename = "strawmelonjuice.png"; 18 - app.db.exec( 19 - `INSERT OR IGNORE INTO file_mapping (id, filename) VALUES (?, ?) 11 + if ( 12 + !(await Bun.file( 13 + "./data/files/00000000-0000-0000-0000-000000000001/strawmelonjuice.png", 14 + ).exists()) 15 + ) { 16 + const sampleId = "00000000-0000-0000-0000-000000000001"; 17 + const sampleFilename = "strawmelonjuice.png"; 18 + app.db.exec( 19 + `INSERT OR IGNORE INTO file_mapping (id, filename) VALUES (?, ?) 20 20 `, 21 - [sampleId, sampleFilename], 22 - ); 23 - const response = await Bun.fetch( 24 - "https://strawmelonjuice.com/assets/site_icon.png", 25 - ); 26 - const arrayBuffer = await response.arrayBuffer(); 27 - await Bun.write( 28 - `./data/files/${sampleId}/${sampleFilename}`, 29 - arrayBuffer, 30 - ); 31 - } 21 + [sampleId, sampleFilename], 22 + ); 23 + const response = await Bun.fetch( 24 + "https://strawmelonjuice.com/assets/site_icon.png", 25 + ); 26 + const arrayBuffer = await response.arrayBuffer(); 27 + await Bun.write(`./data/files/${sampleId}/${sampleFilename}`, arrayBuffer); 28 + } 32 29 } 33 30 34 31 // Log the server start 35 32 app.logging.success("Server starting on http://localhost:" + app.port); 36 33 37 34 Bun.serve({ 38 - port: app.port, 39 - routes: routes(app), 40 - fetch: fetch_routes(app), 41 - websocket: websocket(app), 35 + port: app.port, 36 + routes: routes(app), 37 + fetch: fetch_routes(app), 38 + websocket: websocket(app), 42 39 });
+8 -8
server/logging/webconnection.ts
··· 1 1 export type webconnection = HTTP | Websocket; 2 2 3 3 interface HTTP { 4 - value: "HTTP"; 4 + value: "HTTP"; 5 5 } 6 6 7 7 interface Websocket { 8 - value: "Websocket"; 8 + value: "Websocket"; 9 9 } 10 10 11 11 export default { 12 - HTTP: { 13 - value: "HTTP", 14 - } as HTTP, 15 - Websocket: { 16 - value: "Websocket", 17 - } as Websocket, 12 + HTTP: { 13 + value: "HTTP", 14 + } as HTTP, 15 + Websocket: { 16 + value: "Websocket", 17 + } as Websocket, 18 18 };
+60 -60
server/logging/webconnectionevent.ts
··· 1 1 export type WebConnectionEvent = WebConnectionEventBase; 2 2 3 3 interface WebConnectionEventBase { 4 - readonly value: string; 5 - readonly http_code: number | string; 6 - readonly websocket_code: number | string; 7 - readonly description: string; 4 + readonly value: string; 5 + readonly http_code: number | string; 6 + readonly websocket_code: number | string; 7 + readonly description: string; 8 8 } 9 9 10 10 export const Found: WebConnectionEventBase = { 11 - value: "Found", 12 - http_code: 302, 13 - websocket_code: 1000, 14 - description: "Found; redirected", 11 + value: "Found", 12 + http_code: 302, 13 + websocket_code: 1000, 14 + description: "Found; redirected", 15 15 }; 16 16 17 17 export class UnknownCodeClose implements WebConnectionEventBase { 18 - readonly value = "UnknownCodeClose"; 19 - readonly http_code: number | string; 20 - readonly websocket_code: number | string; 21 - readonly description: string; 18 + readonly value = "UnknownCodeClose"; 19 + readonly http_code: number | string; 20 + readonly websocket_code: number | string; 21 + readonly description: string; 22 22 23 - constructor(all: { 24 - http_code?: number; 25 - websocket_code?: number | string; 26 - description?: string; 27 - }) { 28 - this.http_code = all.http_code ?? "N/A"; 29 - this.websocket_code = all.websocket_code ?? "????"; 23 + constructor(all: { 24 + http_code?: number; 25 + websocket_code?: number | string; 26 + description?: string; 27 + }) { 28 + this.http_code = all.http_code ?? "N/A"; 29 + this.websocket_code = all.websocket_code ?? "????"; 30 30 31 - if (all.description) { 32 - this.description = all.description; 33 - } else { 34 - this.description = "Closed; unknown reason"; 35 - } 31 + if (all.description) { 32 + this.description = all.description; 33 + } else { 34 + this.description = "Closed; unknown reason"; 36 35 } 36 + } 37 37 } 38 38 39 39 export const ResourceNotFound: WebConnectionEventBase = { 40 - value: "ResourceNotFound", 41 - http_code: 404, 42 - websocket_code: 1002, 43 - description: "Not found", 40 + value: "ResourceNotFound", 41 + http_code: 404, 42 + websocket_code: 1002, 43 + description: "Not found", 44 44 }; 45 45 46 46 export const NotAuthenticated: WebConnectionEventBase = { 47 - value: "NotAuthenticated", 48 - http_code: 401, 49 - websocket_code: 1008, 50 - description: "Not authenticated", 47 + value: "NotAuthenticated", 48 + http_code: 401, 49 + websocket_code: 1008, 50 + description: "Not authenticated", 51 51 }; 52 52 export const Forbidden: WebConnectionEventBase = { 53 - value: "Forbidden", 54 - http_code: 403, 55 - websocket_code: 1008, 56 - description: "Forbidden", 53 + value: "Forbidden", 54 + http_code: 403, 55 + websocket_code: 1008, 56 + description: "Forbidden", 57 57 }; 58 58 59 59 export const CloseOk: WebConnectionEventBase = { 60 - value: "CloseOk", 61 - http_code: 200, 62 - websocket_code: 1000, 63 - description: "OK;closed", 60 + value: "CloseOk", 61 + http_code: 200, 62 + websocket_code: 1000, 63 + description: "OK;closed", 64 64 }; 65 65 66 66 export const UpgradeWebsocket: WebConnectionEventBase = { 67 - value: "UpgradeWebsocket", 68 - http_code: 101, 69 - websocket_code: "OPEN", 70 - description: "Upgraded to websocket", 67 + value: "UpgradeWebsocket", 68 + http_code: 101, 69 + websocket_code: "OPEN", 70 + description: "Upgraded to websocket", 71 71 }; 72 72 73 73 export const InvalidRequest: WebConnectionEventBase = { 74 - value: "InvalidRequest", 75 - http_code: 400, 76 - websocket_code: 1002, 77 - description: "Invalid request", 74 + value: "InvalidRequest", 75 + http_code: 400, 76 + websocket_code: 1002, 77 + description: "Invalid request", 78 78 }; 79 79 export const ServerError: WebConnectionEventBase = { 80 - value: "ServerError", 81 - http_code: 500, 82 - websocket_code: 1011, 83 - description: "Internal server error", 80 + value: "ServerError", 81 + http_code: 500, 82 + websocket_code: 1011, 83 + description: "Internal server error", 84 84 }; 85 85 export const Custom_StorageLimitExceeded: WebConnectionEventBase = { 86 - value: "Custom_StorageLimitExceeded", 87 - http_code: 507, 88 - description: "Storage limit exceeded", 89 - websocket_code: 4003, 86 + value: "Custom_StorageLimitExceeded", 87 + http_code: 507, 88 + description: "Storage limit exceeded", 89 + websocket_code: 4003, 90 90 }; 91 91 export const BadRequest: WebConnectionEventBase = { 92 - value: "BadRequest", 93 - http_code: 400, 94 - websocket_code: 1003, 95 - description: "Bad request", 92 + value: "BadRequest", 93 + http_code: 400, 94 + websocket_code: 1003, 95 + description: "Bad request", 96 96 };
+492 -538
server/routes.ts
··· 2 2 import { type BunRequest } from "bun"; 3 3 import webconnection from "./logging/webconnection"; 4 4 import { 5 - BadRequest, 6 - CloseOk, 7 - Forbidden, 8 - Found, 9 - NotAuthenticated, 10 - ResourceNotFound, 11 - ServerError, 5 + BadRequest, 6 + CloseOk, 7 + Forbidden, 8 + Found, 9 + NotAuthenticated, 10 + ResourceNotFound, 11 + ServerError, 12 12 } from "./logging/webconnectionevent"; 13 13 import type StrawmediaJuiceServerApplication from "./app.class"; 14 14 15 15 export default (app: StrawmediaJuiceServerApplication) => { 16 - return { 17 - // "/": dashboard, 18 - "/": async (): Promise<Response> => { 19 - app.logging.webevent(webconnection.HTTP, CloseOk, "/"); 16 + return { 17 + // "/": dashboard, 18 + "/": async (): Promise<Response> => { 19 + app.logging.webevent(webconnection.HTTP, CloseOk, "/"); 20 20 21 - //@ts-expect-error dashboard is html text due to import with type text, yet typescript thinks it's a htmlbundle object 22 - return new Response(dashboard, { 23 - headers: { 24 - "Content-Type": "text/html", 25 - }, 26 - }); 27 - }, 28 - // Serve a file from the ./data/files/ directory by ID. Sent here from /file/:fid or /file/:fid/: 29 - "/file/*": async (req: BunRequest) => { 30 - const fid = new URL(req.url).pathname.split("/file/")[1]; 31 - if (!fid) { 32 - app.logging.webevent( 33 - webconnection.HTTP, 34 - BadRequest, 35 - "/file/ (no fid)", 36 - ); 37 - return new Response("Bad Request: No file ID specified.", { 38 - status: 400, 39 - }); 40 - } 41 - const fidpath = fid.split("/"); 42 - const fileId = fidpath[0]!; // Remove any trailing slash or filename 43 - const maybeRequestFilename = fidpath.slice(1).join("/"); // In case of /file/:fid/filename.ext, this is "filename.ext" 44 - try { 45 - const statement = app.db.query( 46 - `SELECT filename FROM file_mapping WHERE id = ?1`, 47 - ); 48 - const result = statement.get(fileId) as { 49 - filename: string; 50 - } | null; 51 - if (!result) { 52 - app.logging.webevent( 53 - webconnection.HTTP, 54 - ResourceNotFound, 55 - "/file/" + fileId, 56 - ); 57 - return new Response("File not found", { status: 404 }); 58 - } 21 + //@ts-expect-error dashboard is html text due to import with type text, yet typescript thinks it's a htmlbundle object 22 + return new Response(dashboard, { 23 + headers: { 24 + "Content-Type": "text/html", 25 + }, 26 + }); 27 + }, 28 + // Serve a file from the ./data/files/ directory by ID. Sent here from /file/:fid or /file/:fid/: 29 + "/file/*": async (req: BunRequest) => { 30 + const fid = new URL(req.url).pathname.split("/file/")[1]; 31 + if (!fid) { 32 + app.logging.webevent(webconnection.HTTP, BadRequest, "/file/ (no fid)"); 33 + return new Response("Bad Request: No file ID specified.", { 34 + status: 400, 35 + }); 36 + } 37 + const fidpath = fid.split("/"); 38 + const fileId = fidpath[0]!; // Remove any trailing slash or filename 39 + const maybeRequestFilename = fidpath.slice(1).join("/"); // In case of /file/:fid/filename.ext, this is "filename.ext" 40 + try { 41 + const statement = app.db.query( 42 + `SELECT filename FROM file_mapping WHERE id = ?1`, 43 + ); 44 + const result = statement.get(fileId) as { 45 + filename: string; 46 + } | null; 47 + if (!result) { 48 + app.logging.webevent( 49 + webconnection.HTTP, 50 + ResourceNotFound, 51 + "/file/" + fileId, 52 + ); 53 + return new Response("File not found", { status: 404 }); 54 + } 59 55 60 - const filename: string = result.filename; 61 - // If a specific filename was requested, ensure it matches the stored filename 62 - if (maybeRequestFilename && maybeRequestFilename !== filename) { 63 - app.logging.webevent( 64 - webconnection.HTTP, 65 - BadRequest, 66 - "/file/" + fileId + "/" + maybeRequestFilename, 67 - ); 68 - return new Response( 69 - "Bad Request: Your requested filename does not match the known name for this file. Try again without filename or with a correct one.", 70 - { status: 400 }, 71 - ); 72 - } 73 - const filePath = `./data/files/${fileId}/${filename}`; 74 - const file = Bun.file(filePath); 56 + const filename: string = result.filename; 57 + // If a specific filename was requested, ensure it matches the stored filename 58 + if (maybeRequestFilename && maybeRequestFilename !== filename) { 59 + app.logging.webevent( 60 + webconnection.HTTP, 61 + BadRequest, 62 + "/file/" + fileId + "/" + maybeRequestFilename, 63 + ); 64 + return new Response( 65 + "Bad Request: Your requested filename does not match the known name for this file. Try again without filename or with a correct one.", 66 + { status: 400 }, 67 + ); 68 + } 69 + const filePath = `./data/files/${fileId}/${filename}`; 70 + const file = Bun.file(filePath); 75 71 76 - if (!(await file.exists())) { 77 - app.logging.webevent( 78 - webconnection.HTTP, 79 - ResourceNotFound, 80 - "/file/" + fid, 81 - ); 82 - return new Response("File not found", { status: 404 }); 83 - } 72 + if (!(await file.exists())) { 73 + app.logging.webevent( 74 + webconnection.HTTP, 75 + ResourceNotFound, 76 + "/file/" + fid, 77 + ); 78 + return new Response("File not found", { status: 404 }); 79 + } 84 80 85 - app.logging.webevent( 86 - webconnection.HTTP, 87 - CloseOk, 88 - "/file/" + fileId + " (" + filename + ")", 89 - ); 90 - return new Response(file.stream(), { 91 - headers: { 92 - "Content-Type": file.type || "application/octet-stream", 93 - }, 94 - }); 95 - } catch (error) { 96 - app.logging.error_described( 97 - "File System Error", 98 - "Error fetching file:", 99 - error, 100 - ); 101 - app.logging.webevent( 102 - webconnection.HTTP, 103 - ServerError, 104 - "/file/" + fid, 105 - ); 106 - return new Response("Internal Server Error", { status: 500 }); 107 - } 108 - }, 81 + app.logging.webevent( 82 + webconnection.HTTP, 83 + CloseOk, 84 + "/file/" + fileId + " (" + filename + ")", 85 + ); 86 + return new Response(file.stream(), { 87 + headers: { 88 + "Content-Type": file.type || "application/octet-stream", 89 + }, 90 + }); 91 + } catch (error) { 92 + app.logging.error_described( 93 + "File System Error", 94 + "Error fetching file:", 95 + error, 96 + ); 97 + app.logging.webevent(webconnection.HTTP, ServerError, "/file/" + fid); 98 + return new Response("Internal Server Error", { status: 500 }); 99 + } 100 + }, 109 101 110 - // Authentication route 111 - "/api/auth": { 112 - POST: async (req: BunRequest) => { 113 - try { 114 - const body = (await req.json()) as 115 - | { 116 - username: string; 117 - password: string; 118 - } 119 - | { 120 - session: string; 121 - }; 122 - if ("session" in body) { 123 - // Session validation request 124 - // Check if session is valid 125 - const isValid = app 126 - .helper() 127 - .isSessionValid(body.session); 128 - if (isValid) { 129 - const username = app 130 - .helper() 131 - .getUsernameFromId(isValid); 132 - app.logging.webevent( 133 - webconnection.HTTP, 134 - CloseOk, 135 - "/api/auth [session validation]", 136 - ); 137 - return new Response(body.session + ":" + username, { 138 - status: 200, 139 - }); 140 - } else { 141 - app.logging.webevent( 142 - webconnection.HTTP, 143 - NotAuthenticated, 144 - "/api/auth [session validation]", 145 - ); 146 - return new Response("Unauthorized", { 147 - status: 401, 148 - }); 149 - } 150 - } 151 - const { username, password } = body; 102 + // Authentication route 103 + "/api/auth": { 104 + POST: async (req: BunRequest) => { 105 + try { 106 + const body = (await req.json()) as 107 + | { 108 + username: string; 109 + password: string; 110 + } 111 + | { 112 + session: string; 113 + }; 114 + if ("session" in body) { 115 + // Session validation request 116 + // Check if session is valid 117 + const isValid = app.helper().isSessionValid(body.session); 118 + if (isValid) { 119 + const username = app.helper().getUsernameFromId(isValid); 120 + app.logging.webevent( 121 + webconnection.HTTP, 122 + CloseOk, 123 + "/api/auth [session validation]", 124 + ); 125 + return new Response(body.session + ":" + username, { 126 + status: 200, 127 + }); 128 + } else { 129 + app.logging.webevent( 130 + webconnection.HTTP, 131 + NotAuthenticated, 132 + "/api/auth [session validation]", 133 + ); 134 + return new Response("Unauthorized", { 135 + status: 401, 136 + }); 137 + } 138 + } 139 + const { username, password } = body; 152 140 153 - const statement = app.db.query( 154 - `SELECT password_hash FROM users WHERE username = ?1`, 155 - ); 156 - const result = statement.get(username) as { 157 - password_hash: string; 158 - } | null; 141 + const statement = app.db.query( 142 + `SELECT password_hash FROM users WHERE username = ?1`, 143 + ); 144 + const result = statement.get(username) as { 145 + password_hash: string; 146 + } | null; 159 147 160 - if (!result) { 161 - app.logging.webevent( 162 - webconnection.HTTP, 163 - NotAuthenticated, 164 - "/api/auth [" + username + "]", 165 - ); 166 - return new Response("Unauthorized", { status: 401 }); 167 - } 148 + if (!result) { 149 + app.logging.webevent( 150 + webconnection.HTTP, 151 + NotAuthenticated, 152 + "/api/auth [" + username + "]", 153 + ); 154 + return new Response("Unauthorized", { status: 401 }); 155 + } 168 156 169 - const isMatch = await Bun.password.verify( 170 - password, 171 - result.password_hash, 172 - ); 157 + const isMatch = await Bun.password.verify( 158 + password, 159 + result.password_hash, 160 + ); 173 161 174 - const uid = app.helper().getIdfromUsername(username); 162 + const uid = app.helper().getIdfromUsername(username); 175 163 176 - if (isMatch && uid) { 177 - const sid = app.helper().createSession(uid); 178 - app.logging.webevent( 179 - webconnection.HTTP, 180 - CloseOk, 181 - "/api/auth [" + username + "] -> " + sid, 182 - ); 164 + if (isMatch && uid) { 165 + const sid = app.helper().createSession(uid); 166 + app.logging.webevent( 167 + webconnection.HTTP, 168 + CloseOk, 169 + "/api/auth [" + username + "] -> " + sid, 170 + ); 183 171 184 - return new Response(sid, { status: 200 }); 185 - } else { 186 - app.logging.webevent( 187 - webconnection.HTTP, 188 - NotAuthenticated, 189 - "/api/auth [" + username + "]", 190 - ); 191 - return new Response("Unauthorized", { status: 401 }); 192 - } 193 - } catch (error) { 194 - app.logging.error("Error in /auth route:", error); 195 - app.logging.webevent( 196 - webconnection.HTTP, 197 - BadRequest, 198 - "/api/auth", 199 - ); 200 - return new Response("Bad Request", { status: 400 }); 201 - } 202 - }, 203 - }, 204 - "/api/session/destroy": { 205 - POST: async (req: BunRequest) => { 206 - try { 207 - const body = (await req.json()) as { session: string }; 208 - const { session } = body; 172 + return new Response(sid, { status: 200 }); 173 + } else { 174 + app.logging.webevent( 175 + webconnection.HTTP, 176 + NotAuthenticated, 177 + "/api/auth [" + username + "]", 178 + ); 179 + return new Response("Unauthorized", { status: 401 }); 180 + } 181 + } catch (error) { 182 + app.logging.error("Error in /auth route:", error); 183 + app.logging.webevent(webconnection.HTTP, BadRequest, "/api/auth"); 184 + return new Response("Bad Request", { status: 400 }); 185 + } 186 + }, 187 + }, 188 + "/api/session/destroy": { 189 + POST: async (req: BunRequest) => { 190 + try { 191 + const body = (await req.json()) as { session: string }; 192 + const { session } = body; 209 193 210 - const isValid = app.helper().isSessionValid(session); 211 - if (isValid) { 212 - app.helper().destroySession(session); 213 - app.logging.webevent( 214 - webconnection.HTTP, 215 - CloseOk, 216 - "/api/session/destroy", 217 - ); 218 - return new Response("OK", { status: 200 }); 219 - } 220 - app.logging.webevent( 221 - webconnection.HTTP, 222 - NotAuthenticated, 223 - "/api/session/destroy", 224 - ); 225 - return new Response("Unauthorized", { status: 401 }); 226 - } catch (error) { 227 - app.logging.error( 228 - "Error in /api/session/destroy route:", 229 - error, 230 - ); 231 - app.logging.webevent( 232 - webconnection.HTTP, 233 - BadRequest, 234 - "/api/session/destroy", 235 - ); 236 - return new Response("Bad Request", { status: 400 }); 237 - } 238 - }, 239 - }, 194 + const isValid = app.helper().isSessionValid(session); 195 + if (isValid) { 196 + app.helper().destroySession(session); 197 + app.logging.webevent( 198 + webconnection.HTTP, 199 + CloseOk, 200 + "/api/session/destroy", 201 + ); 202 + return new Response("OK", { status: 200 }); 203 + } 204 + app.logging.webevent( 205 + webconnection.HTTP, 206 + NotAuthenticated, 207 + "/api/session/destroy", 208 + ); 209 + return new Response("Unauthorized", { status: 401 }); 210 + } catch (error) { 211 + app.logging.error("Error in /api/session/destroy route:", error); 212 + app.logging.webevent( 213 + webconnection.HTTP, 214 + BadRequest, 215 + "/api/session/destroy", 216 + ); 217 + return new Response("Bad Request", { status: 400 }); 218 + } 219 + }, 220 + }, 240 221 241 - // File upload route 242 - "/api/upload": { 243 - POST: async (req: BunRequest) => { 244 - try { 245 - const body = (await req.json()) as { 246 - session: string; 247 - filename: string; 248 - content: string; 249 - }; 250 - const { session, filename, content } = body; 222 + // File upload route 223 + "/api/upload": { 224 + POST: async (req: BunRequest) => { 225 + try { 226 + const body = (await req.json()) as { 227 + session: string; 228 + filename: string; 229 + content: string; 230 + }; 231 + const { session, filename, content } = body; 251 232 252 - const result = app.helper().getUserFromSession(session); 233 + const result = app.helper().getUserFromSession(session); 253 234 254 - if (!result) { 255 - app.logging.webevent( 256 - webconnection.HTTP, 257 - NotAuthenticated, 258 - "/api/upload [invalid session]", 259 - ); 260 - return new Response("Unauthorized", { status: 401 }); 261 - } 262 - const user = result; 263 - const fileId = Bun.randomUUIDv7(); 264 - const filePath = `./data/files/${fileId}/${filename}`; 235 + if (!result) { 236 + app.logging.webevent( 237 + webconnection.HTTP, 238 + NotAuthenticated, 239 + "/api/upload [invalid session]", 240 + ); 241 + return new Response("Unauthorized", { status: 401 }); 242 + } 243 + const user = result; 244 + const fileId = Bun.randomUUIDv7(); 245 + const filePath = `./data/files/${fileId}/${filename}`; 265 246 266 - const fileBuffer = Buffer.from(content, "base64"); 267 - await Bun.write(filePath, fileBuffer); 247 + const fileBuffer = Buffer.from(content, "base64"); 248 + await Bun.write(filePath, fileBuffer); 268 249 269 - // Calculate file size in megabytes 270 - const fileSizeMB = fileBuffer.length / (1024 * 1024); 250 + // Calculate file size in megabytes 251 + const fileSizeMB = fileBuffer.length / (1024 * 1024); 271 252 272 - // Get user's current storage usage 273 - const userStorage = { 274 - used_megabytes: user.used_megabytes, 275 - max_megabytes: user.max_megabytes, 276 - }; 253 + // Get user's current storage usage 254 + const userStorage = { 255 + used_megabytes: user.used_megabytes, 256 + max_megabytes: user.max_megabytes, 257 + }; 277 258 278 - // Check if user has enough storage space 279 - if ( 280 - userStorage.max_megabytes !== 0 && 281 - userStorage.used_megabytes + fileSizeMB > 282 - userStorage.max_megabytes 283 - ) { 284 - return new Response("Storage quota exceeded", { 285 - status: 413, 286 - }); 287 - } 259 + // Check if user has enough storage space 260 + if ( 261 + userStorage.max_megabytes !== 0 && 262 + userStorage.used_megabytes + fileSizeMB > userStorage.max_megabytes 263 + ) { 264 + return new Response("Storage quota exceeded", { 265 + status: 413, 266 + }); 267 + } 288 268 289 - // Start a transaction to ensure data consistency 290 - app.db.transaction(() => { 291 - // Insert file mapping with owner 292 - app.db.exec( 293 - `INSERT INTO file_mapping (id, filename, owner, owner_uid) VALUES (?1, ?2, ?3)`, 294 - [fileId, filename, user.username, user.uid], 295 - ); 269 + // Start a transaction to ensure data consistency 270 + app.db.transaction(() => { 271 + // Insert file mapping with owner 272 + app.db.exec( 273 + `INSERT INTO file_mapping (id, filename, owner, owner_uid) VALUES (?1, ?2, ?3)`, 274 + [fileId, filename, user.username, user.uid], 275 + ); 296 276 297 - // Update user's storage usage 298 - app.db.exec( 299 - `UPDATE users SET used_megabytes = used_megabytes + ?1 WHERE uid = ?2`, 300 - [fileSizeMB, user.uid], 301 - ); 302 - })(); 277 + // Update user's storage usage 278 + app.db.exec( 279 + `UPDATE users SET used_megabytes = used_megabytes + ?1 WHERE uid = ?2`, 280 + [fileSizeMB, user.uid], 281 + ); 282 + })(); 303 283 304 - app.logging.webevent( 305 - webconnection.HTTP, 306 - CloseOk, 307 - "/api/upload [" + user.username + "] -> " + fileId, 308 - ); 309 - return new Response(JSON.stringify({ id: fileId }), { 310 - status: 200, 311 - headers: { "Content-Type": "application/json" }, 312 - }); 313 - } catch (error) { 314 - app.logging.error("Error in /upload route:", error); 315 - app.logging.webevent( 316 - webconnection.HTTP, 317 - ServerError, 318 - "/api/upload", 319 - ); 320 - return new Response("Internal Server Error", { 321 - status: 500, 322 - }); 323 - } 324 - }, 325 - }, 326 - // List files route 327 - "/api/files": { 328 - POST: async (req: BunRequest) => { 329 - try { 330 - const body = (await req.json()) as { 331 - session: string; 332 - }; 333 - const { session } = body; 284 + app.logging.webevent( 285 + webconnection.HTTP, 286 + CloseOk, 287 + "/api/upload [" + user.username + "] -> " + fileId, 288 + ); 289 + return new Response(JSON.stringify({ id: fileId }), { 290 + status: 200, 291 + headers: { "Content-Type": "application/json" }, 292 + }); 293 + } catch (error) { 294 + app.logging.error("Error in /upload route:", error); 295 + app.logging.webevent(webconnection.HTTP, ServerError, "/api/upload"); 296 + return new Response("Internal Server Error", { 297 + status: 500, 298 + }); 299 + } 300 + }, 301 + }, 302 + // List files route 303 + "/api/files": { 304 + POST: async (req: BunRequest) => { 305 + try { 306 + const body = (await req.json()) as { 307 + session: string; 308 + }; 309 + const { session } = body; 334 310 335 - // Get user ID and username from session 311 + // Get user ID and username from session 336 312 337 - const user = app.helper().getUserFromSession(session); 313 + const user = app.helper().getUserFromSession(session); 338 314 339 - if (!user) { 340 - app.logging.webevent( 341 - webconnection.HTTP, 342 - NotAuthenticated, 343 - "/api/files [invalid session]", 344 - ); 345 - return new Response("Unauthorized", { status: 401 }); 346 - } 315 + if (!user) { 316 + app.logging.webevent( 317 + webconnection.HTTP, 318 + NotAuthenticated, 319 + "/api/files [invalid session]", 320 + ); 321 + return new Response("Unauthorized", { status: 401 }); 322 + } 347 323 348 - // Query files based on user permissions 349 - const filesQuery = user.is_admin 350 - ? app.db.query( 351 - `SELECT id, owner, filename FROM file_mapping`, 352 - ) 353 - : app.db.prepare( 354 - `SELECT id, owner, filename FROM file_mapping WHERE owner = ?1 OR owner_uid = ?2`, 355 - [user.username, user.uid], 356 - ); 324 + // Query files based on user permissions 325 + const filesQuery = user.is_admin 326 + ? app.db.query(`SELECT id, owner, filename FROM file_mapping`) 327 + : app.db.prepare( 328 + `SELECT id, owner, filename FROM file_mapping WHERE owner = ?1 OR owner_uid = ?2`, 329 + [user.username, user.uid], 330 + ); 357 331 358 - const files = filesQuery.all(); 332 + const files = filesQuery.all(); 359 333 360 - // Add file sizes to the response 361 - const filesWithSizes = await Promise.all( 362 - files.map(async (file_) => { 363 - const file = file_ as { 364 - filename: string; 365 - id: string; 366 - }; 367 - const filePath = `./data/files/${file.id}/${file.filename}`; 368 - const fileObj = Bun.file(filePath); 369 - let size = 0; 334 + // Add file sizes to the response 335 + const filesWithSizes = await Promise.all( 336 + files.map(async (file_) => { 337 + const file = file_ as { 338 + filename: string; 339 + id: string; 340 + }; 341 + const filePath = `./data/files/${file.id}/${file.filename}`; 342 + const fileObj = Bun.file(filePath); 343 + let size = 0; 370 344 371 - if (await fileObj.exists()) { 372 - size = fileObj.size / (1024 * 1024); // Convert to MB 373 - } 345 + if (await fileObj.exists()) { 346 + size = fileObj.size / (1024 * 1024); // Convert to MB 347 + } 374 348 375 - return { 376 - ...file, 377 - size_mb: Number(size), // Round to 2 decimal places 378 - }; 379 - }), 380 - ); 349 + return { 350 + ...file, 351 + size_mb: Number(size), // Round to 2 decimal places 352 + }; 353 + }), 354 + ); 381 355 382 - app.logging.webevent( 383 - webconnection.HTTP, 384 - CloseOk, 385 - "/api/files [" + user.username + "]", 386 - ); 387 - return new Response(JSON.stringify(filesWithSizes), { 388 - status: 200, 389 - headers: { "Content-Type": "application/json" }, 390 - }); 391 - } catch (error) { 392 - app.logging.error("Error fetching file list:", error); 393 - app.logging.webevent( 394 - webconnection.HTTP, 395 - ServerError, 396 - "/api/files", 397 - ); 398 - return new Response("Internal Server Error", { 399 - status: 500, 400 - }); 401 - } 402 - }, 403 - }, 404 - // Fetches user details of current user, basically all except for the password hash. 405 - "/api/user/fetch": { 406 - POST: async (req: BunRequest) => { 407 - try { 408 - const body = (await req.json()) as { 409 - session: string; 410 - }; 411 - const { session } = body; 412 - // Verify session and get user info 356 + app.logging.webevent( 357 + webconnection.HTTP, 358 + CloseOk, 359 + "/api/files [" + user.username + "]", 360 + ); 361 + return new Response(JSON.stringify(filesWithSizes), { 362 + status: 200, 363 + headers: { "Content-Type": "application/json" }, 364 + }); 365 + } catch (error) { 366 + app.logging.error("Error fetching file list:", error); 367 + app.logging.webevent(webconnection.HTTP, ServerError, "/api/files"); 368 + return new Response("Internal Server Error", { 369 + status: 500, 370 + }); 371 + } 372 + }, 373 + }, 374 + // Fetches user details of current user, basically all except for the password hash. 375 + "/api/user/fetch": { 376 + POST: async (req: BunRequest) => { 377 + try { 378 + const body = (await req.json()) as { 379 + session: string; 380 + }; 381 + const { session } = body; 382 + // Verify session and get user info 413 383 414 - const result = app.helper().getUserFromSession(session); 384 + const result = app.helper().getUserFromSession(session); 415 385 416 - if (!result) { 417 - app.logging.webevent( 418 - webconnection.HTTP, 419 - NotAuthenticated, 420 - "/api/user/fetch [invalid session]", 421 - ); 422 - return new Response("Unauthorized", { status: 401 }); 423 - } 424 - const user = result; 386 + if (!result) { 387 + app.logging.webevent( 388 + webconnection.HTTP, 389 + NotAuthenticated, 390 + "/api/user/fetch [invalid session]", 391 + ); 392 + return new Response("Unauthorized", { status: 401 }); 393 + } 394 + const user = result; 425 395 426 - // Okay, authenticated! Now... 427 - app.logging.webevent( 428 - webconnection.HTTP, 429 - CloseOk, 430 - "/api/user/fetch [" + user.username + "]", 431 - ); 432 - return new Response(JSON.stringify(user), { 433 - status: 200, 434 - }); 435 - } catch (error) { 436 - app.logging.webevent( 437 - webconnection.HTTP, 438 - ServerError, 439 - "/api/user/fetch", 440 - ); 441 - return new Response("Internal Server Error", { 442 - status: 500, 443 - }); 444 - } 445 - }, 446 - // Delete file route 447 - "/api/files/delete": { 448 - POST: async (req: BunRequest) => { 449 - try { 450 - const body = (await req.json()) as { 451 - session: string; 452 - fileId: string; 453 - }; 454 - const { session, fileId } = body; 455 - // Get user ID and username from session 456 - const result = app.helper().getUserFromSession(session); 396 + // Okay, authenticated! Now... 397 + app.logging.webevent( 398 + webconnection.HTTP, 399 + CloseOk, 400 + "/api/user/fetch [" + user.username + "]", 401 + ); 402 + return new Response(JSON.stringify(user), { 403 + status: 200, 404 + }); 405 + } catch (error) { 406 + app.logging.webevent( 407 + webconnection.HTTP, 408 + ServerError, 409 + "/api/user/fetch", 410 + ); 411 + return new Response("Internal Server Error", { 412 + status: 500, 413 + }); 414 + } 415 + }, 416 + // Delete file route 417 + "/api/files/delete": { 418 + POST: async (req: BunRequest) => { 419 + try { 420 + const body = (await req.json()) as { 421 + session: string; 422 + fileId: string; 423 + }; 424 + const { session, fileId } = body; 425 + // Get user ID and username from session 426 + const result = app.helper().getUserFromSession(session); 457 427 458 - if (!result) { 459 - app.logging.webevent( 460 - webconnection.HTTP, 461 - NotAuthenticated, 462 - "/api/files/delete [invalid session]", 463 - ); 464 - return new Response("Unauthorized", { 465 - status: 401, 466 - }); 467 - } 468 - const user = result; 428 + if (!result) { 429 + app.logging.webevent( 430 + webconnection.HTTP, 431 + NotAuthenticated, 432 + "/api/files/delete [invalid session]", 433 + ); 434 + return new Response("Unauthorized", { 435 + status: 401, 436 + }); 437 + } 438 + const user = result; 469 439 470 - // Get file information 471 - const fileStatement = app.db.query( 472 - `SELECT filename, owner FROM file_mapping WHERE id = ?1`, 473 - ); 474 - const fileResult = fileStatement.get(fileId) as { 475 - filename: string; 476 - owner: string; 477 - } | null; 440 + // Get file information 441 + const fileStatement = app.db.query( 442 + `SELECT filename, owner FROM file_mapping WHERE id = ?1`, 443 + ); 444 + const fileResult = fileStatement.get(fileId) as { 445 + filename: string; 446 + owner: string; 447 + } | null; 478 448 479 - if (!fileResult) { 480 - app.logging.webevent( 481 - webconnection.HTTP, 482 - ResourceNotFound, 483 - "/api/files/delete [" + 484 - user.username + 485 - "] -> " + 486 - fileId, 487 - ); 488 - return new Response("File not found", { 489 - status: 404, 490 - }); 491 - } 449 + if (!fileResult) { 450 + app.logging.webevent( 451 + webconnection.HTTP, 452 + ResourceNotFound, 453 + "/api/files/delete [" + user.username + "] -> " + fileId, 454 + ); 455 + return new Response("File not found", { 456 + status: 404, 457 + }); 458 + } 492 459 493 - // Check if user has permission to delete the file 494 - if (!user.is_admin && fileResult.owner !== user.uid) { 495 - app.logging.webevent( 496 - webconnection.HTTP, 497 - Forbidden, 498 - "/api/files/delete [" + 499 - user.username + 500 - "] -> " + 501 - fileId, 502 - ); 503 - return new Response("Forbidden", { status: 403 }); 504 - } 460 + // Check if user has permission to delete the file 461 + if (!user.is_admin && fileResult.owner !== user.uid) { 462 + app.logging.webevent( 463 + webconnection.HTTP, 464 + Forbidden, 465 + "/api/files/delete [" + user.username + "] -> " + fileId, 466 + ); 467 + return new Response("Forbidden", { status: 403 }); 468 + } 505 469 506 - // Get file size to update quota 507 - const filePath = `./data/files/${fileId}/${fileResult.filename}`; 508 - const file = Bun.file(filePath); 509 - let fileSizeMB = 0; 470 + // Get file size to update quota 471 + const filePath = `./data/files/${fileId}/${fileResult.filename}`; 472 + const file = Bun.file(filePath); 473 + let fileSizeMB = 0; 510 474 511 - if (await file.exists()) { 512 - fileSizeMB = file.size / (1024 * 1024); 513 - } 475 + if (await file.exists()) { 476 + fileSizeMB = file.size / (1024 * 1024); 477 + } 514 478 515 - // Start a transaction for the delete operation 516 - app.db.transaction(() => { 517 - // Update the owner's used storage quota 518 - if (fileResult.owner !== "server") { 519 - // Don't update quota for system files 520 - app.db.exec( 521 - `UPDATE users SET used_megabytes = CASE WHEN used_megabytes - ?1 < 0 THEN 0 ELSE used_megabytes - ?1 END WHERE username = ?2`, 522 - [fileSizeMB, fileResult.owner], 523 - ); 524 - } 479 + // Start a transaction for the delete operation 480 + app.db.transaction(() => { 481 + // Update the owner's used storage quota 482 + if (fileResult.owner !== "server") { 483 + // Don't update quota for system files 484 + app.db.exec( 485 + `UPDATE users SET used_megabytes = CASE WHEN used_megabytes - ?1 < 0 THEN 0 ELSE used_megabytes - ?1 END WHERE username = ?2`, 486 + [fileSizeMB, fileResult.owner], 487 + ); 488 + } 525 489 526 - // Delete the file record from the database 527 - app.db.exec( 528 - `DELETE FROM file_mapping WHERE id = ?1`, 529 - [fileId], 530 - ); 531 - })(); 490 + // Delete the file record from the database 491 + app.db.exec(`DELETE FROM file_mapping WHERE id = ?1`, [fileId]); 492 + })(); 532 493 533 - // Delete the actual file 534 - try { 535 - await Bun.write(filePath, ""); // Clear the file content 536 - await new Response(null, { status: 204 }).blob(); // Force file handle closure 537 - Bun.spawnSync(["rm", "-f", filePath]); // Remove the file 538 - Bun.spawnSync(["rmdir", `./data/files/${fileId}`]); // Try to remove the directory 539 - } catch (error) { 540 - app.logging.error_described( 541 - "File System Error", 542 - "Error cleaning up file:", 543 - error, 544 - ); 545 - // We continue even if physical deletion fails, since the DB is already updated 546 - } 494 + // Delete the actual file 495 + try { 496 + await Bun.write(filePath, ""); // Clear the file content 497 + await new Response(null, { status: 204 }).blob(); // Force file handle closure 498 + Bun.spawnSync(["rm", "-f", filePath]); // Remove the file 499 + Bun.spawnSync(["rmdir", `./data/files/${fileId}`]); // Try to remove the directory 500 + } catch (error) { 501 + app.logging.error_described( 502 + "File System Error", 503 + "Error cleaning up file:", 504 + error, 505 + ); 506 + // We continue even if physical deletion fails, since the DB is already updated 507 + } 547 508 548 - app.logging.webevent( 549 - webconnection.HTTP, 550 - CloseOk, 551 - "/api/files/delete [" + 552 - user.username + 553 - "] -> " + 554 - fileId, 555 - ); 556 - return new Response("OK", { status: 200 }); 557 - } catch (error) { 558 - app.logging.error( 559 - "Error deleting file in /api/files/delete route:", 560 - error, 561 - ); 562 - app.logging.webevent( 563 - webconnection.HTTP, 564 - ServerError, 565 - "/api/files/delete", 566 - ); 567 - return new Response("Internal Server Error", { 568 - status: 500, 569 - }); 570 - } 571 - }, 572 - }, 509 + app.logging.webevent( 510 + webconnection.HTTP, 511 + CloseOk, 512 + "/api/files/delete [" + user.username + "] -> " + fileId, 513 + ); 514 + return new Response("OK", { status: 200 }); 515 + } catch (error) { 516 + app.logging.error( 517 + "Error deleting file in /api/files/delete route:", 518 + error, 519 + ); 520 + app.logging.webevent( 521 + webconnection.HTTP, 522 + ServerError, 523 + "/api/files/delete", 524 + ); 525 + return new Response("Internal Server Error", { 526 + status: 500, 527 + }); 528 + } 529 + }, 530 + }, 573 531 574 - // matches all remaining api routes for GET requests 532 + // matches all remaining api routes for GET requests 575 533 576 - "/favicon.ico": async () => { 577 - app.logging.webevent(webconnection.HTTP, Found, "/favicon.ico"); 578 - return Response.redirect( 579 - "/file/00000000-0000-0000-0000-000000000001", 580 - 302, 581 - ); 582 - }, 583 - "/404": async (req: BunRequest) => { 584 - app.logging.webevent( 585 - webconnection.HTTP, 586 - ResourceNotFound, 587 - req.url, 588 - ); 589 - return new Response("Not found", { status: 404 }); 590 - }, 591 - }, 592 - }; 534 + "/favicon.ico": async () => { 535 + app.logging.webevent(webconnection.HTTP, Found, "/favicon.ico"); 536 + return Response.redirect( 537 + "/file/00000000-0000-0000-0000-000000000001", 538 + 302, 539 + ); 540 + }, 541 + "/404": async (req: BunRequest) => { 542 + app.logging.webevent(webconnection.HTTP, ResourceNotFound, req.url); 543 + return new Response("Not found", { status: 404 }); 544 + }, 545 + }, 546 + }; 593 547 };
+36 -36
server/routes/fetch.ts
··· 5 5 import type StrawmediaJuiceServerApplication from "../app.class"; 6 6 7 7 export default function (app: StrawmediaJuiceServerApplication) { 8 - return async (req: Request, server: Bun.Server) => { 9 - let requrl = new URL(req.url); 8 + return async (req: Request, server: Bun.Server) => { 9 + let requrl = new URL(req.url); 10 10 11 - if (requrl.pathname == "/api/upload-streamed") { 12 - try { 13 - const uploadId = Bun.randomUUIDv7(); 14 - // Okay, ready to receive the file in streamed fashion over WebSocket! 15 - // We'll upgrade the connection to a WebSocket. Collect all messages until the client closes the connection. 16 - // Each message is a chunk of the file, 15 mb each. 17 - // When the connection is closed, we finalize the file and store it. 18 - const data: StreamUploadData = { 19 - uploadId, 20 - filename: "", 21 - uid: "", 22 - accumulatedfilesize: -1, 23 - }; 24 - if ( 25 - server.upgrade(req, { 26 - data, 27 - }) 28 - ) { 29 - return; 30 - } else { 31 - return new Response("Failed to upgrade to WebSocket", { 32 - status: 500, 33 - }); 34 - } 35 - } catch (e) { 36 - app.logging.error("Error in /upload-streamed!"); 37 - app.logging.info("HTTP/500 ERROR /api/upload-streamed"); 38 - return new Response("Internal Server Error", { 39 - status: 500, 40 - }); 41 - } 42 - } 43 - return await routes(app)["/404"](req as BunRequest); 44 - }; 11 + if (requrl.pathname == "/api/upload-streamed") { 12 + try { 13 + const uploadId = Bun.randomUUIDv7(); 14 + // Okay, ready to receive the file in streamed fashion over WebSocket! 15 + // We'll upgrade the connection to a WebSocket. Collect all messages until the client closes the connection. 16 + // Each message is a chunk of the file, 15 mb each. 17 + // When the connection is closed, we finalize the file and store it. 18 + const data: StreamUploadData = { 19 + uploadId, 20 + filename: "", 21 + uid: "", 22 + accumulatedfilesize: -1, 23 + }; 24 + if ( 25 + server.upgrade(req, { 26 + data, 27 + }) 28 + ) { 29 + return; 30 + } else { 31 + return new Response("Failed to upgrade to WebSocket", { 32 + status: 500, 33 + }); 34 + } 35 + } catch (e) { 36 + app.logging.error("Error in /upload-streamed!"); 37 + app.logging.info("HTTP/500 ERROR /api/upload-streamed"); 38 + return new Response("Internal Server Error", { 39 + status: 500, 40 + }); 41 + } 42 + } 43 + return await routes(app)["/404"](req as BunRequest); 44 + }; 45 45 }
+196 -202
server/routes/websocket.ts
··· 3 3 import { unlink } from "fs/promises"; 4 4 import webconnection from "../logging/webconnection"; 5 5 import { 6 - CloseOk, 7 - Custom_StorageLimitExceeded, 8 - InvalidRequest, 9 - NotAuthenticated, 10 - ServerError, 11 - UnknownCodeClose, 12 - UpgradeWebsocket, 6 + CloseOk, 7 + Custom_StorageLimitExceeded, 8 + InvalidRequest, 9 + NotAuthenticated, 10 + ServerError, 11 + UnknownCodeClose, 12 + UpgradeWebsocket, 13 13 } from "../logging/webconnectionevent"; 14 14 import type StrawmediaJuiceServerApplication from "../app.class"; 15 15 16 16 export interface StreamUploadData { 17 - uploadId: string; 18 - filename: string; 19 - uid: string; 20 - accumulatedfilesize: number; 21 - messagesReceived?: number; 17 + uploadId: string; 18 + filename: string; 19 + uid: string; 20 + accumulatedfilesize: number; 21 + messagesReceived?: number; 22 22 } 23 23 24 24 export function websocket( 25 - app: StrawmediaJuiceServerApplication, 25 + app: StrawmediaJuiceServerApplication, 26 26 ): Bun.WebSocketHandler<StreamUploadData> { 27 - function firstMessage( 28 - ws: Bun.ServerWebSocket<StreamUploadData>, 29 - message: string | Buffer<ArrayBufferLike>, 30 - uploadData: StreamUploadData, 31 - ) { 32 - // First message is JSON with filename and uid 33 - // Parse the JSON 34 - if (typeof message !== "string") { 35 - app.logging.error( 36 - `First message of streaming upload is not JSON string! Closing socket after ${uploadData.messagesReceived} messages. (1003)`, 37 - ); 38 - ws.close(1003, "First message for upload must be JSON string"); 39 - return; 40 - } 41 - try { 42 - const json = JSON.parse(message as string) as { 43 - session_id: string; 44 - filename: string; 45 - }; 46 - const sid = json.session_id; 47 - const filename = json.filename; 48 - // Authenticate user 27 + function firstMessage( 28 + ws: Bun.ServerWebSocket<StreamUploadData>, 29 + message: string | Buffer<ArrayBufferLike>, 30 + uploadData: StreamUploadData, 31 + ) { 32 + // First message is JSON with filename and uid 33 + // Parse the JSON 34 + if (typeof message !== "string") { 35 + app.logging.error( 36 + `First message of streaming upload is not JSON string! Closing socket after ${uploadData.messagesReceived} messages. (1003)`, 37 + ); 38 + ws.close(1003, "First message for upload must be JSON string"); 39 + return; 40 + } 41 + try { 42 + const json = JSON.parse(message as string) as { 43 + session_id: string; 44 + filename: string; 45 + }; 46 + const sid = json.session_id; 47 + const filename = json.filename; 48 + // Authenticate user 49 49 50 - const result = app.helper().getUserFromSession(sid); 50 + const result = app.helper().getUserFromSession(sid); 51 51 52 - if (!result) { 53 - app.logging.webevent( 54 - webconnection.Websocket, 55 - NotAuthenticated, 56 - "/api/upload", 57 - ); 58 - ws.close(1008, "Unauthorized"); 59 - return; 60 - } 61 - const user = result; 62 - const uid = user.uid; 52 + if (!result) { 53 + app.logging.webevent( 54 + webconnection.Websocket, 55 + NotAuthenticated, 56 + "/api/upload", 57 + ); 58 + ws.close(1008, "Unauthorized"); 59 + return; 60 + } 61 + const user = result; 62 + const uid = user.uid; 63 63 64 - uploadData.filename = filename; 65 - uploadData.uid = uid; 66 - uploadData.accumulatedfilesize = 0; 67 - } catch (e) { 68 - app.logging.webevent( 69 - webconnection.Websocket, 70 - InvalidRequest, 71 - `Failed to parse first message of streaming upload as JSON! Closing socket after ${uploadData.messagesReceived} messages.`, 72 - ); 73 - ws.close(1003, "Failed to parse JSON"); 74 - return; 75 - } 76 - } 64 + uploadData.filename = filename; 65 + uploadData.uid = uid; 66 + uploadData.accumulatedfilesize = 0; 67 + } catch (e) { 68 + app.logging.webevent( 69 + webconnection.Websocket, 70 + InvalidRequest, 71 + `Failed to parse first message of streaming upload as JSON! Closing socket after ${uploadData.messagesReceived} messages.`, 72 + ); 73 + ws.close(1003, "Failed to parse JSON"); 74 + return; 75 + } 76 + } 77 77 78 - return { 79 - async open(ws: Bun.ServerWebSocket<StreamUploadData>): Promise<void> { 80 - const uploadData = ws.data as StreamUploadData; 81 - app.logging.webevent( 82 - webconnection.Websocket, 83 - UpgradeWebsocket, 84 - "/api/upload-streamed -- waiting for file info...", 85 - ); 86 - mkdirSync(`./data/files/${uploadData.uploadId}`, { 87 - recursive: true, 88 - }); 89 - uploadData.messagesReceived = 1; 90 - ws.send(uploadData.uploadId); 91 - }, 92 - message( 93 - ws: Bun.ServerWebSocket<StreamUploadData>, 94 - message: string | Buffer<ArrayBufferLike>, 95 - ): void { 96 - const uploadData = ws.data as StreamUploadData; 97 - uploadData.messagesReceived = 98 - (uploadData.messagesReceived || 1) + 1; 99 - if (uploadData.accumulatedfilesize == -1) { 100 - return firstMessage(ws, message, uploadData); 101 - } else { 102 - // Each message is a chunk of the file 103 - const filename = uploadData.filename; 104 - const uid = uploadData.uid; 105 - const filePathUnfinished = `./data/files/${uploadData.uploadId}/${filename}.part`; 106 - // Stream each message into the file. 107 - appendFileSync(filePathUnfinished, message, {}); 108 - uploadData.accumulatedfilesize += 109 - message.length / (1024 * 1024); 110 - // Get user's current storage usage 111 - const userStorage = app.db 112 - .query( 113 - `SELECT used_megabytes, max_megabytes FROM users WHERE uid = ?1`, 114 - ) 115 - .get(uid) as { 116 - used_megabytes: number; 117 - max_megabytes: number; 118 - } | null; 119 - if (userStorage == null) { 120 - app.logging.webevent( 121 - webconnection.Websocket, 122 - ServerError, 123 - `Something's wrong! Mid streaming upload, this user disappeared. Closing socket after ${uploadData.messagesReceived} messages.`, 124 - ); 125 - ws.close(1011, "User not found"); 126 - } else { 127 - // Check if user has enough storage space 128 - if ( 129 - userStorage.max_megabytes !== 0 && 130 - userStorage.used_megabytes + 131 - uploadData.accumulatedfilesize > 132 - userStorage.max_megabytes 133 - ) { 134 - const username = app.helper().getUsernameFromId(uid); 135 - app.logging.webevent( 136 - webconnection.Websocket, 137 - Custom_StorageLimitExceeded, 138 - "Storage quota for " + 139 - username + 140 - " exceeded mid streaming upload. Closing socket.", 141 - ); 142 - ws.close(4003, "Storage quota exceeded"); 143 - } 144 - // Otherwise, all good for this chunk. 145 - ws.send("continue"); 146 - } 147 - } 148 - }, 149 - async close( 150 - ws: Bun.ServerWebSocket<StreamUploadData>, 151 - code: number, 152 - reason: string, 153 - ): Promise<void> { 154 - if (code == 1000) { 155 - // Finalize the file 156 - const uploadData = ws.data as StreamUploadData; 157 - const filename = uploadData.filename; 158 - const filePath = `./data/files/${uploadData.uploadId}/${filename}`; 159 - const filePathUnfinished = `./data/files/${uploadData.uploadId}/${filename}.part`; 160 - const uid = uploadData.uid; 161 - const username = app.helper().getUsernameFromId(uid); 162 - // Rename the .part file to final filename 163 - try { 164 - await new Promise((resolve) => { 165 - rename(filePathUnfinished, filePath, (e) => { 166 - if (e) { 167 - throw e; 168 - } 169 - resolve(true); 170 - }); 171 - }); 172 - } catch (e) { 173 - app.logging.error_described( 174 - "File System", 175 - "Error finalizing uploaded file:", 176 - e, 177 - ); 178 - } 179 - // Start a transaction to ensure data consistency 180 - app.db.transaction(() => { 181 - // Insert file mapping with owner 182 - app.db.exec( 183 - `INSERT INTO file_mapping (id, filename, owner) VALUES (?1, ?2, ?3)`, 184 - [uploadData.uploadId, filename, username], 185 - ); 78 + return { 79 + async open(ws: Bun.ServerWebSocket<StreamUploadData>): Promise<void> { 80 + const uploadData = ws.data as StreamUploadData; 81 + app.logging.webevent( 82 + webconnection.Websocket, 83 + UpgradeWebsocket, 84 + "/api/upload-streamed -- waiting for file info...", 85 + ); 86 + mkdirSync(`./data/files/${uploadData.uploadId}`, { 87 + recursive: true, 88 + }); 89 + uploadData.messagesReceived = 1; 90 + ws.send(uploadData.uploadId); 91 + }, 92 + message( 93 + ws: Bun.ServerWebSocket<StreamUploadData>, 94 + message: string | Buffer<ArrayBufferLike>, 95 + ): void { 96 + const uploadData = ws.data as StreamUploadData; 97 + uploadData.messagesReceived = (uploadData.messagesReceived || 1) + 1; 98 + if (uploadData.accumulatedfilesize == -1) { 99 + return firstMessage(ws, message, uploadData); 100 + } else { 101 + // Each message is a chunk of the file 102 + const filename = uploadData.filename; 103 + const uid = uploadData.uid; 104 + const filePathUnfinished = `./data/files/${uploadData.uploadId}/${filename}.part`; 105 + // Stream each message into the file. 106 + appendFileSync(filePathUnfinished, message, {}); 107 + uploadData.accumulatedfilesize += message.length / (1024 * 1024); 108 + // Get user's current storage usage 109 + const userStorage = app.db 110 + .query( 111 + `SELECT used_megabytes, max_megabytes FROM users WHERE uid = ?1`, 112 + ) 113 + .get(uid) as { 114 + used_megabytes: number; 115 + max_megabytes: number; 116 + } | null; 117 + if (userStorage == null) { 118 + app.logging.webevent( 119 + webconnection.Websocket, 120 + ServerError, 121 + `Something's wrong! Mid streaming upload, this user disappeared. Closing socket after ${uploadData.messagesReceived} messages.`, 122 + ); 123 + ws.close(1011, "User not found"); 124 + } else { 125 + // Check if user has enough storage space 126 + if ( 127 + userStorage.max_megabytes !== 0 && 128 + userStorage.used_megabytes + uploadData.accumulatedfilesize > 129 + userStorage.max_megabytes 130 + ) { 131 + const username = app.helper().getUsernameFromId(uid); 132 + app.logging.webevent( 133 + webconnection.Websocket, 134 + Custom_StorageLimitExceeded, 135 + "Storage quota for " + 136 + username + 137 + " exceeded mid streaming upload. Closing socket.", 138 + ); 139 + ws.close(4003, "Storage quota exceeded"); 140 + } 141 + // Otherwise, all good for this chunk. 142 + ws.send("continue"); 143 + } 144 + } 145 + }, 146 + async close( 147 + ws: Bun.ServerWebSocket<StreamUploadData>, 148 + code: number, 149 + reason: string, 150 + ): Promise<void> { 151 + if (code == 1000) { 152 + // Finalize the file 153 + const uploadData = ws.data as StreamUploadData; 154 + const filename = uploadData.filename; 155 + const filePath = `./data/files/${uploadData.uploadId}/${filename}`; 156 + const filePathUnfinished = `./data/files/${uploadData.uploadId}/${filename}.part`; 157 + const uid = uploadData.uid; 158 + const username = app.helper().getUsernameFromId(uid); 159 + // Rename the .part file to final filename 160 + try { 161 + await new Promise((resolve) => { 162 + rename(filePathUnfinished, filePath, (e) => { 163 + if (e) { 164 + throw e; 165 + } 166 + resolve(true); 167 + }); 168 + }); 169 + } catch (e) { 170 + app.logging.error_described( 171 + "File System", 172 + "Error finalizing uploaded file:", 173 + e, 174 + ); 175 + } 176 + // Start a transaction to ensure data consistency 177 + app.db.transaction(() => { 178 + // Insert file mapping with owner 179 + app.db.exec( 180 + `INSERT INTO file_mapping (id, filename, owner) VALUES (?1, ?2, ?3)`, 181 + [uploadData.uploadId, filename, username], 182 + ); 186 183 187 - // Update user's storage usage 188 - app.db.exec( 189 - `UPDATE users SET used_megabytes = used_megabytes + ?1 WHERE username = ?2`, 190 - [uploadData.accumulatedfilesize, username], 191 - ); 192 - })(); 184 + // Update user's storage usage 185 + app.db.exec( 186 + `UPDATE users SET used_megabytes = used_megabytes + ?1 WHERE username = ?2`, 187 + [uploadData.accumulatedfilesize, username], 188 + ); 189 + })(); 193 190 194 - app.logging.webevent( 195 - webconnection.Websocket, 196 - CloseOk, 197 - "/api/upload-streamed [" + 198 - username + 199 - "] -> " + 200 - uploadData.uploadId, 201 - ); 202 - } else { 203 - // Clean up the partial file 204 - const uploadData = ws.data as StreamUploadData; 205 - const filename = uploadData.filename; 206 - const filePathUnfinished = `./data/files/${uploadData.uploadId}/${filename}.part`; 207 - if (await Bun.file(filePathUnfinished).exists()) { 208 - await unlink(filePathUnfinished); 209 - } 210 - app.logging.webevent( 211 - webconnection.Websocket, 212 - new UnknownCodeClose({ 213 - websocket_code: code, 214 - // Hoping setting that as description is OK, otherwise it'll be cut off. 215 - description: reason, 216 - }), 217 - `Partial file cleaned up.`, 218 - ); 219 - } 220 - }, 221 - }; 191 + app.logging.webevent( 192 + webconnection.Websocket, 193 + CloseOk, 194 + "/api/upload-streamed [" + username + "] -> " + uploadData.uploadId, 195 + ); 196 + } else { 197 + // Clean up the partial file 198 + const uploadData = ws.data as StreamUploadData; 199 + const filename = uploadData.filename; 200 + const filePathUnfinished = `./data/files/${uploadData.uploadId}/${filename}.part`; 201 + if (await Bun.file(filePathUnfinished).exists()) { 202 + await unlink(filePathUnfinished); 203 + } 204 + app.logging.webevent( 205 + webconnection.Websocket, 206 + new UnknownCodeClose({ 207 + websocket_code: code, 208 + // Hoping setting that as description is OK, otherwise it'll be cut off. 209 + description: reason, 210 + }), 211 + `Partial file cleaned up.`, 212 + ); 213 + } 214 + }, 215 + }; 222 216 }
+2 -8
tsconfig.json
··· 1 1 { 2 2 "compilerOptions": { 3 3 // Environment setup & latest features 4 - "lib": [ 5 - "ESNext", 6 - "DOM" 7 - ], 8 - "rootDirs": [ 9 - "server", 10 - "dashboard/src/" 11 - ], 4 + "lib": ["ESNext", "DOM"], 5 + "rootDirs": ["server", "dashboard/src/"], 12 6 "target": "ESNext", 13 7 "module": "Preserve", 14 8 "moduleDetection": "force",