Compare commits

...

14 Commits

19 changed files with 4161 additions and 2865 deletions

2
.gitignore vendored
View File

@@ -1,2 +1,4 @@
/target /target
*.swp *.swp
api/dist/
api/node_modules/

1344
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,5 @@
resolver = "2" resolver = "2"
members = [ members = [
"admin-cli", "admin-cli"
"api"
] ]

View File

@@ -7,5 +7,7 @@ edition = "2021"
clap = { version = "4.5.20", features = ["derive"] } clap = { version = "4.5.20", features = ["derive"] }
postgres = "0.19.9" postgres = "0.19.9"
base64 = "0.22.1" base64 = "0.22.1"
serde = "1.0.215" serde = { version = "1.0.215", features = ["derive"] }
serde_json = "1.0.133" serde_json = "1.0.133"
argon2 = "0.5.3"
rand_core = { version = "0.9.3", features = [ "os_rng" ] }

View File

@@ -1,10 +1,16 @@
use std::env; use std::env;
use std::fs; use std::fs;
use std::io::Read; use std::io::Read;
use argon2::password_hash::Salt;
use clap::Parser; use clap::Parser;
use postgres::{Client, NoTls}; use postgres::{Client, NoTls};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use base64::engine::general_purpose::STANDARD_NO_PAD;
use serde::Serialize; use serde::Serialize;
use argon2::{Argon2, PasswordHasher};
const PASSWORD_LENGTH: usize = 64;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
#[command(version, about, long_about = None)] #[command(version, about, long_about = None)]
@@ -12,48 +18,98 @@ struct Args {
/// Setup everything from scratch /// Setup everything from scratch
#[arg(short, long)] #[arg(short, long)]
setup: bool, setup: bool,
/// Specify the directory where all the sql files are located
#[arg(short, long)]
psql_dir: Option<String>
}
#[derive(Serialize)]
struct Admin {
username: String,
password: String,
} }
#[derive(Serialize)] #[derive(Serialize)]
struct Config { struct Config {
postgres_user: String, postgres: Admin,
postgres_pass: String, bubble: Admin
} }
fn random_string(size: usize) -> String { fn random_string(size: usize) -> String {
// Next we'll generate a bunch of random numbers // Generates a URL safe string of random text of a given size
// Internally uses /dev/urandom to generate that string
let mut buffer = vec![0; size]; let mut buffer = vec![0; size];
let mut f = std::fs::File::open("/dev/urandom").unwrap(); let mut f = std::fs::File::open("/dev/urandom").unwrap();
f.read_exact(&mut buffer).unwrap(); f.read_exact(&mut buffer).unwrap();
URL_SAFE_NO_PAD.encode(buffer) URL_SAFE_NO_PAD.encode(buffer)
} }
fn full_setup() -> Result<Config, postgres::Error> { fn random_b64_std(size: usize) -> String {
let mut buffer = vec![0; size];
let mut f = std::fs::File::open("/dev/urandom").unwrap();
f.read_exact(&mut buffer).unwrap();
STANDARD_NO_PAD.encode(buffer)
}
fn salt_and_hash(password: &str) -> String {
// Generates a salted and hashed variation of the given password
let salt_str = random_b64_std(8);
let salt = Salt::from_b64(&salt_str).unwrap();
let a2 = Argon2::default();
let hash = a2.hash_password(password.as_bytes(), salt).unwrap();
hash.to_string()
}
fn admin(username: &str, password_size: usize) -> Admin {
Admin {
username: username.to_string(),
password: random_string(password_size)
}
}
fn full_setup(args: Args) -> Result<Config, postgres::Error> {
// Check to make sure we have the DB url set to connect // Check to make sure we have the DB url set to connect
const KEY: &'static str = "DB_CONNECTION_STRING" ; const KEY: &'static str = "DB_CONNECTION_STRING" ;
let connection_string = env::var(KEY).expect( let connection_string = env::var(KEY).expect(
&format!("The env var {} is not set!", KEY) &format!("The env var {} is not set!", KEY)
); );
let setup_tables_script = fs::read_to_string("db/setup-tables.sql") let db_folder = match args.psql_dir {
Some(val) => format!("{}/setup-tables.sql", val),
None => "db/setup-tables.sql".to_string()
};
let setup_tables_script = fs::read_to_string(db_folder)
.expect("Failed to load file: db/setup-tables.sql"); .expect("Failed to load file: db/setup-tables.sql");
let bubble_admin_password = random_string(32); let postgres_admin = admin(&format!("bubble_admin-{}", random_string(8)), PASSWORD_LENGTH);
let bubble_admin = admin(
&format!("admin-{}", random_string(8)),
PASSWORD_LENGTH
);
let mut client = Client::connect(&connection_string, NoTls)?; let mut client = Client::connect(&connection_string, NoTls)?;
// Preliminary bs // Preliminary bs
client.execute("DROP DATABASE IF EXISTS bubble;", &[])?; client.execute("DROP DATABASE IF EXISTS bubble;", &[])?;
client.execute("CREATE DATABASE bubble;", &[])?; client.execute("CREATE DATABASE bubble;", &[])?;
client.execute("DROP USER IF EXISTS bubble_admin;", &[])?; client.execute("DROP USER IF EXISTS bubble_admin;", &[])?;
client.execute( let stmt = format!("CREATE ROLE bubble_admin WITH ENCRYPTED PASSWORD '{}'", postgres_admin.password);
&format!("CREATE USER bubble_admin WITH ENCRYPTED PASSWORD '{}';", bubble_admin_password), client.execute(&stmt , &[])?;
&[]
)?;
// Ensure the admin has ownership of the db we created // Ensure the admin has ownership of the db we created
client.execute("ALTER DATABASE bubble OWNER TO bubble_admin", &[])?; client.execute("ALTER DATABASE bubble OWNER TO bubble_admin", &[])?;
// Service table creation // Service table creation
client.batch_execute(&setup_tables_script)?; client.batch_execute(&setup_tables_script)?;
// Populate the user table with the first user ( owner )
let salted = salt_and_hash(&bubble_admin.password);
client.execute(
"INSERT INTO users (id, username, password) VALUES (gen_random_uuid(), $1, $2)",
&[&bubble_admin.username, &salted]
)?;
Ok(Config { Ok(Config {
postgres_user: "bubble_admin".into(), postgres: postgres_admin,
postgres_pass: bubble_admin_password bubble: bubble_admin
}) })
} }
@@ -61,7 +117,7 @@ fn main() {
let args = Args::parse(); let args = Args::parse();
if args.setup { if args.setup {
match full_setup() { match full_setup(args) {
Ok(config) => println!("{}", serde_json::to_string(&config).unwrap()), Ok(config) => println!("{}", serde_json::to_string(&config).unwrap()),
Err(e) => eprintln!("{:#?}", e) Err(e) => eprintln!("{:#?}", e)
} }

1602
api/Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
[package]
name = "api"
version = "0.1.0"
edition = "2021"
[dependencies]
tokio = { version = "1", features = [ "full" ] }
serde = "1.0.213"
rocket = { version = "0.5.1", features = [ "json" ] }

3729
api/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

30
api/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"dependencies": {
"@types/express": "^5.0.0",
"@types/node": "^22.13.10",
"express": "^4.21.2",
"node": "^23.9.0",
"npm": "^11.2.0",
"pg-promise": "^11.13.0",
"ts-node": "^10.9.2",
"tslib": "^2.8.1"
},
"name": "api",
"version": "1.0.0",
"description": "HTTP API component for bubble",
"main": "dist/main.js",
"devDependencies": {
"typescript": "^5.8.2"
},
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "tsc",
"debug": "node dist/main.js"
},
"repository": {
"type": "git",
"url": "ssh://git@git.shockrah.xyz:2222/shockrah/bubble.git"
},
"author": "shockrah",
"license": "MIT"
}

View File

@@ -1,41 +0,0 @@
use serde::Serialize;
use rocket::serde::json::Json;
#[derive(Serialize)]
enum Type {
Voice,
Text,
Video
}
#[derive(Serialize)]
struct Channel {
id: i64,
name: String,
desc: Option<String>,
r#type: Type
}
#[get("/list")]
pub async fn list() -> Json<Vec<Channel>> {
// TODO
Json(vec![])
}
#[post("/create")]
pub async fn create() -> Json<Option<Channel>> {
// TODO
Json(None)
}
#[delete("/delete")]
pub async fn delete() -> Json<Option<i64>> {
// TODO
Json(None)
}
#[post("/update")]
pub async fn update() -> Json<Option<Channel>> {
// TODO
Json(None)
}

30
api/src/channels.ts Normal file
View File

@@ -0,0 +1,30 @@
import { Request, Response } from 'express'
import {values_present} from './common'
export enum Type {
Text = 'text',
Voice = 'voice',
}
function valid_channel(ch: string) : boolean {
return ch === Type.Text || ch === Type.Voice
}
export async function list(req: Request, res: Response, db) : Promise<void> {
const channels = await db.one('SELECT * from channels')
res.send({path: req.path, data: channels.value})
}
export async function create(req: Request, res: Response) : Promise<void> {
const { name, description, type } = req.query
if (values_present([name, description, type])|| !valid_channel(type.toString()) ) {
res.status(400).send({ error: 'Invalid query string params' })
return
}
res.send({
id: 123,
name: name,
description: description,
type: type
})
}

6
api/src/common.ts Normal file
View File

@@ -0,0 +1,6 @@
export function values_present(values: any[]) : boolean {
/**
* Function determines if there are any undefined or null values present
*/
return values.filter((val) => val === undefined || val === null).length > 0
}

View File

@@ -1,14 +0,0 @@
use rocket::serde::json::Json;
use serde::Serialize;
#[derive(Serialize)]
pub struct User {
id: i64,
name: String,
}
#[get("/<id>")]
pub async fn invite_user(id: i64) -> Json<Option<User>> {
// First we check to see if the id is present in the invites table
Json(None)
}

5
api/src/invites.ts Normal file
View File

@@ -0,0 +1,5 @@
import { Request, Response } from 'express'
export async function code(req: Request, res: Response) : Promise<void> {
res.send({path: req.path})
}

View File

@@ -1,17 +0,0 @@
#[macro_use] extern crate rocket;
mod invites;
mod channels;
#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
let _ = rocket::build()
.mount("/channels", routes![
channels::list,
channels::create,
channels::delete
])
.mount("/invite", routes![invites::invite_user])
.ignite().await?
.launch().await?;
Ok(())
}

