├── .env ├── .gitignore ├── pkg ├── frontend │ ├── templates │ │ ├── input.css │ │ ├── home.jinja2 │ │ ├── room.jinja2 │ │ ├── users.jinja2 │ │ ├── profile.jinja2 │ │ ├── settings.jinja2 │ │ ├── register.jinja2 │ │ ├── users-partial.jinja2 │ │ ├── profile-partial.jinja2 │ │ ├── room-partial.jinja2 │ │ ├── settings-partial.jinja2 │ │ ├── home-partial.jinja2 │ │ ├── components │ │ │ ├── upload-show.jinja2 │ │ │ ├── button.jinja2 │ │ │ ├── settings.jinja2 │ │ │ ├── uploaded-file.jinja2 │ │ │ ├── layout.jinja2 │ │ │ ├── permission-search-results.jinja2 │ │ │ ├── users.jinja2 │ │ │ ├── user-profile-image.jinja2 │ │ │ ├── text-input.jinja2 │ │ │ ├── chatroom.jinja2 │ │ │ ├── room-user.jinja2 │ │ │ ├── user-search-results.jinja2 │ │ │ ├── message-list.jinja2 │ │ │ ├── user-rooms-name.jinja2 │ │ │ ├── user-buttons-enabled.jinja2 │ │ │ ├── user-buttons-disabled.jinja2 │ │ │ ├── invite.jinja2 │ │ │ ├── message.jinja2 │ │ │ ├── room-name.jinja2 │ │ │ ├── user-buttons-admin.jinja2 │ │ │ ├── title.jinja2 │ │ │ ├── user-item.jinja2 │ │ │ ├── user-profile-edit.jinja2 │ │ │ ├── user-profile-image-edit.jinja2 │ │ │ ├── sendmessageform.jinja2 │ │ │ ├── profile.jinja2 │ │ │ ├── private-room-users.jinja2 │ │ │ ├── partial-register.jinja2 │ │ │ ├── add-room.jinja2 │ │ │ ├── user-selection.jinja2 │ │ │ ├── sidebar.jinja2 │ │ │ └── thread-view.jinja2 │ │ ├── icons │ │ │ ├── send.jinja2 │ │ │ ├── emoji.jinja2 │ │ │ ├── levers.jinja2 │ │ │ ├── image.jinja2 │ │ │ ├── delete.jinja2 │ │ │ └── settings.jinja2 │ │ ├── base.jinja2 │ │ └── login.jinja2 │ ├── assets │ │ ├── favicon.ico │ │ ├── 404.svg │ │ ├── logo.svg │ │ ├── chat.svg │ │ └── wait.svg │ ├── assets.rs │ ├── Cargo.toml │ ├── tailwind.config.js │ ├── templates.rs │ ├── frontend.rs │ └── api.rs ├── database │ ├── migrations │ │ ├── 03_uploads.down.sql │ │ ├── 04_message_uploads.down.sql │ │ ├── 01_users.down.sql │ │ ├── 02_rooms.down.sql │ │ ├── 03_uploads.up.sql │ │ ├── 04_message_uploads.up.sql │ │ ├── 01_users.up.sql │ │ └── 02_rooms.up.sql │ ├── Cargo.toml │ ├── database.rs │ ├── uploads.rs │ ├── messages.rs │ ├── rooms.rs │ └── users.rs ├── rooms │ ├── Cargo.toml │ └── rooms.rs ├── users │ ├── Cargo.toml │ └── users.rs └── uploads │ ├── Cargo.toml │ └── uploads.rs ├── justfile ├── cmd └── chat │ ├── Cargo.toml │ └── src │ └── main.rs ├── Cargo.toml ├── .github └── workflows │ └── build.yml └── README.md /.env: -------------------------------------------------------------------------------- 1 | DATABASE_URL=sqlite://./data/db.sqlite -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | db.sqlite* 2 | /build/ 3 | data/ 4 | target/ -------------------------------------------------------------------------------- /pkg/frontend/templates/input.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /pkg/frontend/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gamedolphin/speakwith/HEAD/pkg/frontend/assets/favicon.ico -------------------------------------------------------------------------------- /pkg/frontend/templates/home.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja2' %} 2 | 3 | {% block content %} 4 | {% include 'home-partial.jinja2' %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /pkg/frontend/templates/room.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja2' %} 2 | 3 | {% block content %} 4 | {% include 'room-partial.jinja2' %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /pkg/frontend/templates/users.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja2' %} 2 | 3 | {% block content %} 4 | {% include 'users-partial.jinja2' %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /pkg/frontend/templates/profile.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja2' %} 2 | 3 | {% block content %} 4 | {% include 'profile-partial.jinja2' %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /pkg/frontend/templates/settings.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja2' %} 2 | 3 | {% block content %} 4 | {% include 'settings-partial.jinja2' %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /pkg/database/migrations/03_uploads.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS uploads_room_index; 2 | DROP INDEX IF EXISTS uploads_user_index; 3 | 4 | DROP TABLE IF EXISTS uploads; 5 | -------------------------------------------------------------------------------- /pkg/frontend/templates/register.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja2' %} 2 | 3 | {% block content %} 4 | {% include 'components/partial-register.jinja2' %} 5 | {% endblock %} 6 | -------------------------------------------------------------------------------- /pkg/frontend/templates/users-partial.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'components/layout.jinja2' %} 2 | {% block current %} 3 | {% include 'components/users.jinja2' %} 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /pkg/database/migrations/04_message_uploads.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS message_id_upload; 2 | DROP INDEX IF EXISTS upload_id_message; 3 | DROP TABLE IF EXISTS message_uploads; 4 | -------------------------------------------------------------------------------- /pkg/frontend/templates/profile-partial.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'components/layout.jinja2' %} 2 | {% block current %} 3 | {% include 'components/profile.jinja2' %} 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /pkg/frontend/templates/room-partial.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'components/layout.jinja2' %} 2 | {% block current %} 3 | {% include 'components/chatroom.jinja2' %} 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /pkg/frontend/templates/settings-partial.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'components/layout.jinja2' %} 2 | {% block current %} 3 | {% include 'components/settings.jinja2' %} 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /pkg/database/migrations/01_users.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS user_profile_index; 2 | DROP TABLE IF EXISTS user_profiles; 3 | DROP INDEX IF EXISTS user_email_index; 4 | DROP TABLE IF EXISTS users; 5 | -------------------------------------------------------------------------------- /pkg/database/migrations/02_rooms.down.sql: -------------------------------------------------------------------------------- 1 | DROP INDEX IF EXISTS message_user_index; 2 | DROP INDEX IF EXISTS message_room_index; 3 | DROP TABLE IF EXISTS messages; 4 | DROP TABLE IF EXISTS user_rooms; 5 | DROP TABLE IF EXISTS rooms; 6 | -------------------------------------------------------------------------------- /pkg/frontend/templates/home-partial.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'components/layout.jinja2' %} 2 | {% block current %} 3 |
Loading
4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/upload-show.jinja2: -------------------------------------------------------------------------------- 1 | {% if file_type == 'image' %} 2 | uploaded image 3 | {% else %} 4 | uploaded file 5 | {% endif %} 6 | -------------------------------------------------------------------------------- /pkg/frontend/templates/icons/send.jinja2: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /pkg/frontend/templates/icons/emoji.jinja2: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /justfile: -------------------------------------------------------------------------------- 1 | init-db: 2 | mkdir -p data & sqlx database setup --source ./pkg/database/migrations/ 3 | watch-tw: 4 | cd pkg/frontend && tailwindcss -w -i ./templates/input.css -o ./assets/output.css 5 | 6 | watch-rust: 7 | cargo watch -x 'run' -i "{**/*.html,**/*.css,**/*.jinja2,**/*.sqlite*,**/uploads/**}" 8 | 9 | run: 10 | just watch-rust & just watch-tw 11 | -------------------------------------------------------------------------------- /pkg/rooms/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "rooms" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [lib] 8 | name = "rooms" 9 | path = "rooms.rs" 10 | 11 | [dependencies] 12 | anyhow.workspace = true 13 | thiserror.workspace = true 14 | serde.workspace = true 15 | tokio.workspace = true 16 | parking_lot.workspace = true 17 | 18 | database.workspace = true 19 | time = "0" -------------------------------------------------------------------------------- /pkg/frontend/templates/components/button.jinja2: -------------------------------------------------------------------------------- 1 | 5 | -------------------------------------------------------------------------------- /cmd/chat/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "chat" 3 | version = "0.1.0" 4 | edition = "2021" 5 | 6 | [dependencies] 7 | tokio.workspace = true 8 | axum.workspace = true 9 | anyhow.workspace = true 10 | tracing.workspace = true 11 | tracing-subscriber.workspace = true 12 | clap = { version = "4.5.0", features = ["derive", "env"] } 13 | 14 | # chat.workspace = true 15 | frontend.workspace = true 16 | database.workspace = true -------------------------------------------------------------------------------- /pkg/frontend/templates/icons/levers.jinja2: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/settings.jinja2: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Settings

4 | 5 |
6 |
7 |
8 |
9 |
10 |
11 | -------------------------------------------------------------------------------- /pkg/users/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "users" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [lib] 8 | name = "users" 9 | path = "users.rs" 10 | 11 | [dependencies] 12 | anyhow.workspace = true 13 | thiserror.workspace = true 14 | serde.workspace = true 15 | tokio.workspace = true 16 | parking_lot.workspace = true 17 | jwt = "0.16.0" #token 18 | hmac = "0.12.1" 19 | sha2 = "0.10.8" 20 | 21 | database.workspace = true -------------------------------------------------------------------------------- /pkg/database/migrations/03_uploads.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS uploads ( 2 | id TEXT NOT NULL PRIMARY KEY, 3 | uploaded_by TEXT NOT NULL, 4 | room_id TEXT, 5 | filename TEXT NOT NULL, 6 | url TEXT NOT NULL, 7 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP 8 | ); 9 | 10 | CREATE INDEX IF NOT EXISTS uploads_room_index ON uploads(room_id); 11 | CREATE INDEX IF NOT EXISTS uploads_user_index ON uploads(uploaded_by); 12 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/uploaded-file.jinja2: -------------------------------------------------------------------------------- 1 |
2 | {% include 'components/upload-show.jinja2' %} 3 |
4 | {% with size = 3 %} 5 | {% include 'icons/delete.jinja2' %} 6 | {% endwith %} 7 |
8 |
9 |
10 | 11 |
12 | -------------------------------------------------------------------------------- /pkg/uploads/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "uploads" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [lib] 8 | name = "uploads" 9 | path = "uploads.rs" 10 | 11 | [dependencies] 12 | anyhow.workspace = true 13 | thiserror.workspace = true 14 | serde.workspace = true 15 | tokio.workspace = true 16 | parking_lot.workspace = true 17 | xid.workspace = true 18 | tokio-util = "0.7.10" 19 | mime_guess = "2.0.4" 20 | futures = "0" 21 | 22 | database.workspace = true -------------------------------------------------------------------------------- /pkg/database/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "database" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [lib] 8 | name = "database" 9 | path = "database.rs" 10 | 11 | [dependencies] 12 | anyhow.workspace = true 13 | thiserror.workspace = true 14 | serde.workspace = true 15 | tokio.workspace = true 16 | parking_lot.workspace = true 17 | xid.workspace = true 18 | sqlx = { version = "0.7", features = [ "runtime-tokio", "sqlite", "macros", "migrate", "time" ] } 19 | time = "0" 20 | rand_core = {version = "0.6.4", features=["getrandom"] } 21 | argon2 = {version = "0.5.2"} 22 | -------------------------------------------------------------------------------- /pkg/database/migrations/04_message_uploads.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS message_uploads ( 2 | id TEXT NOT NULL PRIMARY KEY, 3 | message_id TEXT NOT NULL, 4 | upload_id TEXT NOT NULL, 5 | upload_path TEXT NOT NULL, 6 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 7 | FOREIGN KEY (message_id) REFERENCES messages(id), 8 | FOREIGN KEY (upload_id) REFERENCES uploads(id) 9 | ); 10 | 11 | CREATE INDEX IF NOT EXISTS message_id_upload ON message_uploads(message_id); 12 | CREATE INDEX IF NOT EXISTS upload_id_message ON message_uploads(upload_id); 13 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/layout.jinja2: -------------------------------------------------------------------------------- 1 |
5 | {% include 'components/sidebar.jinja2' %} 6 |
7 | {% block current %} 8 | {% endblock %} 9 |
10 |
11 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/permission-search-results.jinja2: -------------------------------------------------------------------------------- 1 |
2 | {% for user in results %} 3 |
4 | 5 |

{{ user.username }}

