Compare commits

...

16 Commits

Author SHA1 Message Date
0ea65a665d Channel listing without db 2025-04-24 20:45:56 -07:00
836beda422 Quick helper module 2025-04-24 20:45:34 -07:00
99a3b57cf6 Safely generating passwords for admin users 2025-03-19 21:26:14 -07:00
6cf8a02100 Typescript here we come IG 2025-03-11 20:35:06 -07:00
44530ff327 Scrapping the APi because rocket is not mature enough for this project 2025-03-11 20:24:54 -07:00
1c9d0a6207 Salting and hashing admin creds upon creation 2025-03-11 16:51:06 -07:00
503ba812f2 Adding framework for salt & hashing passwords before creating admin users, still requires integration at this stage 2025-03-10 23:20:56 -07:00
a7bae463a2 Setting postgres.username with rando value on tail end 2025-03-10 21:51:03 -07:00
219ec4df9a Admin-cli now creates minimum tables for users 2025-03-10 21:44:21 -07:00
d83bd0a7bf Fixing horrid typo ;-; 2025-03-10 20:48:34 -07:00
8f67333050 Adding dev script for easier testing workflow 2025-03-09 19:34:51 -07:00
b4aa323577 Cleaning up the admin credentials creation a ton and creating the default bubble
admin user in the users table.
Still need UUIDv7 in as primary keys but we're getting there slowly
2025-01-07 22:54:50 -08:00
a679f49b18 Basic cli parse of postgres params 2024-12-15 17:02:30 -08:00
d55714a6f0 Addign clap to api deps 2024-12-15 17:02:05 -08:00
57c2241e12 Simple name for postgres container 2024-11-24 16:01:42 -08:00
df978d7250 Working db setup with admin-cli 2024-11-24 15:38:25 -08:00
21 changed files with 4217 additions and 2873 deletions

2
.gitignore vendored
View File

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

1355
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -1,7 +1,16 @@
use std::env;
use std::fs;
use std::io::Read;
use argon2::password_hash::Salt;
use clap::Parser;
use postgres::{Client, NoTls};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use base64::engine::general_purpose::STANDARD_NO_PAD;
use serde::Serialize;
use argon2::{Argon2, PasswordHasher};
const PASSWORD_LENGTH: usize = 64;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
@@ -9,31 +18,108 @@ struct Args {
/// Setup everything from scratch
#[arg(short, long)]
setup: bool,
/// Setup just the Database
/// Specify the directory where all the sql files are located
#[arg(short, long)]
db: bool
psql_dir: Option<String>
}
fn execute_sql(connection_string: &str, filename: &'static str) -> Result<(), postgres::Error> {
let content = fs::read_to_string(filename)
.expect(&format!("Failed to load file: {}", filename));
let mut client = Client::connect(connection_string, NoTls)?;
client.batch_execute(&content)
#[derive(Serialize)]
struct Admin {
username: String,
password: String,
}
fn setup_database() -> Result<(), postgres::Error> {
#[derive(Serialize)]
struct Config {
postgres: Admin,
bubble: Admin
}
fn random_string(size: usize) -> String {
// 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 f = std::fs::File::open("/dev/urandom").unwrap();
f.read_exact(&mut buffer).unwrap();
URL_SAFE_NO_PAD.encode(buffer)
}
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
const KEY: &'static str = "DB_CONNECTION_STRING" ;
let connection_string = env::var(KEY).expect(
&format!("The env var {} is not set!", KEY)
);
execute_sql(&connection_string, "../db/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");
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)?;
// Preliminary bs
client.execute("DROP DATABASE IF EXISTS bubble;", &[])?;
client.execute("CREATE DATABASE bubble;", &[])?;
client.execute("DROP USER IF EXISTS bubble_admin;", &[])?;
let stmt = format!("CREATE ROLE bubble_admin WITH ENCRYPTED PASSWORD '{}'", postgres_admin.password);
client.execute(&stmt , &[])?;
// Ensure the admin has ownership of the db we created
client.execute("ALTER DATABASE bubble OWNER TO bubble_admin", &[])?;
// Service table creation
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 {
postgres: postgres_admin,
bubble: bubble_admin
})
}
fn main() {
let args = Args::parse();
if args.db { let _ = setup_database(); }
println!("Setup is {}", args.setup);
println!("Setup is {}", args.db);
if args.setup {
match full_setup(args) {
Ok(config) => println!("{}", serde_json::to_string(&config).unwrap()),
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 (
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)
);
@@ -10,3 +16,10 @@ CREATE TABLE IF NOT EXISTS invites (
expires INTEGER,
PRIMARY KEY (id)
);
CREATE TABLE IF NOT EXISTS channels (
id BIGINT,
name varchar(256),
description varchar(256),
PRIMARY KEY (id)
);

View File

@@ -1,8 +1,9 @@
-- Create the user that we'll use for service data
DO $SERVICE_USER_CREATION$
-- TODO: IDK wtf is wrong with this statement but im forgoing making this
-- work for the time being
$$
BEGIN
CREATE ROLE bubble_admin WITH PASSWORD $1;
EXCEPTION WHEN duplicate_object THEN RAISE NOTICE '%, skipping', SQLERRM USING ERRCODE = SQLSTATE;
IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'bubble_admin') THEN
CREATE USER 'bubble_admin' WITH PASSWORD $1;
END IF
END
$SERVICE_USER_CREATION$;
$$

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
)

11
docker-compose.yaml Normal file
View File

@@ -0,0 +1,11 @@
services:
postgres:
image: postgres
restart: always
container_name: bubble-postgres
environment:
POSTGRES_USER: "${POSTGRES_USER}"
POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
ports:
- '5432:5432'