22
api/src/main.ts Normal file
View File

@@ -0,0 +1,22 @@
import process from 'process'
import express from 'express'
import * as channels from './channels'
import * as invites from './invites'
const pgPromise = require('pg-promise')()
const DB_URL = process.env['DB_CONNECTION_STRING']
const PORT = process.env['PORT'] || 8000
console.log('db-url', DB_URL)
console.log('port', PORT)
const app = express()
const db = pgPromise(DB_URL)
app.get('/invite/<code>', async (req, res) => await invites.code(req, res))
app.get('/channels/list', async (req, res) => await channels.list(req, res, db))
app.post('/channels/create', async (req, res) => await channels.create(req, res))
app.listen(PORT)

20
api/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "es5",
"module": "commonjs",
"rootDir": "./src/",
"outDir": "./dist/",
"importHelpers": true,
"strictFunctionTypes": true,
"noImplicitThis": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
}
}

View File

@@ -1,6 +1,12 @@
CREATE TABLE IF NOT EXISTS users ( CREATE TABLE IF NOT EXISTS users (
id INTEGER, /* */
name VARCHAR(256), id UUID,
/* Acts as a kind of nick name per instance as it assumes no uniqueness */
username VARCHAR(256),
/* Salt to be generated everytime password is (re)created */
salt VARCHAR(256),
/* Basic hashed password */
password VARCHAR(256),
PRIMARY KEY (id) PRIMARY KEY (id)
); );
@@ -10,3 +16,10 @@ CREATE TABLE IF NOT EXISTS invites (
expires INTEGER, expires INTEGER,
PRIMARY KEY (id) PRIMARY KEY (id)
); );
CREATE TABLE IF NOT EXISTS channels (
id BIGINT,
name varchar(256),
description varchar(256),
PRIMARY KEY (id)
);

49
dev.py Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/python3
from subprocess import run
from argparse import ArgumentParser
import os
if __name__ == '__main__':
parser = ArgumentParser()
parser.add_argument(
'-i',
'--init-db',
help='Run the admin cli to setup the db backend',
action='store_true'
)
parser.add_argument(
'-d',
'--db-url',
help='Sets the database URL to use for connecting to postgres',
default='postgres://psql:example@localhost:5432'
)
parser.add_argument(
'-c',
'--check-container',
help='Execs into the given container with bash for debugging'
)
parser.add_argument(
'-s',
'--server',
help='Run a debug server (assumes db is ready)',
action='store_true'
)
args = parser.parse_args()
os.environ['DB_CONNECTION_STRING'] = args.db_url
if args.init_db:
run(
'cargo run --bin admin-cli -- --setup',
env=os.environ,
shell=True
)
if args.check_container:
run(f'docker exec -it {args.check_container} bash'.split())
if args.server:
run('npm run build', env=os.environ, cwd='api', shell=True)
run(
f'npm run debug',
env=os.environ,
cwd='api',
shell=True
)