6 |
7 | {% endfor %} 8 |
9 | -------------------------------------------------------------------------------- /pkg/frontend/templates/icons/image.jinja2: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/users.jinja2: -------------------------------------------------------------------------------- 1 | {% with currentRoom = { 'id': 'users', 'description': 'Manage users' } %} 2 | {% include 'components/title.jinja2' %} 3 | {% endwith %} 4 |
5 |
6 | {% include 'components/invite.jinja2' %} 7 |
8 | {% for item in userlist %} 9 | {% include 'components/user-item.jinja2' %} 10 | {% endfor %} 11 |
12 |
13 |
14 | -------------------------------------------------------------------------------- /pkg/frontend/templates/icons/delete.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/user-profile-image.jinja2: -------------------------------------------------------------------------------- 1 |
5 | {% if image %} 6 | Rounded avatar 9 | {% else %} 10 |
11 | {{ username | list | first }} 12 |
13 | {% endif %} 14 |
15 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | resolver = "2" 3 | 4 | members = [ 5 | "cmd/*", 6 | "pkg/*" 7 | ] 8 | 9 | [workspace.dependencies] 10 | parking_lot = "0" 11 | tracing = "0" 12 | tracing-subscriber = "0" 13 | anyhow = "1" 14 | thiserror = "1" 15 | tower = "0.4.13" 16 | serde = { version = "1", features = ["derive"] } 17 | serde_json = "1" 18 | axum = { version = "0.7" , features = ["json", "macros", "multipart"] } 19 | tokio = { version = "1", features = ["full"] } 20 | xid = "1" 21 | frontend = { path = "./pkg/frontend" } 22 | database = { path = "./pkg/database" } 23 | users = { path = "./pkg/users" } 24 | rooms = { path = "./pkg/rooms" } 25 | uploads = { path = "./pkg/uploads" } -------------------------------------------------------------------------------- /pkg/frontend/templates/components/text-input.jinja2: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /pkg/frontend/templates/icons/settings.jinja2: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pkg/database/migrations/01_users.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS users ( 2 | id TEXT NOT NULL PRIMARY KEY, 3 | email TEXT NOT NULL UNIQUE, 4 | password BLOB NOT NULL, 5 | hash BLOB NOT NULL, 6 | is_admin BOOLEAN DEFAULT FALSE, 7 | is_enabled BOOLEAN DEFAULT FALSE, 8 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP 9 | ); 10 | 11 | CREATE INDEX IF NOT EXISTS user_email_index ON users(email); 12 | 13 | CREATE TABLE IF NOT EXISTS user_profiles ( 14 | id TEXT NOT NULL PRIMARY KEY, 15 | user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, 16 | username TEXT NOT NULL, 17 | bio TEXT, 18 | image TEXT 19 | ); 20 | 21 | CREATE INDEX IF NOT EXISTS user_profile_index ON user_profiles(user_id); 22 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/chatroom.jinja2: -------------------------------------------------------------------------------- 1 | {% include 'components/title.jinja2' %} 2 |
10 |
11 | {% include 'components/message-list.jinja2' %} 12 |
13 |
14 | 15 |
16 | {% include 'components/sendmessageform.jinja2' %} 17 |
18 |
19 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/room-user.jinja2: -------------------------------------------------------------------------------- 1 | {% for user in roomUsers %} 2 |
3 |

4 | {{ user.name }} 5 |

6 | {% if loop.length > 1 %} 7 | 10 | {% endif %} 11 |
12 | {% endfor %} 13 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/user-search-results.jinja2: -------------------------------------------------------------------------------- 1 | {% for user in results %} 2 |
10 | 11 |

{{ user.username }}

12 |
13 | {% endfor %} 14 | -------------------------------------------------------------------------------- /pkg/frontend/assets.rs: -------------------------------------------------------------------------------- 1 | use axum::{ 2 | extract::Path, 3 | http::{header, StatusCode}, 4 | response::IntoResponse, 5 | routing::get, 6 | Router, 7 | }; 8 | use rust_embed::RustEmbed; 9 | 10 | #[derive(RustEmbed)] 11 | #[folder = "./assets"] 12 | struct Assets; 13 | 14 | pub(crate) fn setup_asset_handler() -> Router { 15 | Router::new().route("/*file", get(asset_handler)) 16 | } 17 | 18 | async fn asset_handler(Path(path): Path) -> impl IntoResponse { 19 | match Assets::get(path.as_str()) { 20 | Some(content) => { 21 | let mime = mime_guess::from_path(path).first_or_octet_stream(); 22 | ([(header::CONTENT_TYPE, mime.as_ref())], content.data).into_response() 23 | } 24 | None => (StatusCode::NOT_FOUND, "404 Not Found").into_response(), 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | 7 | env: 8 | CARGO_TERM_COLOR: always 9 | DATABASE_URL: sqlite://db.sqlite 10 | jobs: 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - run: rustup toolchain install nightly --profile minimal 18 | - uses: Swatinem/rust-cache@v2 19 | - name: sqlx 20 | run: cargo install sqlx-cli --no-default-features --features sqlite 21 | - name: setup db 22 | run: sqlx database setup --source ./pkg/database/migrations/ 23 | - name: Build 24 | run: cargo build --release 25 | - name: Run tests 26 | run: cargo test --verbose 27 | - uses: actions/upload-artifact@v4 28 | with: 29 | name: speakwith 30 | path: ./target/chat 31 | -------------------------------------------------------------------------------- /pkg/database/database.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use anyhow::Result; 4 | use rooms::init_rooms; 5 | use sqlx::{migrate::MigrateDatabase, sqlite::SqlitePoolOptions, Pool, Sqlite}; 6 | 7 | pub mod messages; 8 | pub mod rooms; 9 | pub mod uploads; 10 | pub mod users; 11 | 12 | #[derive(Clone)] 13 | pub struct Database { 14 | pool: Pool, 15 | } 16 | 17 | impl Database { 18 | pub async fn new(path: &str) -> Result { 19 | if !Sqlite::database_exists(path).await.unwrap_or(false) { 20 | Sqlite::create_database(path).await?; 21 | } 22 | 23 | let pool = SqlitePoolOptions::new() 24 | .acquire_timeout(Duration::from_secs(5)) 25 | .connect(path) 26 | .await?; 27 | 28 | sqlx::migrate!("./migrations").run(&pool).await?; 29 | 30 | init_rooms(&pool).await?; 31 | 32 | Ok(Database { pool }) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/message-list.jinja2: -------------------------------------------------------------------------------- 1 | {% for message in messages %} 2 | {% if loop.previtem %} 3 | {% if (loop.previtem.created_at | dateformat) != (message.created_at | dateformat) %} 4 | 5 |
6 | {{ loop.previtem.created_at | dateformat }} 7 |
8 | {% endif %} 9 | {% endif %} 10 | {% include 'components/message.jinja2' %} 11 | 12 | {% if page != 0 %} 13 | {% if loop.last %} 14 |
18 |
19 | {% endif %} 20 | {% endif %} 21 | {% endfor %} 22 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/user-rooms-name.jinja2: -------------------------------------------------------------------------------- 1 |
  • 2 |
    3 | 4 |
    5 | 6 | {{ room.name }} 7 |
  • 8 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/user-buttons-enabled.jinja2: -------------------------------------------------------------------------------- 1 | {% include 'components/user-buttons-admin.jinja2' %} 2 | 7 | -------------------------------------------------------------------------------- /pkg/database/migrations/02_rooms.up.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS rooms ( 2 | id TEXT NOT NULL PRIMARY KEY, 3 | name TEXT NOT NULL, 4 | description TEXT NOT NULL, 5 | is_private BOOLEAN DEFAULT FALSE, 6 | is_user BOOLEAN DEFAULT FALSE, 7 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP 8 | ); 9 | 10 | CREATE TABLE IF NOT EXISTS user_rooms ( 11 | user_id TEXT NOT NULL, 12 | room_id TEXT NOT NULL, 13 | PRIMARY KEY (user_id, room_id), 14 | FOREIGN KEY (user_id) REFERENCES users(id), 15 | FOREIGN KEY (room_id) REFERENCES rooms(id) 16 | ); 17 | 18 | CREATE TABLE IF NOT EXISTS messages ( 19 | id TEXT NOT NULL PRIMARY KEY, 20 | room_id TEXT NOT NULL REFERENCES rooms(id), 21 | user_id TEXT NOT NULL REFERENCES users(id), 22 | created_at DATETIME DEFAULT CURRENT_TIMESTAMP, 23 | 24 | message TEXT 25 | ); 26 | 27 | CREATE INDEX IF NOT EXISTS message_room_index ON messages(room_id); 28 | CREATE INDEX IF NOT EXISTS message_user_index ON messages(user_id); 29 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/user-buttons-disabled.jinja2: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /pkg/uploads/uploads.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use futures::Stream; 3 | use tokio::{fs::File, io::BufWriter}; 4 | use tokio_util::{bytes::Buf, io::StreamReader}; 5 | 6 | pub async fn upload_file( 7 | body: impl Stream>, 8 | base_path: &str, 9 | id: Option, 10 | file_name: &str, 11 | ) -> Result<(String, String)> 12 | where 13 | B: Buf, 14 | E: Into, 15 | { 16 | let id = id.unwrap_or_else(|| xid::new().to_string()); 17 | 18 | let file_path = format!("{}-{}", id, file_name); 19 | let guess = mime_guess::from_path(file_name); 20 | let file_type = guess.first_or_octet_stream().type_().to_string(); 21 | let path = std::path::Path::new(base_path).join(&file_path); 22 | 23 | let mut file = BufWriter::new(File::create(&path).await?); 24 | 25 | let body_reader = StreamReader::new(body); 26 | 27 | futures::pin_mut!(body_reader); 28 | 29 | tokio::io::copy(&mut body_reader, &mut file).await?; 30 | 31 | Ok((file_path, file_type)) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/invite.jinja2: -------------------------------------------------------------------------------- 1 |
    2 |

    localhost:3000/register/{{ register_id }}

    3 |
    4 | 7 | 10 |
    11 |
    12 | -------------------------------------------------------------------------------- /pkg/database/uploads.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use sqlx::{Sqlite, Transaction}; 3 | use time::OffsetDateTime; 4 | 5 | use crate::Database; 6 | 7 | pub struct Upload { 8 | pub id: String, 9 | pub uploaded_by: String, 10 | pub room_id: Option, 11 | pub url: String, 12 | pub created_at: OffsetDateTime, 13 | } 14 | 15 | pub async fn add_upload_and_continue<'a>( 16 | db: &'a Database, 17 | user_id: &str, 18 | room_id: Option, 19 | filename: Option, 20 | url: Option, 21 | ) -> Result<(String, Transaction<'a, Sqlite>)> { 22 | let mut trx = db.pool.begin().await?; 23 | 24 | if url.is_none() { 25 | // just return, nothing to upload really 26 | return Ok(("".to_string(), trx)); 27 | } 28 | 29 | let id = xid::new().to_string(); 30 | sqlx::query!( 31 | r#" 32 | INSERT INTO uploads (id, uploaded_by, room_id, filename, url) 33 | VALUES ($1, $2, $3, $4, $5) 34 | "#, 35 | id, 36 | user_id, 37 | room_id, 38 | filename, 39 | url, 40 | ) 41 | .execute(&mut *trx) 42 | .await?; 43 | 44 | Ok((id, trx)) 45 | } 46 | -------------------------------------------------------------------------------- /pkg/frontend/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "frontend" 3 | version = "0.1.0" 4 | edition = "2021" 5 | publish = false 6 | 7 | [lib] 8 | name = "frontend" 9 | path = "frontend.rs" 10 | 11 | [dependencies] 12 | tracing.workspace = true 13 | anyhow.workspace = true 14 | thiserror.workspace = true 15 | axum.workspace = true 16 | tokio.workspace = true 17 | parking_lot.workspace = true 18 | serde.workspace = true 19 | xid.workspace = true 20 | tower.workspace = true 21 | rust-embed = "8.2.0" 22 | mime_guess = "2.0.4" 23 | minijinja = { version = "1.0.10", features = ["loader"] } 24 | axum-htmx = { version = "0.5.0", features = ["serde"] } 25 | axum-extra = { version = "0.9.1", features = ["cookie", "typed-header", "form"] } 26 | minijinja-contrib = { version = "1.0.11", features = ["datetime"] } 27 | time = "0" 28 | tower-http = { version = "0.5.1", features = ["fs"] } 29 | futures = "0" 30 | tokio-stream = { version = "0.1.14", features = ["sync"] } 31 | tokio-util = "0.7.10" 32 | pin-project = "1" 33 | notify = "6" 34 | rand = "0.8.5" 35 | convert_case = "0.6.0" 36 | 37 | database.workspace = true 38 | users.workspace = true 39 | rooms.workspace = true 40 | uploads.workspace = true 41 | 42 | # [build-dependencies] 43 | # anyhow.workspace = true -------------------------------------------------------------------------------- /pkg/frontend/templates/components/message.jinja2: -------------------------------------------------------------------------------- 1 |
    2 | {% with image = message.user_image, username = message.user_name %} 3 | {% include 'components/user-profile-image.jinja2' %} 4 | {% endwith %} 5 |
    6 |
    7 | {{ message.user_name }} 8 | 9 |
    10 |
    11 | {% if message.uploads %} 12 |
    13 | {% for val in message.uploads | split %} 14 | uploaded image 15 | {% endfor %} 16 |
    17 | {% endif %} 18 |

    {{ message.message }}

    19 |
    20 |
    21 |
    22 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/room-name.jinja2: -------------------------------------------------------------------------------- 1 |
  • 7 | {% if room.is_private %} 8 | 9 | 10 | 11 | {% else %} 12 | 13 | {% endif %} 14 | # {{ room.name }} 15 | 18 |
  • 19 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/user-buttons-admin.jinja2: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SPEAK WITH 2 | 3 | A simple slack alternative in the works made in the TAMASHA stack. 4 | 5 | ## Build and run 6 | 7 | ### Pre-requisites: 8 | - tailwindcss 9 | - cargo 10 | - sqlx-cli 11 | - just 12 | 13 | ### Initialize Database (First time) 14 | ```bash 15 | just init-db 16 | ``` 17 | 18 | ### Run dev 19 | ```bash 20 | just run 21 | ``` 22 | All data is stored in the `data` folder. 23 | 24 | ## Building 25 | Run `cargo build` to build the project and then execute the `chat` binary that gets generated. 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ### Features 35 | 1. Public and private chat rooms 36 | 2. User to user private chats with an option to have multiple users per private chat. 37 | 3. user management - new users use the registration link, but are not activated unless the admin allows them. 38 | 4. Tiny things - like user profile images etc. 39 | 40 | ### Roadmap 41 | 1. File uploads through chat. 42 | 2. Emojis in messages, and generally richer text messages with code blocks and user and channel tagging in the messages. 43 | 3. Automatic backups to storage providers. 44 | 4. User online indicators and unread message counts. 45 | 5. Notifications! 46 | 6. Archiving channels 47 | 7. Automatic SSL 48 | 8. Cross workspace connections (a la slack connections) 49 | 9. Ui/ux overhaul? maybe. Live with programmer art for now. 50 | -------------------------------------------------------------------------------- /cmd/chat/src/main.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use axum::Router; 3 | use clap::Parser; 4 | 5 | #[derive(Parser, Debug)] 6 | #[command(version, about, long_about = None)] 7 | struct Args { 8 | #[arg(short, long, default_value = "./data")] 9 | data_path: String, 10 | 11 | #[arg(short, long, default_value = "local_dev")] 12 | secret: String, 13 | 14 | #[arg(short, long, default_value = "/")] 15 | base_url: String, 16 | 17 | #[arg(short, long, default_value = "3231")] 18 | port: u32, 19 | } 20 | 21 | #[tokio::main] 22 | async fn main() -> Result<()> { 23 | let args = Args::parse(); 24 | 25 | tracing_subscriber::fmt::init(); 26 | 27 | std::fs::create_dir_all(&args.data_path)?; 28 | 29 | let db_path = std::path::Path::new(&args.data_path) 30 | .join("db.sqlite") 31 | .to_string_lossy() 32 | .to_string(); 33 | 34 | let db_url = format!("sqlite://{}", db_path); 35 | 36 | let db = database::Database::new(&db_url).await?; 37 | let frontend = frontend::initialize(&args.base_url, &args.secret, args.data_path, db).await?; 38 | 39 | let app = Router::new().nest(&args.base_url, frontend); 40 | 41 | let addr = format!("0.0.0.0:{}", args.port); 42 | 43 | let listener = tokio::net::TcpListener::bind(&addr).await?; 44 | axum::serve(listener, app).await?; 45 | 46 | Ok(()) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/title.jinja2: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |
    5 |
    # {{ currentRoom.name }}
    6 |

    {{currentRoom.description}}

    7 |
    8 | {% if currentRoom.is_private and currentRoom.is_user is false %} 9 |
    10 |
    11 | 14 |
    15 | {% include 'components/private-room-users.jinja2' %} 16 |
    17 | {% endif %} 18 |
    19 |
    20 |
    21 | -------------------------------------------------------------------------------- /pkg/frontend/templates/base.jinja2: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Speak With 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 |
    20 | {% block content %} 21 | {% endblock %} 22 |
    23 | 24 | 25 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/user-item.jinja2: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | {% if item.user_image %} 4 | 5 | {% else %} 6 | 7 | {% endif %} 8 |
    9 |
    10 |

    11 | {{ item.username }} 12 |

    13 |

    14 | {{ item.email }} 15 |

    16 |
    17 |
    18 | {% if user.id != item.id %} 19 |
    20 | {% if item.is_enabled %} 21 | {% with is_admin = item.is_admin, userid = item.id %} 22 | {% include 'components/user-buttons-enabled.jinja2' %} 23 | {% endwith %} 24 | {% else %} 25 | {% with userid = item.id %} 26 | {% include 'components/user-buttons-disabled.jinja2' %} 27 | {% endwith %} 28 | {% endif %} 29 |
    30 | {% endif %} 31 |
    32 | 33 |
    34 | -------------------------------------------------------------------------------- /pkg/users/users.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use anyhow::Result; 4 | use database::Database; 5 | use hmac::{Hmac, Mac}; 6 | use jwt::{SignWithKey, VerifyWithKey}; 7 | use sha2::Sha256; 8 | use thiserror::Error; 9 | 10 | #[derive(serde::Deserialize)] 11 | pub struct RegisterForm { 12 | pub email: String, 13 | pub name: String, 14 | pub password: String, 15 | } 16 | 17 | #[derive(serde::Deserialize)] 18 | pub struct LoginForm { 19 | pub email: String, 20 | pub password: String, 21 | } 22 | 23 | pub async fn login_user(db: &Database, secret: &str, form: LoginForm) -> Result { 24 | let user_id = database::users::verify_userpassword(db, &form.email, &form.password).await?; 25 | 26 | generate_token(secret, &user_id) 27 | } 28 | 29 | pub fn generate_token(secret: &str, user_id: &str) -> Result { 30 | let key: Hmac = Hmac::new_from_slice(secret.as_bytes())?; 31 | let mut claims = BTreeMap::new(); 32 | claims.insert("sub", user_id); 33 | let token = claims.sign_with_key(&key)?; 34 | 35 | Ok(token) 36 | } 37 | 38 | pub fn extract_sub(secret: &str, token: &str) -> Result> { 39 | let key: Hmac = Hmac::new_from_slice(secret.as_bytes())?; 40 | let claims: BTreeMap = token.verify_with_key(&key)?; 41 | let sub = claims.get("sub").cloned(); 42 | Ok(sub) 43 | } 44 | 45 | #[derive(Error, Debug)] 46 | pub enum UserErrors { 47 | #[error("internal error: {0}")] 48 | InternalError(anyhow::Error), 49 | } 50 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/user-profile-edit.jinja2: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 | 5 | {% with name="name", placeholder="First Last", htmxpairs = [("value", username)] %} 6 | {% include 'components/text-input.jinja2' %} 7 | {% endwith %} 8 |
    9 |
    10 | 11 | {% with name="email", placeholder="you@somewhere.com", htmxpairs = [("value", email), ("disabled", "true")] %} 12 | {% include 'components/text-input.jinja2' %} 13 | {% endwith %} 14 |
    15 |
    16 | 17 | 18 |
    19 |
    20 |
    21 | {% with label = "Update" %} 22 | {% include 'components/button.jinja2' %} 23 | {% endwith %} 24 |
    25 |
    26 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/user-profile-image-edit.jinja2: -------------------------------------------------------------------------------- 1 | 29 | {% if oob %} 30 | {% include 'components/user-profile-image.jinja2' %} 31 | {% endif %} 32 | -------------------------------------------------------------------------------- /pkg/frontend/templates/login.jinja2: -------------------------------------------------------------------------------- 1 | {% extends 'base.jinja2' %} 2 | 3 | {% block content %} 4 |
    5 | 6 | logo 7 | SpeakWith 8 | 9 |
    10 |
    11 |

    12 | Sign in to your account 13 |

    14 |
    15 |
    16 | 17 | {% with inputType = "email", id = "email", placeholder = "you@here.com" %} 18 | {% include 'components/text-input.jinja2' %} 19 | {% endwith %} 20 |
    21 |
    22 | 23 | {% with inputType = "password", id = "password", placeholder = "••••••••" %} 24 | {% include 'components/text-input.jinja2' %} 25 | {% endwith %} 26 |
    27 | {% with label = "Sign in" %} 28 | {% include 'components/button.jinja2' %} 29 | {% endwith %} 30 |
    31 |
    32 |
    33 |
    34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /pkg/frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ["./**/*.{html,js,rs,jinja2}"], 4 | darkMode: 'class', 5 | theme: { 6 | extend: { 7 | colors: { 8 | primary: {"50":"#f8fafc","100":"#f1f5f9","200":"#e2e8f0","300":"#cbd5e1","400":"#94a3b8","500":"#64748b","600":"#475569","700":"#334155","800":"#1e293b","900":"#0f172a","950":"#020617"} 9 | } 10 | }, 11 | fontFamily: { 12 | 'body': [ 13 | 'Inter', 14 | 'ui-sans-serif', 15 | 'system-ui', 16 | '-apple-system', 17 | 'system-ui', 18 | 'Segoe UI', 19 | 'Roboto', 20 | 'Helvetica Neue', 21 | 'Arial', 22 | 'Noto Sans', 23 | 'sans-serif', 24 | 'Apple Color Emoji', 25 | 'Segoe UI Emoji', 26 | 'Segoe UI Symbol', 27 | 'Noto Color Emoji' 28 | ], 29 | 'mono': [ 30 | 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas', "Liberation Mono", "Courier New", 'monospace' 31 | ], 32 | 'sans': [ 33 | 'Inter', 34 | 'ui-sans-serif', 35 | 'system-ui', 36 | '-apple-system', 37 | 'system-ui', 38 | 'Segoe UI', 39 | 'Roboto', 40 | 'Helvetica Neue', 41 | 'Arial', 42 | 'Noto Sans', 43 | 'sans-serif', 44 | 'Apple Color Emoji', 45 | 'Segoe UI Emoji', 46 | 'Segoe UI Symbol', 47 | 'Noto Color Emoji' 48 | ] 49 | } 50 | }, 51 | plugins: [] 52 | } 53 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/sendmessageform.jinja2: -------------------------------------------------------------------------------- 1 |
    2 |
    4 |
    5 |
    6 |
    7 |
    8 | 9 | 20 | 21 | 25 | 26 |
    27 |
    28 | 32 |
    33 |
    34 |
    35 | 36 |
    37 | -------------------------------------------------------------------------------- /pkg/frontend/templates.rs: -------------------------------------------------------------------------------- 1 | use std::{path::Path, sync::Arc}; 2 | 3 | use anyhow::Result; 4 | use minijinja::{Environment, ErrorKind}; 5 | use notify::{RecommendedWatcher, RecursiveMode, Watcher}; 6 | use parking_lot::RwLock; 7 | use rust_embed::RustEmbed; 8 | 9 | use crate::FrontendError; 10 | 11 | #[derive(RustEmbed)] 12 | #[folder = "./templates"] 13 | struct TemplateFiles; 14 | 15 | #[derive(Clone)] 16 | pub struct Templates { 17 | env: Arc>>, 18 | _watcher: Arc, 19 | } 20 | 21 | fn split(val: String) -> Result, minijinja::Error> { 22 | let out = val.split("||").map(|v| v.into()).collect::>(); 23 | 24 | Ok(out) 25 | } 26 | 27 | impl Default for Templates { 28 | fn default() -> Self { 29 | let mut env = Environment::new(); 30 | minijinja_contrib::add_to_environment(&mut env); 31 | env.set_loader(embedded_loader); 32 | env.add_filter("split", split); 33 | 34 | let env = Arc::new(RwLock::new(env)); 35 | 36 | let watched = env.clone(); 37 | 38 | let mut watcher = notify::recommended_watcher(move |res| match res { 39 | Ok(_) => { 40 | watched.write().clear_templates(); 41 | println!("cleared templates"); 42 | } 43 | Err(e) => println!("watch error: {:?}", e), 44 | }) 45 | .unwrap(); 46 | 47 | if cfg!(debug_assertions) { 48 | // Add a path to be watched. All files and directories at that path and 49 | // below will be monitored for changes. 50 | watcher 51 | .watch( 52 | Path::new("./pkg/frontend/templates"), 53 | RecursiveMode::Recursive, 54 | ) 55 | .unwrap(); 56 | } 57 | 58 | Self { 59 | env, 60 | _watcher: Arc::new(watcher), 61 | } 62 | } 63 | } 64 | 65 | impl Templates { 66 | pub fn render_template( 67 | &self, 68 | name: &str, 69 | ctx: S, 70 | ) -> Result { 71 | self.env 72 | .read() 73 | .get_template(name) 74 | .map_err(|e| { 75 | println!("get template failed: {:?}", e); 76 | FrontendError::NotFound(name.to_string()) 77 | })? 78 | .render(ctx) 79 | .map_err(|e| { 80 | println!("render template failed: {:?}", e); 81 | FrontendError::InternalError(e.into()) 82 | }) 83 | } 84 | } 85 | 86 | fn embedded_loader(name: &str) -> Result, minijinja::Error> { 87 | let Some(file) = TemplateFiles::get(name) else { 88 | return Ok(None); 89 | }; 90 | 91 | let val = String::from_utf8(file.data.to_vec()) 92 | .map_err(|_| minijinja::Error::from(ErrorKind::CannotDeserialize))?; 93 | 94 | Ok(Some(val)) 95 | } 96 | -------------------------------------------------------------------------------- /pkg/rooms/rooms.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use anyhow::Result; 4 | use database::{messages::ChatMessage, Database}; 5 | use parking_lot::RwLock; 6 | use thiserror::Error; 7 | use time::OffsetDateTime; 8 | use tokio::sync::broadcast::{Receiver, Sender}; 9 | 10 | pub struct Room { 11 | pub room_id: String, 12 | pub sender: Sender, 13 | pub db: Database, 14 | } 15 | 16 | pub struct Manager { 17 | db: Database, 18 | rooms: RwLock>, 19 | } 20 | 21 | impl Manager { 22 | pub fn new(db: Database) -> Self { 23 | Self { 24 | db, 25 | rooms: Default::default(), 26 | } 27 | } 28 | 29 | pub async fn join_room(&self, room_id: String, user_id: &str) -> Result> { 30 | let _ = database::rooms::get_room(&self.db, &room_id, user_id).await?; 31 | 32 | let mut rooms = self.rooms.write(); 33 | let room = rooms.entry(room_id.clone()).or_insert_with(move || { 34 | let (sender, _) = tokio::sync::broadcast::channel::(1000); 35 | Room { 36 | room_id, 37 | sender, 38 | db: self.db.clone(), 39 | } 40 | }); 41 | 42 | let recv = room.sender.subscribe(); 43 | Ok(recv) 44 | } 45 | 46 | pub async fn get_room_messages( 47 | &self, 48 | room_id: &str, 49 | page: i32, 50 | user_id: &str, 51 | ) -> Result> { 52 | let msgs = 53 | database::messages::get_messages_for_room(&self.db, room_id, user_id, page).await?; 54 | 55 | Ok(msgs) 56 | } 57 | 58 | pub async fn send_message( 59 | &self, 60 | room_id: &str, 61 | user_id: &str, 62 | user_name: &str, 63 | user_image: Option, 64 | message: &str, 65 | uploads: Vec, 66 | ) -> Result<()> { 67 | let id = 68 | database::messages::send_message(&self.db, room_id, user_id, message, &uploads).await?; 69 | 70 | let rooms = self.rooms.read(); 71 | let room = rooms 72 | .get(room_id) 73 | .ok_or_else(|| ChatRoomErrors::RoomEmpty(room_id.to_string()))?; 74 | 75 | let uploads = if uploads.is_empty() { 76 | None 77 | } else { 78 | Some(uploads.join(",")) 79 | }; 80 | 81 | let obj = ChatMessage { 82 | id, 83 | room_id: room_id.to_string(), 84 | user_id: user_id.to_string(), 85 | user_name: user_name.to_string(), 86 | user_image, 87 | created_at: OffsetDateTime::now_utc(), 88 | message: message.to_string(), 89 | uploads, 90 | }; 91 | 92 | room.sender.send(obj)?; 93 | 94 | Ok(()) 95 | } 96 | } 97 | 98 | #[derive(Error, Debug)] 99 | pub enum ChatRoomErrors { 100 | #[error("room not joined : {0}")] 101 | RoomEmpty(String), 102 | } 103 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/profile.jinja2: -------------------------------------------------------------------------------- 1 | {% with currentRoom = { 'id': 'profile', 'name': 'Settings', 'description': 'Your profile' } %} 2 | {% include 'components/title.jinja2' %} 3 | {% endwith %} 4 |
    5 |
    6 |
    7 |
    8 | {% with image = user.image %} 9 | {% include 'components/user-profile-image-edit.jinja2' %} 10 | {% endwith %} 11 |
    12 | 18 |
    19 | {% with username = user.username, email = user.email, bio = user.bio %} 20 | {% include 'components/user-profile-edit.jinja2' %} 21 | {% endwith %} 22 |
    23 |
    24 |
    25 | 26 | {% with name="current", placeholder="****", inputType = "password" %} 27 | {% include 'components/text-input.jinja2' %} 28 | {% endwith %} 29 |
    30 |
    31 | 32 | {% with name="update", placeholder="****", inputType = "password" %} 33 | {% include 'components/text-input.jinja2' %} 34 | {% endwith %} 35 |
    36 |
    37 | 38 | {% with name="retype", placeholder="****", inputType = "password" %} 39 | {% include 'components/text-input.jinja2' %} 40 | {% endwith %} 41 |
    42 |
    43 |
    44 | {% with label = "Update Password" %} 45 | {% include 'components/button.jinja2' %} 46 | {% endwith %} 47 |
    48 |
    49 |
    50 | 51 |
    52 | -------------------------------------------------------------------------------- /pkg/database/messages.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use time::OffsetDateTime; 3 | 4 | use crate::Database; 5 | 6 | pub const MAX_FETCH: i32 = 5; 7 | 8 | #[derive(Clone, Debug, serde::Serialize, sqlx::FromRow)] 9 | pub struct ChatMessage { 10 | pub id: String, 11 | pub room_id: String, 12 | pub user_id: String, 13 | pub user_name: String, 14 | pub user_image: Option, 15 | pub created_at: OffsetDateTime, 16 | 17 | pub message: String, 18 | 19 | pub uploads: Option, 20 | } 21 | 22 | pub async fn get_messages_for_room( 23 | db: &Database, 24 | room_id: &str, 25 | user_id: &str, 26 | page: i32, 27 | ) -> Result> { 28 | let offset = page * MAX_FETCH; 29 | let messages = sqlx::query_as!( 30 | ChatMessage, 31 | r#" 32 | SELECT m.id as "id!", m.room_id as "room_id!", m.user_id as "user_id!", m.created_at as "created_at!", m.message as "message!", user_profiles.username as "user_name!", user_profiles.image as "user_image!", GROUP_CONCAT(up.upload_path, '||') as "uploads" 33 | FROM messages m 34 | INNER JOIN user_profiles ON user_profiles.user_id = m.user_id 35 | LEFT JOIN message_uploads up ON up.message_id = m.id 36 | JOIN ( 37 | SELECT r.id 38 | FROM rooms r 39 | LEFT JOIN user_rooms ur ON r.id = ur.room_id AND ur.user_id = $4 40 | WHERE r.id = $1 AND (r.is_private = FALSE OR ur.user_id IS NOT NULL) 41 | ) AS accessible_rooms ON m.room_id = accessible_rooms.id 42 | WHERE m.room_id = $1 43 | GROUP BY m.id 44 | ORDER BY m.created_at DESC 45 | LIMIT $2 46 | OFFSET $3; 47 | "#, 48 | room_id, 49 | MAX_FETCH, 50 | offset, 51 | user_id, 52 | ) 53 | .fetch_all(&db.pool) 54 | .await?; 55 | 56 | Ok(messages) 57 | } 58 | 59 | pub async fn send_message( 60 | db: &Database, 61 | room_id: &str, 62 | user_id: &str, 63 | message: &str, 64 | uploads: &[String], 65 | ) -> Result { 66 | let mut trx = db.pool.begin().await?; 67 | sqlx::query!( 68 | r#" 69 | SELECT r.id 70 | FROM rooms r 71 | LEFT JOIN user_rooms ur ON r.id = ur.room_id AND ur.user_id = $2 72 | WHERE r.id = $1 AND (r.is_private = FALSE OR ur.user_id IS NOT NULL) 73 | "#, 74 | room_id, 75 | user_id 76 | ) 77 | .fetch_one(&mut *trx) 78 | .await?; 79 | 80 | let id = xid::new().to_string(); 81 | 82 | sqlx::query!( 83 | r#" 84 | INSERT INTO messages (id, room_id, user_id, message) 85 | VALUES ($1, $2, $3, $4) 86 | "#, 87 | id, 88 | room_id, 89 | user_id, 90 | message 91 | ) 92 | .execute(&mut *trx) 93 | .await?; 94 | 95 | for upload in uploads { 96 | let upload_url = sqlx::query!( 97 | r#" 98 | SELECT url as 'url' 99 | FROM uploads 100 | WHERE id = $1 101 | "#, 102 | upload 103 | ) 104 | .fetch_one(&mut *trx) 105 | .await?; 106 | let upload_url = upload_url.url; 107 | let attachment_id = xid::new().to_string(); 108 | sqlx::query!( 109 | r#" 110 | INSERT INTO message_uploads(id, message_id, upload_id, upload_path) 111 | VALUES ($1, $2, $3, $4) 112 | "#, 113 | attachment_id, 114 | id, 115 | upload, 116 | upload_url 117 | ) 118 | .execute(&mut *trx) 119 | .await?; 120 | } 121 | 122 | trx.commit().await?; 123 | 124 | Ok(id) 125 | } 126 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/private-room-users.jinja2: -------------------------------------------------------------------------------- 1 | 57 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/partial-register.jinja2: -------------------------------------------------------------------------------- 1 |
    2 | 3 | logo 4 | SpeakWith 5 | 6 |
    7 |
    8 |

    Register

    9 |
    10 |
    11 | 23 |
    24 |
    25 | 26 | {% with inputType = "text", id = "name", placeholder = "First Last" %} 27 | {% include 'components/text-input.jinja2' %} 28 | {% endwith %} 29 |
    30 |
    31 | 32 | {% with inputType = "email", id = "email", placeholder = "you@here.com" %} 33 | {% include 'components/text-input.jinja2' %} 34 | {% endwith %} 35 |
    36 |
    37 | 38 | {% with inputType = "password", id = "password", placeholder = "••••••••" %} 39 | {% include 'components/text-input.jinja2' %} 40 | {% endwith %} 41 |
    42 |
    43 | 44 | {% with inputType = "password", id = "confirm", placeholder = "••••••••" %} 45 | {% include 'components/text-input.jinja2' %} 46 | {% endwith %} 47 |
    48 | {% with label = "Create an account" %} 49 | {% include 'components/button.jinja2' %} 50 | {% endwith %} 51 |

    52 | Already have an account? Login here 53 |

    54 |
    55 |
    56 |
    57 | 78 |
    79 | -------------------------------------------------------------------------------- /pkg/frontend/assets/404.svg: -------------------------------------------------------------------------------- 1 | void -------------------------------------------------------------------------------- /pkg/frontend/templates/components/add-room.jinja2: -------------------------------------------------------------------------------- 1 | 57 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/user-selection.jinja2: -------------------------------------------------------------------------------- 1 | 67 | -------------------------------------------------------------------------------- /pkg/frontend/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Created with Fabric.js 5.3.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/sidebar.jinja2: -------------------------------------------------------------------------------- 1 |
    2 |
    3 | 4 | 5 | 6 |
    7 | 8 | 78 |
    79 | -------------------------------------------------------------------------------- /pkg/frontend/templates/components/thread-view.jinja2: -------------------------------------------------------------------------------- 1 |
    2 |
    3 |
    4 |

    Thread

    5 | council-of-elrond 6 |
    7 | 12 |
    13 |
    14 |
    15 |
    16 |
    17 |
    18 | Boromim 19 | 01:26 20 |
    21 |

    Aragorn? This… is Isildur’s heir?

    22 |
    23 |
    24 |
    25 |
    26 |
    27 |
    28 | Legolas 29 | 01:26 30 |
    31 |

    And heir to the throne of Gondor.

    32 |
    33 | 37 | 41 | 45 |
    46 |
    47 |
    48 |
    49 |
    50 |
    51 |
    52 | Aragorn 53 | 01:26 54 |
    55 |

    Havo dad Legolas.

    56 |
    57 | 61 |
    62 |
    63 |
    64 |
    65 |
    66 |
    67 |
    68 | Pippin 69 | 01:26 70 |
    71 |

    Is that elvish for second breakfast?

    72 |
    73 | 77 |
    78 |
    79 |
    80 |
    81 |
    82 | 83 |
    84 | 89 | 94 | 97 | 102 | 107 | 112 |
    113 | 114 |
    115 |
    116 |
    117 | 118 |
    119 | -------------------------------------------------------------------------------- /pkg/database/rooms.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use sqlx::{Pool, Sqlite}; 3 | use thiserror::Error; 4 | use time::OffsetDateTime; 5 | 6 | use crate::Database; 7 | 8 | #[derive(serde::Serialize)] 9 | pub struct Room { 10 | pub id: String, 11 | pub name: String, 12 | pub is_private: bool, 13 | pub is_user: bool, 14 | pub description: String, 15 | pub created_at: OffsetDateTime, 16 | } 17 | 18 | #[derive(Error, Debug)] 19 | pub enum RoomError { 20 | #[error("room cannot be empty")] 21 | CannotBeEmpty, 22 | } 23 | 24 | pub async fn init_rooms(pool: &Pool) -> Result<()> { 25 | let mut tx = pool.begin().await?; 26 | 27 | let None = sqlx::query_as!( 28 | Room, 29 | r#" 30 | SELECT id, name, description, is_user as "is_user!", is_private as "is_private!", created_at as "created_at!" FROM rooms 31 | "# 32 | ) 33 | .fetch_one(&mut *tx) 34 | .await 35 | .ok() else { 36 | // some rooms exists 37 | return Ok(()); 38 | }; 39 | 40 | let general = Room { 41 | id: "general".into(), 42 | name: "general".into(), 43 | is_private: false, 44 | is_user: false, 45 | description: "The general channel".to_string(), 46 | created_at: OffsetDateTime::now_utc(), 47 | }; 48 | 49 | sqlx::query!( 50 | r#" 51 | INSERT INTO rooms (id, name, description) 52 | VALUES ($1, $2, $3)"#, 53 | general.id, 54 | general.name, 55 | general.description, 56 | ) 57 | .execute(&mut *tx) 58 | .await?; 59 | 60 | tx.commit().await?; 61 | 62 | Ok(()) 63 | } 64 | 65 | #[derive(sqlx::FromRow, serde::Serialize, Debug)] 66 | pub struct RoomUser { 67 | pub id: String, 68 | pub name: String, 69 | } 70 | 71 | pub async fn add_user_to_room(db: &Database, roomid: &str, userid: &str) -> Result<()> { 72 | sqlx::query!( 73 | r#"INSERT INTO user_rooms (user_id, room_id) VALUES ($1, $2)"#, 74 | userid, 75 | roomid 76 | ) 77 | .execute(&db.pool) 78 | .await?; 79 | 80 | Ok(()) 81 | } 82 | 83 | pub async fn remove_user_from_room(db: &Database, roomid: &str, userid: &str) -> Result<()> { 84 | let total = sqlx::query!( 85 | "SELECT COUNT(*) as count FROM user_rooms WHERE room_id = $1", 86 | roomid 87 | ) 88 | .fetch_one(&db.pool) 89 | .await?; 90 | 91 | if total.count == 1 { 92 | return Err(RoomError::CannotBeEmpty.into()); 93 | } 94 | 95 | sqlx::query!( 96 | r#"DELETE FROM user_rooms WHERE user_id = $1 AND room_id = $2"#, 97 | userid, 98 | roomid 99 | ) 100 | .execute(&db.pool) 101 | .await?; 102 | 103 | Ok(()) 104 | } 105 | 106 | pub async fn is_member_of_room(db: &Database, roomid: &str, userid: &str) -> bool { 107 | let output = sqlx::query!( 108 | "SELECT user_id FROM user_rooms WHERE room_id = $1 AND user_id = $2", 109 | roomid, 110 | userid 111 | ) 112 | .fetch_one(&db.pool) 113 | .await; 114 | 115 | output.is_ok() 116 | } 117 | 118 | pub async fn get_room_users(db: &Database, roomid: &str) -> Result> { 119 | let users = sqlx::query_as!( 120 | RoomUser, 121 | r#" 122 | SELECT ur.user_id as "id!", p.username as "name!" 123 | FROM user_rooms ur 124 | LEFT JOIN user_profiles AS p ON ur.user_id = p.user_id 125 | WHERE ur.room_id = $1 126 | "#, 127 | roomid 128 | ) 129 | .fetch_all(&db.pool) 130 | .await?; 131 | 132 | Ok(users) 133 | } 134 | 135 | pub async fn get_room(db: &Database, roomid: &str, user_id: &str) -> Result { 136 | let room = sqlx::query_as!( 137 | Room, 138 | r#" 139 | SELECT id, description, name, is_user as "is_user!", is_private as "is_private!", created_at as "created_at!" 140 | FROM rooms r 141 | LEFT JOIN user_rooms ur ON r.id = ur.room_id AND ur.user_id = $1 142 | WHERE r.id = $2 AND (r.is_private = FALSE OR ur.user_id IS NOT NULL); 143 | "#, 144 | user_id, 145 | roomid 146 | ) 147 | .fetch_one(&db.pool) 148 | .await?; 149 | 150 | Ok(room) 151 | } 152 | 153 | pub async fn create_room( 154 | db: &Database, 155 | id: &str, 156 | name: &str, 157 | description: &str, 158 | is_private: bool, 159 | is_user: bool, 160 | users: &[String], 161 | ) -> Result { 162 | let mut tx = db.pool.begin().await?; 163 | 164 | if sqlx::query!("SELECT id FROM rooms WHERE id = $1", id) 165 | .fetch_one(&mut *tx) 166 | .await 167 | .is_ok() 168 | { 169 | // room with this id already exists, return early 170 | return Ok(id.to_string()); 171 | }; 172 | 173 | let room = Room { 174 | id: id.into(), 175 | name: name.into(), 176 | is_user, 177 | is_private, 178 | description: description.to_string(), 179 | created_at: OffsetDateTime::now_utc(), 180 | }; 181 | 182 | sqlx::query!( 183 | r#" 184 | INSERT INTO rooms (id, name, description, is_private, is_user) 185 | VALUES ($1, $2, $3, $4, $5)"#, 186 | room.id, 187 | room.name, 188 | room.description, 189 | room.is_private, 190 | room.is_user, 191 | ) 192 | .execute(&mut *tx) 193 | .await?; 194 | 195 | if room.is_private { 196 | for user_id in users { 197 | sqlx::query!( 198 | r#" 199 | INSERT INTO user_rooms (room_id, user_id) 200 | VALUES ($1, $2) 201 | "#, 202 | id, 203 | user_id 204 | ) 205 | .execute(&mut *tx) 206 | .await?; 207 | } 208 | } 209 | 210 | tx.commit().await?; 211 | 212 | Ok(id.to_string()) 213 | } 214 | 215 | pub async fn get_rooms(db: &Database, user_id: &str) -> Result<(Vec, Vec)> { 216 | let rooms = sqlx::query_as!( 217 | Room, 218 | r#" 219 | SELECT id, description, name, is_user as "is_user!", is_private as "is_private!", created_at as "created_at!" 220 | FROM rooms r 221 | LEFT JOIN user_rooms ur ON r.id = ur.room_id AND ur.user_id = $1 222 | WHERE r.is_private = FALSE OR ur.user_id IS NOT NULL 223 | "#, 224 | user_id 225 | ) 226 | .fetch_all(&db.pool) 227 | .await?; 228 | 229 | let rooms = rooms.into_iter().partition(|v| v.is_user); 230 | 231 | Ok(rooms) 232 | } 233 | -------------------------------------------------------------------------------- /pkg/database/users.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use argon2::{ 3 | password_hash::{PasswordHasher, SaltString}, 4 | Argon2, PasswordHash, PasswordVerifier, 5 | }; 6 | use rand_core::OsRng; 7 | use sqlx::{Sqlite, Transaction}; 8 | use thiserror::Error; 9 | use time::OffsetDateTime; 10 | 11 | use crate::Database; 12 | 13 | #[derive(sqlx::FromRow, serde::Serialize, Debug)] 14 | pub struct UserCombined { 15 | pub id: String, 16 | pub email: String, 17 | pub is_admin: bool, 18 | pub is_enabled: bool, 19 | pub created_at: OffsetDateTime, 20 | pub username: String, 21 | pub bio: Option, 22 | pub image: Option, 23 | } 24 | 25 | pub struct User { 26 | pub id: String, 27 | pub email: String, 28 | pub password: String, 29 | pub hash: String, 30 | pub is_admin: bool, 31 | pub is_enabled: bool, 32 | pub created_at: OffsetDateTime, 33 | } 34 | 35 | impl Default for User { 36 | fn default() -> Self { 37 | Self { 38 | id: Default::default(), 39 | email: Default::default(), 40 | password: Default::default(), 41 | hash: Default::default(), 42 | is_admin: false, 43 | is_enabled: false, 44 | created_at: OffsetDateTime::now_utc(), 45 | } 46 | } 47 | } 48 | 49 | #[derive(Default)] 50 | pub struct UserProfile { 51 | pub id: String, 52 | pub user_id: String, 53 | pub username: String, 54 | pub bio: Option, 55 | pub image: Option, 56 | } 57 | 58 | pub async fn has_admin(db: &Database) -> Result { 59 | let res = sqlx::query!("SELECT COUNT(*) user_count FROM users") 60 | .fetch_one(&db.pool) 61 | .await?; 62 | 63 | let count = res.user_count; 64 | 65 | Ok(count > 0) 66 | } 67 | 68 | pub async fn search_users(db: &Database, search: &str, user_id: &str) -> Result> { 69 | let search = format!("%{}%", search); 70 | let output = sqlx::query_as!( 71 | UserCombined, 72 | r#" 73 | SELECT u.id, u.email, u.is_admin as "is_admin!", u.is_enabled as "is_enabled!", u.created_at as "created_at!", p.username as username, p.bio, p.image 74 | FROM users AS u 75 | INNER JOIN user_profiles AS p ON u.id = p.user_id 76 | WHERE (p.username LIKE $1) AND u.id != $2 77 | LIMIT 5 78 | "#, 79 | search, 80 | user_id 81 | ).fetch_all(&db.pool).await?; 82 | 83 | Ok(output) 84 | } 85 | 86 | pub async fn update_user_password( 87 | db: &Database, 88 | email: &str, 89 | current: &str, 90 | update: &str, 91 | ) -> Result<()> { 92 | let user_id = verify_userpassword(db, email, current).await?; 93 | 94 | let (password, salt) = hash_password(update)?; 95 | 96 | sqlx::query!( 97 | r#" 98 | UPDATE users 99 | SET password = $1, hash = $2 100 | WHERE id = $3 101 | "#, 102 | password, 103 | salt, 104 | user_id 105 | ) 106 | .execute(&db.pool) 107 | .await?; 108 | 109 | Ok(()) 110 | } 111 | 112 | pub async fn update_user_profile( 113 | db: &Database, 114 | user_id: &str, 115 | name: &str, 116 | bio: &str, 117 | ) -> Result { 118 | sqlx::query!( 119 | r#" 120 | UPDATE user_profiles 121 | SET username = $1, bio = $2 122 | WHERE user_id = $3 123 | "#, 124 | name, 125 | bio, 126 | user_id 127 | ) 128 | .execute(&db.pool) 129 | .await?; 130 | 131 | get_user_with_profile(db, user_id).await 132 | } 133 | 134 | pub async fn set_user_image<'a>( 135 | trx: &mut Transaction<'a, Sqlite>, 136 | user_id: &str, 137 | url: Option<&str>, 138 | ) -> Result<()> { 139 | sqlx::query!( 140 | r#" 141 | UPDATE user_profiles 142 | SET image = $1 143 | WHERE user_id = $2 144 | "#, 145 | url, 146 | user_id 147 | ) 148 | .execute(&mut **trx) 149 | .await?; 150 | 151 | Ok(()) 152 | } 153 | 154 | pub async fn unset_user_image<'a>(db: &Database, user_id: &str) -> Result<()> { 155 | sqlx::query!( 156 | r#" 157 | UPDATE user_profiles 158 | SET image = $1 159 | WHERE user_id = $2 160 | "#, 161 | None::, 162 | user_id 163 | ) 164 | .execute(&db.pool) 165 | .await?; 166 | 167 | Ok(()) 168 | } 169 | 170 | pub async fn enable_user(db: &Database, user_id: &str, enable: bool) -> Result<()> { 171 | sqlx::query!( 172 | r#" 173 | UPDATE users 174 | SET is_enabled = $1 175 | WHERE id = $2; 176 | "#, 177 | enable, 178 | user_id 179 | ) 180 | .execute(&db.pool) 181 | .await?; 182 | 183 | Ok(()) 184 | } 185 | 186 | #[derive(sqlx::FromRow)] 187 | pub struct NameQuery { 188 | username: String, 189 | } 190 | 191 | pub async fn get_usernames_by_id(db: &Database, users: &str) -> Result> { 192 | println!("users:{}", users); 193 | let query = format!( 194 | "SELECT username FROM user_profiles WHERE user_id IN ({})", 195 | users 196 | ); 197 | let res: Vec = sqlx::query_as(&query).fetch_all(&db.pool).await?; 198 | 199 | let output = res.into_iter().map(|v| v.username).collect::>(); 200 | 201 | Ok(output) 202 | } 203 | 204 | pub async fn make_admin_user(db: &Database, user_id: &str, enable: bool) -> Result<()> { 205 | sqlx::query!( 206 | r#" 207 | UPDATE users 208 | SET is_admin = $1 209 | WHERE id = $2; 210 | "#, 211 | enable, 212 | user_id 213 | ) 214 | .execute(&db.pool) 215 | .await?; 216 | 217 | Ok(()) 218 | } 219 | 220 | pub async fn create_user<'a>( 221 | user_id: &str, 222 | mut trx: Transaction<'a, Sqlite>, 223 | user: User, 224 | profile: UserProfile, 225 | ) -> Result<(String, Transaction<'a, Sqlite>)> { 226 | let (password, salt) = hash_password(&user.password)?; 227 | 228 | sqlx::query_as!( 229 | User, 230 | r#" 231 | INSERT INTO users (id, email, password, hash, is_admin, is_enabled) 232 | VALUES ($1, $2, $3, $4, $5, $6) 233 | "#, 234 | user_id, 235 | user.email, 236 | password, 237 | salt, 238 | user.is_admin, 239 | user.is_enabled, 240 | ) 241 | .execute(&mut *trx) 242 | .await?; 243 | 244 | let profile_id = xid::new().to_string(); 245 | sqlx::query_as!( 246 | UserProfile, 247 | r#" 248 | INSERT INTO user_profiles (id, user_id, username, bio, image) 249 | VALUES ($1 ,$2, $3, $4, $5) 250 | "#, 251 | profile_id, 252 | user_id, 253 | profile.username, 254 | profile.bio, 255 | profile.image, 256 | ) 257 | .execute(&mut *trx) 258 | .await?; 259 | 260 | Ok((user_id.to_string(), trx)) 261 | } 262 | 263 | pub async fn get_user_list(db: &Database) -> Result> { 264 | sqlx::query_as!( 265 | UserCombined, 266 | r#" 267 | SELECT u.id, u.email, u.is_admin as "is_admin!", u.is_enabled as "is_enabled!", u.created_at as "created_at!", p.username as username, p.bio, p.image 268 | FROM users AS u 269 | INNER JOIN user_profiles AS p ON u.id = p.user_id 270 | "#, 271 | ) 272 | .fetch_all(&db.pool) 273 | .await.map_err(|e| e.into()) 274 | } 275 | 276 | pub async fn get_user_with_profile(db: &Database, userid: &str) -> Result { 277 | sqlx::query_as!( 278 | UserCombined, 279 | r#" 280 | SELECT u.id, u.email, u.is_admin as "is_admin!", u.is_enabled as "is_enabled!", u.created_at as "created_at!", p.username as username, p.bio, p.image 281 | FROM users AS u 282 | INNER JOIN user_profiles AS p ON u.id = p.user_id 283 | WHERE u.id = $1 284 | "#, 285 | userid 286 | ) 287 | .fetch_one(&db.pool) 288 | .await.map_err(|e| e.into()) 289 | } 290 | 291 | pub async fn verify_userpassword( 292 | db: &Database, 293 | email: &str, 294 | password: &str, 295 | ) -> Result { 296 | let user = sqlx::query!( 297 | r#"SELECT id, is_enabled as "is_enabled!", password FROM users WHERE email = $1"#, 298 | email 299 | ) 300 | .fetch_one(&db.pool) 301 | .await 302 | .map_err(|e| DBUserErrors::InternalError(e.into()))?; 303 | 304 | if !user.is_enabled { 305 | return Err(DBUserErrors::UserNotEnabled); 306 | } 307 | 308 | let existing = 309 | String::from_utf8(user.password).map_err(|e| DBUserErrors::InternalError(e.into()))?; 310 | if compare_password(&existing, password) { 311 | return Ok(user.id); 312 | } 313 | 314 | Err(DBUserErrors::PasswordMismatch(password.to_string())) 315 | } 316 | 317 | fn hash_password(password: &str) -> Result<(String, String), DBUserErrors> { 318 | let salt = SaltString::generate(&mut OsRng); 319 | let argon2 = Argon2::default(); 320 | 321 | let password_hash = argon2 322 | .hash_password(password.as_bytes(), &salt) 323 | .map_err(DBUserErrors::PasswordHashFailed)? 324 | .to_string(); 325 | 326 | Ok((password_hash, salt.to_string())) 327 | } 328 | 329 | fn compare_password(hashed: &str, password: &str) -> bool { 330 | let Ok(parsed_hash) = PasswordHash::new(hashed) else { 331 | return false; 332 | }; 333 | 334 | Argon2::default() 335 | .verify_password(password.as_bytes(), &parsed_hash) 336 | .is_ok() 337 | } 338 | 339 | #[derive(Debug, Error)] 340 | pub enum DBUserErrors { 341 | #[error("failed to hash password: {0}")] 342 | PasswordHashFailed(argon2::password_hash::Error), 343 | #[error("password mismatch: {0}")] 344 | PasswordMismatch(String), 345 | #[error("user not enabled")] 346 | UserNotEnabled, 347 | #[error("internal error: {0}")] 348 | InternalError(anyhow::Error), 349 | } 350 | -------------------------------------------------------------------------------- /pkg/frontend/frontend.rs: -------------------------------------------------------------------------------- 1 | use std::sync::{atomic::AtomicBool, Arc}; 2 | 3 | use anyhow::Result; 4 | use api::{extract_user, get_random_alphanumeric, setup_api, validate_token}; 5 | use assets::setup_asset_handler; 6 | use axum::{ 7 | debug_handler, 8 | extract::{Path, Request, State}, 9 | http::StatusCode, 10 | response::{Html, IntoResponse, Redirect}, 11 | Router, 12 | }; 13 | use axum_extra::extract::CookieJar; 14 | use axum_htmx::HxRequest; 15 | use database::{rooms::RoomUser, Database}; 16 | use minijinja::context; 17 | use parking_lot::RwLock; 18 | use templates::Templates; 19 | use thiserror::Error; 20 | use tower::ServiceExt; 21 | use tower_http::services::ServeDir; 22 | 23 | mod api; 24 | mod assets; 25 | mod templates; 26 | 27 | #[derive(Error, Debug)] 28 | pub enum FrontendError { 29 | #[error("internal server error : {0}")] 30 | InternalError(anyhow::Error), 31 | #[error("not found : {0}")] 32 | NotFound(String), 33 | #[error("unauthorized")] 34 | Unauthorized, 35 | #[error("no permission")] 36 | NoPermission, 37 | #[error("no permission")] 38 | UserNotEnabled, 39 | #[error("needs_admin")] 40 | NeedsAdmin, 41 | #[error("already logged in")] 42 | AlreadyLoggedIn, 43 | #[error("invalid credentials")] 44 | InvalidCredentials, 45 | #[error("invalid file")] 46 | InvalidForm(String), 47 | } 48 | 49 | impl IntoResponse for FrontendError { 50 | fn into_response(self) -> axum::response::Response { 51 | match self { 52 | FrontendError::Unauthorized => Redirect::temporary("/login").into_response(), 53 | FrontendError::NeedsAdmin => Redirect::temporary("/register/admin").into_response(), 54 | FrontendError::AlreadyLoggedIn => Redirect::temporary("/").into_response(), 55 | FrontendError::InvalidForm(e) => (StatusCode::BAD_REQUEST, e).into_response(), 56 | FrontendError::NoPermission => (StatusCode::UNAUTHORIZED).into_response(), 57 | FrontendError::InternalError(e) => { 58 | (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response() 59 | } 60 | _ => (StatusCode::INTERNAL_SERVER_ERROR, "").into_response(), 61 | } 62 | } 63 | } 64 | 65 | #[derive(Clone)] 66 | struct FrontendState { 67 | has_admin: Arc, 68 | templates: Templates, 69 | room_manager: Arc, 70 | db: Database, 71 | secret: String, 72 | uploads_path: String, 73 | register_id: Arc>, 74 | } 75 | 76 | pub async fn initialize( 77 | _base_route: &str, 78 | secret: &str, 79 | data_path: String, 80 | db: Database, 81 | ) -> Result { 82 | let has_admin = database::users::has_admin(&db).await?; 83 | let register_id = if has_admin { 84 | get_random_alphanumeric() 85 | } else { 86 | String::from("admin") 87 | }; 88 | 89 | let templates = Templates::default(); 90 | 91 | let state = Arc::new(FrontendState { 92 | has_admin: Arc::new(AtomicBool::new(has_admin)), 93 | templates, 94 | secret: secret.to_string(), 95 | room_manager: Arc::new(rooms::Manager::new(db.clone())), 96 | register_id: Arc::new(RwLock::new(register_id)), 97 | uploads_path: format!("{}/uploads", &data_path), 98 | db, 99 | }); 100 | 101 | std::fs::create_dir_all(&state.uploads_path)?; 102 | 103 | let router = Router::new() 104 | .route("/", axum::routing::get(home_handler)) 105 | .route("/login", axum::routing::get(login_handler)) 106 | .route("/register/:id", axum::routing::get(register_handler)) 107 | .route("/users", axum::routing::get(user_handler)) 108 | .route("/profile", axum::routing::get(profile_handler)) 109 | .route("/chatroom/:roomid", axum::routing::get(room_handler)) 110 | .route("/template/*path", axum::routing::get(template_handler)) 111 | .with_state(state.clone()) 112 | .nest_service( 113 | "/uploads", 114 | axum::routing::get(uploads_handler).with_state(state.clone()), 115 | ) 116 | .nest("/htmx", setup_api(state)) 117 | .nest("/assets", setup_asset_handler()); 118 | 119 | Ok(router) 120 | } 121 | 122 | #[debug_handler] 123 | async fn uploads_handler( 124 | jar: CookieJar, 125 | State(state): State>, 126 | req: Request, 127 | ) -> Result { 128 | let Some(_) = validate_token(jar, &state.secret) else { 129 | return Err(FrontendError::Unauthorized); 130 | }; 131 | 132 | let service = ServeDir::new(&state.uploads_path); 133 | let result = service 134 | .oneshot(req) 135 | .await 136 | .map_err(|e| FrontendError::InternalError(e.into())); 137 | 138 | Ok(result) 139 | } 140 | 141 | #[debug_handler] 142 | async fn template_handler( 143 | Path(path): Path, 144 | State(state): State>, 145 | ) -> Result { 146 | let templates = &state.templates; 147 | 148 | let output = templates.render_template(&path, context! {})?; 149 | 150 | Ok(Html(output)) 151 | } 152 | 153 | #[debug_handler] 154 | async fn profile_handler( 155 | jar: CookieJar, 156 | HxRequest(is_htmx): HxRequest, 157 | State(state): State>, 158 | ) -> Result { 159 | let user = redirect_to_register(jar, &state).await?; 160 | 161 | let output = if is_htmx { 162 | state 163 | .templates 164 | .render_template("components/profile.jinja2", context! { user => user })? 165 | } else { 166 | let (user_rooms, rooms) = database::rooms::get_rooms(&state.db, &user.id) 167 | .await 168 | .map_err(FrontendError::InternalError)?; 169 | 170 | state.templates.render_template( 171 | "profile.jinja2", 172 | context! { rooms => rooms, user_rooms => user_rooms , user => user }, 173 | )? 174 | }; 175 | 176 | Ok(Html(output).into_response()) 177 | } 178 | 179 | #[debug_handler] 180 | async fn home_handler( 181 | jar: CookieJar, 182 | State(state): State>, 183 | ) -> Result { 184 | let user = redirect_to_register(jar, &state).await?; 185 | 186 | let (user_rooms, rooms) = database::rooms::get_rooms(&state.db, &user.id) 187 | .await 188 | .map_err(FrontendError::InternalError)?; 189 | 190 | let templates = &state.templates; 191 | 192 | let output = templates.render_template( 193 | "home.jinja2", 194 | context! { rooms => rooms, user_rooms => user_rooms, user => user }, 195 | )?; 196 | 197 | Ok(Html(output).into_response()) 198 | } 199 | 200 | #[debug_handler] 201 | async fn room_handler( 202 | HxRequest(is_htmx): HxRequest, 203 | jar: CookieJar, 204 | Path(roomid): Path, 205 | State(state): State>, 206 | ) -> Result { 207 | let user = redirect_to_register(jar, &state).await?; 208 | 209 | let room = database::rooms::get_room(&state.db, &roomid, &user.id) 210 | .await 211 | .map_err(|e| FrontendError::NotFound(e.to_string()))?; 212 | 213 | let messages = state 214 | .room_manager 215 | .get_room_messages(&roomid, 0, &user.id) 216 | .await 217 | .map_err(FrontendError::InternalError)?; 218 | 219 | dbg!(&messages); 220 | 221 | let page = if messages.len() < database::messages::MAX_FETCH as usize { 222 | 0 223 | } else { 224 | 1 225 | }; 226 | 227 | let room_users: Vec = if room.is_private && !room.is_user { 228 | database::rooms::get_room_users(&state.db, &roomid) 229 | .await 230 | .map_err(FrontendError::InternalError)? 231 | } else { 232 | vec![] 233 | }; 234 | 235 | let output = if is_htmx { 236 | state.templates.render_template( 237 | "components/chatroom.jinja2", 238 | context! { roomid => roomid, currentRoom => room, messages => messages, page => page, user => user, roomUsers => room_users }, 239 | )? 240 | } else { 241 | let (user_rooms, rooms) = database::rooms::get_rooms(&state.db, &user.id) 242 | .await 243 | .map_err(FrontendError::InternalError)?; 244 | 245 | state.templates.render_template( 246 | "room.jinja2", 247 | context! { rooms => rooms, roomid => roomid, currentRoom => room, user_rooms => user_rooms , messages => messages, page => page, user => user, roomUsers => room_users }, 248 | )? 249 | }; 250 | 251 | Ok(Html(output).into_response()) 252 | } 253 | 254 | async fn redirect_to_home(jar: CookieJar, state: &Arc) -> Result<(), FrontendError> { 255 | let Some(_) = extract_user(jar, &state.db, &state.secret).await else { 256 | return Ok(()); 257 | }; 258 | 259 | Err(FrontendError::AlreadyLoggedIn) 260 | } 261 | 262 | async fn redirect_to_register( 263 | jar: CookieJar, 264 | state: &Arc, 265 | ) -> Result { 266 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 267 | if state.has_admin.load(std::sync::atomic::Ordering::Relaxed) { 268 | return Err(FrontendError::Unauthorized); 269 | } else { 270 | return Err(FrontendError::NeedsAdmin); 271 | } 272 | }; 273 | 274 | Ok(user) 275 | } 276 | 277 | #[debug_handler] 278 | async fn register_handler( 279 | jar: CookieJar, 280 | Path(id): Path, 281 | State(state): State>, 282 | ) -> Result { 283 | redirect_to_home(jar, &state).await?; 284 | 285 | if id != *state.register_id.read() { 286 | return Err(FrontendError::NotFound( 287 | "invalid registration link".to_string(), 288 | )); 289 | } 290 | 291 | let templates = &state.templates; 292 | 293 | let output = templates.render_template("register.jinja2", context! {})?; 294 | 295 | Ok(Html(output)) 296 | } 297 | 298 | #[debug_handler] 299 | async fn user_handler( 300 | jar: CookieJar, 301 | HxRequest(is_htmx): HxRequest, 302 | State(state): State>, 303 | ) -> Result { 304 | let user = redirect_to_register(jar, &state).await?; 305 | 306 | if !user.is_admin { 307 | return Err(FrontendError::NoPermission); 308 | } 309 | 310 | let user_list = database::users::get_user_list(&state.db) 311 | .await 312 | .map_err(FrontendError::InternalError)?; 313 | 314 | let output = if is_htmx { 315 | let register_id = state.register_id.read(); 316 | let register_id = register_id.as_str().to_string(); 317 | 318 | state.templates.render_template( 319 | "components/users.jinja2", 320 | context! { register_id => register_id, user => user, userlist => user_list }, 321 | )? 322 | } else { 323 | let (user_rooms, rooms) = database::rooms::get_rooms(&state.db, &user.id) 324 | .await 325 | .map_err(FrontendError::InternalError)?; 326 | 327 | let register_id = state.register_id.read(); 328 | let register_id = register_id.as_str().to_string(); 329 | 330 | state.templates.render_template( 331 | "users.jinja2", 332 | context! { rooms => rooms, user_rooms => user_rooms, register_id => register_id, user => user, userlist => user_list }, 333 | )? 334 | }; 335 | 336 | Ok(Html(output)) 337 | } 338 | 339 | #[debug_handler] 340 | async fn login_handler( 341 | jar: CookieJar, 342 | State(state): State>, 343 | ) -> Result { 344 | redirect_to_home(jar, &state).await?; 345 | 346 | let templates = &state.templates; 347 | 348 | let output = templates.render_template("login.jinja2", context! {})?; 349 | 350 | Ok(Html(output)) 351 | } 352 | -------------------------------------------------------------------------------- /pkg/frontend/assets/chat.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/frontend/assets/wait.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg/frontend/api.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | convert::Infallible, 3 | sync::{atomic::Ordering, Arc}, 4 | }; 5 | 6 | use anyhow::Result; 7 | use axum::{ 8 | debug_handler, 9 | extract::{Multipart, Path, Query, State}, 10 | response::{ 11 | sse::{Event, KeepAlive}, 12 | Html, IntoResponse, Sse, 13 | }, 14 | routing::{get, post}, 15 | Form, Router, 16 | }; 17 | use axum_extra::extract::{ 18 | cookie::{Cookie, SameSite}, 19 | CookieJar, 20 | }; 21 | use axum_htmx::HxRedirect; 22 | use convert_case::{Case, Casing}; 23 | use database::{users::UserCombined, Database}; 24 | use futures::TryStreamExt; 25 | use minijinja::context; 26 | use rand::{distributions::Alphanumeric, thread_rng, Rng}; 27 | use time::{Duration, OffsetDateTime}; 28 | use tokio_stream::wrappers::BroadcastStream; 29 | use tokio_stream::StreamExt as _; 30 | use users::LoginForm; 31 | 32 | use crate::{FrontendError, FrontendState}; 33 | 34 | pub fn setup_api(state: Arc) -> Router { 35 | Router::new() 36 | .route("/register", post(handle_registration)) 37 | .route("/login", post(handle_login)) 38 | .route("/create-room", post(handle_create_room)) 39 | .route("/reset-register-link", post(handle_reset_register_link)) 40 | .route("/search-user", get(handle_search_users)) 41 | .route("/create-user-room", post(handle_create_user_room)) 42 | .route("/user/update/password", post(handle_update_user_password)) 43 | .route("/user/update/profile", post(handle_update_user_profile)) 44 | .route("/user/update/image", post(handle_update_user_image)) 45 | .route("/user/update/image-none", post(handle_delete_user_image)) 46 | .route("/users/:userid/enabled", post(handle_enable_user)) 47 | .route("/users/:userid/admin", post(handle_user_admin)) 48 | .route("/room/:roomid", get(handle_join_room)) 49 | .route("/room/:roomid/upload", post(handle_upload_to_room)) 50 | .route("/room/:roomid/send", post(handle_send_message)) 51 | .route("/room/:roomid/more", get(handle_pagination)) 52 | .route("/room/:roomid/add/:userid", post(handle_add_user_to_room)) 53 | .route( 54 | "/room/:roomid/remove/:userid", 55 | post(handle_remove_user_from_room), 56 | ) 57 | .with_state(state) 58 | } 59 | 60 | #[debug_handler] 61 | async fn handle_reset_register_link( 62 | jar: CookieJar, 63 | State(state): State>, 64 | ) -> Result { 65 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 66 | return Err(FrontendError::Unauthorized); 67 | }; 68 | 69 | if !user.is_admin { 70 | return Err(FrontendError::NoPermission); 71 | } 72 | 73 | let mut regid = state.register_id.write(); 74 | *regid = get_random_alphanumeric(); 75 | 76 | let regid = regid.to_string(); 77 | 78 | let output = state.templates.render_template( 79 | "components/invite.jinja2", 80 | context! { 81 | register_id => regid, 82 | }, 83 | )?; 84 | 85 | Ok(Html(output).into_response()) 86 | } 87 | 88 | #[derive(serde::Deserialize)] 89 | struct Allow { 90 | value: bool, 91 | } 92 | 93 | #[debug_handler] 94 | async fn handle_user_admin( 95 | jar: CookieJar, 96 | Path(userid): Path, 97 | allow: Query, 98 | State(state): State>, 99 | ) -> Result { 100 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 101 | return Err(FrontendError::Unauthorized); 102 | }; 103 | 104 | if !user.is_admin { 105 | return Err(FrontendError::NoPermission); 106 | } 107 | 108 | if user.id == userid { 109 | // not allowed to enable/disable self 110 | return Err(FrontendError::NoPermission); 111 | } 112 | 113 | database::users::make_admin_user(&state.db, &userid, allow.value) 114 | .await 115 | .map_err(FrontendError::InternalError)?; 116 | 117 | let template = "components/user-buttons-admin.jinja2"; 118 | 119 | let output = state.templates.render_template( 120 | template, 121 | context! { 122 | is_admin => allow.value, 123 | userid => userid, 124 | }, 125 | )?; 126 | 127 | Ok(Html(output).into_response()) 128 | } 129 | 130 | #[derive(serde::Deserialize, Debug)] 131 | #[serde(untagged)] 132 | enum UserList { 133 | Single(String), 134 | Many(Vec), 135 | } 136 | 137 | #[derive(serde::Deserialize, Debug)] 138 | struct UserRoomForm { 139 | user: UserList, 140 | } 141 | 142 | #[debug_handler] 143 | async fn handle_create_user_room( 144 | jar: CookieJar, 145 | State(state): State>, 146 | axum_extra::extract::Form(form): axum_extra::extract::Form, // extra::form to read array values 147 | ) -> Result { 148 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 149 | return Err(FrontendError::Unauthorized); 150 | }; 151 | 152 | let mut users = match form.user { 153 | UserList::Single(item) => vec![item], 154 | UserList::Many(items) => items, 155 | }; 156 | 157 | let userids = users 158 | .iter() 159 | .map(|u| format!(r#"'{}'"#, u)) 160 | .collect::>() 161 | .join(","); 162 | 163 | let usernames = database::users::get_usernames_by_id(&state.db, &userids) 164 | .await 165 | .map_err(FrontendError::InternalError)? 166 | .join(", "); 167 | 168 | // add self to it 169 | users.push(user.id); 170 | users.sort_unstable(); 171 | users.dedup(); 172 | 173 | if users.is_empty() { 174 | return Err(FrontendError::InvalidForm( 175 | "needs at least one user!".into(), 176 | )); 177 | } 178 | 179 | let room_id = users.join("-"); 180 | 181 | let room_id = 182 | database::rooms::create_room(&state.db, &room_id, &usernames, "", true, true, &users) 183 | .await 184 | .map_err(FrontendError::InternalError)?; 185 | 186 | Ok(( 187 | HxRedirect(format!("/chatroom/{}", room_id).parse().unwrap()), 188 | "", 189 | ) 190 | .into_response()) 191 | } 192 | 193 | #[debug_handler] 194 | async fn handle_enable_user( 195 | jar: CookieJar, 196 | Path(userid): Path, 197 | allow: Query, 198 | State(state): State>, 199 | ) -> Result { 200 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 201 | return Err(FrontendError::Unauthorized); 202 | }; 203 | 204 | if !user.is_admin { 205 | return Err(FrontendError::NoPermission); 206 | } 207 | 208 | if user.id == userid { 209 | // not allowed to enable/disable self 210 | return Err(FrontendError::NoPermission); 211 | } 212 | 213 | let otheruser = database::users::get_user_with_profile(&state.db, &userid) 214 | .await 215 | .map_err(FrontendError::InternalError)?; 216 | 217 | database::users::enable_user(&state.db, &userid, allow.value) 218 | .await 219 | .map_err(FrontendError::InternalError)?; 220 | 221 | let template = if allow.value { 222 | "components/user-buttons-enabled.jinja2" 223 | } else { 224 | "components/user-buttons-disabled.jinja2" 225 | }; 226 | 227 | let output = state.templates.render_template( 228 | template, 229 | context! { 230 | is_admin => otheruser.is_admin, 231 | userid => userid, 232 | }, 233 | )?; 234 | 235 | Ok(Html(output).into_response()) 236 | } 237 | 238 | #[derive(serde::Deserialize)] 239 | struct Pagination { 240 | page: i32, 241 | } 242 | 243 | #[debug_handler] 244 | async fn handle_pagination( 245 | jar: CookieJar, 246 | Path(roomid): Path, 247 | pagination: Query, 248 | State(state): State>, 249 | ) -> Result { 250 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 251 | return Err(FrontendError::Unauthorized); 252 | }; 253 | 254 | let room = database::rooms::get_room(&state.db, &roomid, &user.id) 255 | .await 256 | .map_err(|e| FrontendError::NotFound(e.to_string()))?; 257 | 258 | let page = pagination.page; 259 | 260 | let messages = state 261 | .room_manager 262 | .get_room_messages(&roomid, page, &user.id) 263 | .await 264 | .map_err(FrontendError::InternalError)?; 265 | 266 | let next_page = if messages.len() < database::messages::MAX_FETCH as usize { 267 | 0 268 | } else { 269 | page + 1 270 | }; 271 | 272 | let output = state.templates.render_template( 273 | "components/message-list.jinja2", 274 | context! { 275 | roomid => roomid, currentRoom => room, messages => messages, page => next_page 276 | }, 277 | )?; 278 | 279 | Ok(Html(output).into_response()) 280 | } 281 | 282 | #[debug_handler] 283 | async fn handle_join_room( 284 | jar: CookieJar, 285 | Path(roomid): Path, 286 | State(state): State>, 287 | ) -> Result { 288 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 289 | return Err(FrontendError::Unauthorized); 290 | }; 291 | 292 | tracing::info!("connected to {} as {}", roomid, user.username); 293 | 294 | let rcv = state 295 | .room_manager 296 | .join_room(roomid, &user.id) 297 | .await 298 | .map_err(FrontendError::InternalError)?; 299 | 300 | let ss = BroadcastStream::new(rcv) 301 | .filter_map(|c| c.ok()) 302 | .map(move |c| { 303 | let rendered = state 304 | .templates 305 | .render_template("components/message.jinja2", context! { message => c }) 306 | .unwrap(); 307 | Event::default().event("IncomingMessage").data(rendered) 308 | }) 309 | .map(Ok::); 310 | 311 | Ok(Sse::new(ss) 312 | .keep_alive(KeepAlive::default()) 313 | .into_response()) 314 | } 315 | 316 | #[derive(serde::Deserialize, Default)] 317 | pub struct MessageForm { 318 | pub msg: String, 319 | pub uploads: Option>, 320 | } 321 | 322 | #[debug_handler] 323 | async fn handle_send_message( 324 | jar: CookieJar, 325 | Path(roomid): Path, 326 | State(state): State>, 327 | axum_extra::extract::Form(form): axum_extra::extract::Form, 328 | ) -> Result { 329 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 330 | return Err(FrontendError::Unauthorized); 331 | }; 332 | 333 | tracing::info!("uploads: {:?}", form.uploads); 334 | 335 | state 336 | .room_manager 337 | .send_message( 338 | &roomid, 339 | &user.id, 340 | &user.username, 341 | user.image, 342 | &form.msg, 343 | form.uploads.unwrap_or_default(), 344 | ) 345 | .await 346 | .map_err(FrontendError::InternalError)?; 347 | 348 | // Ok(user_id) 349 | Ok("".into_response()) 350 | } 351 | 352 | #[debug_handler] 353 | async fn handle_add_user_to_room( 354 | jar: CookieJar, 355 | Path((roomid, userid)): Path<(String, String)>, 356 | State(state): State>, 357 | ) -> Result { 358 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 359 | return Err(FrontendError::Unauthorized); 360 | }; 361 | 362 | if !user.is_admin && !database::rooms::is_member_of_room(&state.db, &roomid, &user.id).await { 363 | return Err(FrontendError::NoPermission); 364 | } 365 | 366 | database::rooms::add_user_to_room(&state.db, &roomid, &userid) 367 | .await 368 | .map_err(FrontendError::InternalError)?; 369 | 370 | let room_users = database::rooms::get_room_users(&state.db, &roomid) 371 | .await 372 | .map_err(FrontendError::InternalError)?; 373 | 374 | let output = state.templates.render_template( 375 | "components/room-user.jinja2", 376 | context! { roomUsers => room_users, roomid => roomid }, 377 | )?; 378 | 379 | Ok(Html(output)) 380 | } 381 | 382 | #[debug_handler] 383 | async fn handle_remove_user_from_room( 384 | jar: CookieJar, 385 | Path((roomid, userid)): Path<(String, String)>, 386 | State(state): State>, 387 | ) -> Result { 388 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 389 | return Err(FrontendError::Unauthorized); 390 | }; 391 | 392 | if !user.is_admin && !database::rooms::is_member_of_room(&state.db, &roomid, &user.id).await { 393 | return Err(FrontendError::NoPermission); 394 | } 395 | 396 | database::rooms::remove_user_from_room(&state.db, &roomid, &userid) 397 | .await 398 | .map_err(FrontendError::InternalError)?; 399 | 400 | let room_users = database::rooms::get_room_users(&state.db, &roomid) 401 | .await 402 | .map_err(FrontendError::InternalError)?; 403 | 404 | let output = state.templates.render_template( 405 | "components/room-user.jinja2", 406 | context! { roomUsers => room_users, roomid => roomid }, 407 | )?; 408 | 409 | Ok(Html(output)) 410 | } 411 | 412 | #[debug_handler] 413 | async fn handle_login( 414 | mut jar: CookieJar, 415 | State(state): State>, 416 | Form(form): Form, 417 | ) -> Result { 418 | let user_id = database::users::verify_userpassword(&state.db, &form.email, &form.password) 419 | .await 420 | .map_err(|e| match e { 421 | database::users::DBUserErrors::UserNotEnabled => FrontendError::UserNotEnabled, 422 | database::users::DBUserErrors::InternalError(e) => FrontendError::InternalError(e), 423 | _ => FrontendError::InvalidCredentials, 424 | })?; 425 | let user_token = 426 | users::generate_token(&state.secret, &user_id).map_err(FrontendError::InternalError)?; 427 | 428 | jar = set_token(jar, user_token); 429 | 430 | // Ok(user_id) 431 | Ok((HxRedirect("/".parse().unwrap()), jar, "").into_response()) 432 | } 433 | 434 | #[derive(serde::Deserialize)] 435 | struct PasswordUpdate { 436 | pub current: String, 437 | pub update: String, 438 | } 439 | 440 | #[debug_handler] 441 | async fn handle_update_user_password( 442 | jar: CookieJar, 443 | State(state): State>, 444 | Form(update): Form, 445 | ) -> Result { 446 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 447 | return Err(FrontendError::Unauthorized); 448 | }; 449 | 450 | database::users::update_user_password(&state.db, &user.email, &update.current, &update.update) 451 | .await 452 | .map_err(FrontendError::InternalError)?; 453 | 454 | Ok("") 455 | } 456 | 457 | #[derive(serde::Deserialize)] 458 | struct ProfileUpdate { 459 | pub name: String, 460 | pub bio: String, 461 | } 462 | 463 | #[debug_handler] 464 | async fn handle_update_user_profile( 465 | jar: CookieJar, 466 | State(state): State>, 467 | Form(update): Form, 468 | ) -> Result { 469 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 470 | return Err(FrontendError::Unauthorized); 471 | }; 472 | 473 | let user = database::users::update_user_profile(&state.db, &user.id, &update.name, &update.bio) 474 | .await 475 | .map_err(FrontendError::InternalError)?; 476 | 477 | let output = state.templates.render_template( 478 | "components/user-profile-edit.jinja2", 479 | context! { username => user.username, email => user.email, bio => user.bio }, 480 | )?; 481 | 482 | Ok(Html(output)) 483 | } 484 | 485 | #[debug_handler] 486 | async fn handle_delete_user_image( 487 | jar: CookieJar, 488 | State(state): State>, 489 | ) -> Result { 490 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 491 | return Err(FrontendError::Unauthorized); 492 | }; 493 | 494 | database::users::unset_user_image(&state.db, &user.id) 495 | .await 496 | .map_err(FrontendError::InternalError)?; 497 | 498 | let output = state.templates.render_template( 499 | "components/user-profile-image-edit.jinja2", 500 | context! { image => None::, username => user.username, oob => true }, 501 | )?; 502 | 503 | Ok(Html(output)) 504 | } 505 | 506 | #[debug_handler] 507 | async fn handle_upload_to_room( 508 | jar: CookieJar, 509 | Path(roomid): Path, 510 | State(state): State>, 511 | mut multipart: Multipart, 512 | ) -> Result { 513 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 514 | return Err(FrontendError::Unauthorized); 515 | }; 516 | 517 | let Ok(Some(field)) = multipart.next_field().await else { 518 | return Err(FrontendError::InvalidForm( 519 | "missing field name: {:?}".into(), 520 | )); 521 | }; 522 | 523 | let name = field 524 | .name() 525 | .ok_or_else(|| FrontendError::InvalidForm(format!("missing field name: {:?}", field)))?; 526 | 527 | if name != "file" { 528 | return Err(FrontendError::InvalidForm(format!( 529 | "missing field name: {:?}", 530 | field 531 | ))); 532 | } 533 | 534 | let file_name = field 535 | .file_name() 536 | .ok_or_else(|| { 537 | FrontendError::InvalidForm(format!("missing file name for file: {:?}", field)) 538 | })? 539 | .to_string(); 540 | 541 | let body_with_err = field.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)); 542 | 543 | let (file_url, file_type) = uploads::upload_file( 544 | body_with_err, 545 | &state.uploads_path, 546 | None, // unique id per file upload 547 | &file_name, 548 | ) 549 | .await 550 | .map_err(FrontendError::InternalError)?; 551 | 552 | let (upload_id, trx) = database::uploads::add_upload_and_continue( 553 | &state.db, 554 | &user.id, 555 | Some(roomid), 556 | Some(file_name.clone()), 557 | Some(file_url.clone()), 558 | ) 559 | .await 560 | .map_err(FrontendError::InternalError)?; 561 | 562 | trx.commit() 563 | .await 564 | .map_err(|e| FrontendError::InternalError(e.into()))?; 565 | 566 | println!("file_type: {}", file_type); 567 | 568 | let output = state.templates.render_template( 569 | "components/uploaded-file.jinja2", 570 | context! { path => file_url, file_type => file_type, upload_id => upload_id }, 571 | )?; 572 | 573 | Ok(Html(output)) 574 | } 575 | 576 | #[debug_handler] 577 | async fn handle_update_user_image( 578 | jar: CookieJar, 579 | State(state): State>, 580 | mut multipart: Multipart, 581 | ) -> Result { 582 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 583 | return Err(FrontendError::Unauthorized); 584 | }; 585 | 586 | let Ok(Some(field)) = multipart.next_field().await else { 587 | return Err(FrontendError::InvalidForm( 588 | "missing field name: {:?}".into(), 589 | )); 590 | }; 591 | 592 | let name = field 593 | .name() 594 | .ok_or_else(|| FrontendError::InvalidForm(format!("missing field name: {:?}", field)))?; 595 | 596 | if name != "image" { 597 | return Err(FrontendError::InvalidForm(format!( 598 | "missing field name: {:?}", 599 | field 600 | ))); 601 | } 602 | 603 | let file_name = field 604 | .file_name() 605 | .ok_or_else(|| { 606 | FrontendError::InvalidForm(format!("missing file name for file: {:?}", field)) 607 | })? 608 | .to_string(); 609 | 610 | let body_with_err = field.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)); 611 | 612 | let (file_url, _) = uploads::upload_file( 613 | body_with_err, 614 | &state.uploads_path, 615 | Some(user.id.clone()), 616 | &file_name, 617 | ) 618 | .await 619 | .map_err(FrontendError::InternalError)?; 620 | 621 | let (_, mut trx) = database::uploads::add_upload_and_continue( 622 | &state.db, 623 | &user.id, 624 | None, 625 | Some(file_name.clone()), 626 | Some(file_url.clone()), 627 | ) 628 | .await 629 | .map_err(FrontendError::InternalError)?; 630 | 631 | database::users::set_user_image(&mut trx, &user.id, Some(file_url.as_ref())) 632 | .await 633 | .map_err(FrontendError::InternalError)?; 634 | 635 | let output = state.templates.render_template( 636 | "components/user-profile-image-edit.jinja2", 637 | context! { image => file_url, username => user.username, oob => true }, 638 | )?; 639 | 640 | trx.commit() 641 | .await 642 | .map_err(|e| FrontendError::InternalError(e.into()))?; 643 | 644 | Ok(Html(output)) 645 | } 646 | 647 | #[derive(Default, Debug)] 648 | struct RegistrationForm { 649 | pub name: String, 650 | pub email: String, 651 | pub password: String, 652 | pub filename: String, 653 | pub image: Option, 654 | } 655 | 656 | #[debug_handler] 657 | async fn handle_registration( 658 | mut jar: CookieJar, 659 | State(state): State>, 660 | mut multipart: Multipart, 661 | ) -> Result { 662 | let user_id = xid::new().to_string(); 663 | 664 | let mut form = RegistrationForm::default(); 665 | 666 | while let Ok(Some(field)) = multipart.next_field().await { 667 | let name = field.name().ok_or_else(|| { 668 | FrontendError::InvalidForm(format!("missing field name: {:?}", field)) 669 | })?; 670 | 671 | match name { 672 | "image" => { 673 | let file_name = field 674 | .file_name() 675 | .ok_or_else(|| { 676 | FrontendError::InvalidForm(format!( 677 | "missing file name for file: {:?}", 678 | field 679 | )) 680 | })? 681 | .to_string(); 682 | 683 | let body_with_err = 684 | field.map_err(|err| std::io::Error::new(std::io::ErrorKind::Other, err)); 685 | 686 | let (path, _) = uploads::upload_file( 687 | body_with_err, 688 | &state.uploads_path, 689 | Some(user_id.clone()), 690 | &file_name, 691 | ) 692 | .await 693 | .map_err(FrontendError::InternalError)?; 694 | 695 | form.image = Some(path); 696 | form.filename = file_name; 697 | } 698 | "name" => { 699 | form.name = field.text().await.map_err(|e| { 700 | FrontendError::InvalidForm(format!("failed to read name: {}", e)) 701 | })?; 702 | } 703 | "email" => { 704 | form.email = field.text().await.map_err(|e| { 705 | FrontendError::InvalidForm(format!("failed to read email: {}", e)) 706 | })?; 707 | } 708 | "password" => { 709 | form.password = field.text().await.map_err(|e| { 710 | FrontendError::InvalidForm(format!("failed to read email: {}", e)) 711 | })?; 712 | } 713 | _ => {} 714 | } 715 | } 716 | 717 | if form.name.is_empty() { 718 | return Err(FrontendError::InvalidForm("missing name field".to_string())); 719 | } 720 | 721 | if form.email.is_empty() { 722 | return Err(FrontendError::InvalidForm( 723 | "missing email field".to_string(), 724 | )); 725 | } 726 | 727 | if form.password.is_empty() { 728 | return Err(FrontendError::InvalidForm( 729 | "missing password field".to_string(), 730 | )); 731 | } 732 | 733 | let (_, trx) = database::uploads::add_upload_and_continue( 734 | &state.db, 735 | &user_id, 736 | None, 737 | Some(form.filename.clone()), 738 | form.image.clone(), 739 | ) 740 | .await 741 | .map_err(FrontendError::InternalError)?; 742 | 743 | let has_admin = state.has_admin.load(Ordering::Relaxed); 744 | 745 | let user = database::users::User { 746 | email: form.email, 747 | password: form.password, 748 | is_admin: !has_admin, 749 | is_enabled: !has_admin, 750 | ..Default::default() 751 | }; 752 | 753 | let profile = database::users::UserProfile { 754 | username: form.name, 755 | image: form.image, 756 | ..Default::default() 757 | }; 758 | 759 | let (user_id, trx) = database::users::create_user(&user_id, trx, user, profile) 760 | .await 761 | .map_err(FrontendError::InternalError)?; 762 | 763 | let user_token = 764 | users::generate_token(&user_id, &state.secret).map_err(FrontendError::InternalError)?; 765 | 766 | jar = set_token(jar, user_token); 767 | 768 | if !has_admin { 769 | let mut register_id = state.register_id.write(); 770 | *register_id = get_random_alphanumeric(); // just created a new admin, set registration_id 771 | state.has_admin.store(true, Ordering::Relaxed); 772 | } 773 | 774 | trx.commit() 775 | .await 776 | .map_err(|e| FrontendError::InternalError(e.into()))?; 777 | 778 | // Ok(user_id) 779 | Ok((HxRedirect("/".parse().unwrap()), jar, "").into_response()) 780 | } 781 | 782 | pub fn get_random_alphanumeric() -> String { 783 | thread_rng() 784 | .sample_iter(&Alphanumeric) 785 | .take(10) 786 | .map(char::from) 787 | .collect() 788 | } 789 | 790 | #[derive(serde::Deserialize, Default, Debug)] 791 | pub struct SearchUser { 792 | name: String, 793 | local: bool, 794 | roomid: Option, 795 | } 796 | 797 | #[debug_handler] 798 | async fn handle_search_users( 799 | jar: CookieJar, 800 | State(state): State>, 801 | search: Query, 802 | ) -> Result { 803 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 804 | return Err(FrontendError::Unauthorized); 805 | }; 806 | 807 | let users = database::users::search_users(&state.db, &search.name, &user.id) 808 | .await 809 | .map_err(FrontendError::InternalError)?; 810 | 811 | println!("searched :{:?}", search); 812 | 813 | let output = state.templates.render_template( 814 | "components/user-search-results.jinja2", 815 | context! { results => users, local => search.local, roomid => search.roomid }, 816 | )?; 817 | 818 | Ok(Html(output)) 819 | } 820 | 821 | #[derive(serde::Deserialize)] 822 | pub struct NewRoom { 823 | name: String, 824 | description: String, 825 | is_private: Option, 826 | } 827 | 828 | #[debug_handler] 829 | async fn handle_create_room( 830 | jar: CookieJar, 831 | State(state): State>, 832 | Form(form): Form, 833 | ) -> Result { 834 | let Some(user) = extract_user(jar, &state.db, &state.secret).await else { 835 | return Err(FrontendError::Unauthorized); 836 | }; 837 | 838 | let room_id = form.name.to_case(Case::Kebab); 839 | 840 | let room_id = database::rooms::create_room( 841 | &state.db, 842 | &room_id, 843 | &form.name, 844 | &form.description, 845 | form.is_private.unwrap_or_default(), 846 | false, 847 | &[user.id], 848 | ) 849 | .await 850 | .map_err(FrontendError::InternalError)?; 851 | 852 | // Ok(user_id) 853 | Ok(( 854 | HxRedirect(format!("/chatroom/{}", room_id).parse().unwrap()), 855 | "", 856 | ) 857 | .into_response()) 858 | } 859 | 860 | fn set_token(jar: CookieJar, user_token: String) -> CookieJar { 861 | let mut cookie = Cookie::new("token", user_token); 862 | cookie.set_same_site(SameSite::Strict); 863 | cookie.set_path("/"); 864 | cookie.set_expires(OffsetDateTime::now_utc() + Duration::weeks(52)); 865 | jar.add(cookie) 866 | } 867 | 868 | pub(crate) fn validate_token(jar: CookieJar, secret: &str) -> Option { 869 | let token = jar.get("token")?.value(); 870 | 871 | let user_id = users::extract_sub(secret, token).ok()??; 872 | 873 | Some(user_id) 874 | } 875 | 876 | pub(crate) async fn extract_user( 877 | jar: CookieJar, 878 | db: &Database, 879 | secret: &str, 880 | ) -> Option { 881 | let user_id = validate_token(jar, secret)?; 882 | 883 | let user = database::users::get_user_with_profile(db, &user_id) 884 | .await 885 | .ok()?; 886 | 887 | Some(user) 888 | } 889 | --------------------------------------------------------------------------------