diff --git a/app/build b/app/build new file mode 100644 index 0000000..5d3788b --- /dev/null +++ b/app/build @@ -0,0 +1,6 @@ +#!/bin/bash + +cd src +v install fleximus.argon2 +v install thomaspeissl.dotenv +v -prod -cflags "-static" -os linux -o ../../build/app/cdn . diff --git a/app/src/crypto/crypto.v b/app/src/crypto/crypto.v new file mode 100644 index 0000000..40e45ba --- /dev/null +++ b/app/src/crypto/crypto.v @@ -0,0 +1,16 @@ +module crypto + +import rand +import fleximus.argon2 + +pub struct Crypto {} + +pub fn Crypto.hash_password(password string) !string { + salt := rand.bytes(16) or { return error('failed to generate salt: ${err}') } + hash := argon2.hash(password.bytes(), salt) or { return error('argon2 hash failed: ${err}') } + return hash +} + +pub fn Crypto.hash_verify(password string, hash string) !bool { + return argon2.verify(hash, password.bytes()) or { return error('argon2 verify failed: ${err}') } +} diff --git a/app/src/database/db.v b/app/src/database/db.v index a35a2df..5a6a688 100644 --- a/app/src/database/db.v +++ b/app/src/database/db.v @@ -1,22 +1,14 @@ module database import os -import rand +import orm import util -// import db.mysql -// import db.redis -import fleximus.argon2 +import db.sqlite -pub struct Crypto {} - -pub fn Crypto.hash_password(password string) !string { - salt := rand.bytes(16) or { return error('failed to generate salt: ${err}') } - hash := argon2.hash(password.bytes(), salt) or { return error('argon2 hash failed: ${err}') } - return hash -} - -pub fn Crypto.hash_verify(password string, hash string) !bool { - return argon2.verify(hash, password.bytes()) or { return error('argon2 verify failed: ${err}') } +pub struct Database { + cfg util.Config +mut: + conn sqlite.DB } pub fn get_dummy_exclusion_list(exe string, root string) []string { @@ -26,42 +18,48 @@ pub fn get_dummy_exclusion_list(exe string, root string) []string { ] } -// struct Database { -// mut: -// conn mysql.DB -// } -// -// pub fn get_connection(cfg util.Config) !&Database { -// // connection := mysql.connect(mysql.Config{ -// // host: cfg.database.host -// // // port: u32(cfg.database.port) -// // dbname: 'CDN_DATABASE' -// // username: cfg.database.username -// // password: cfg.database.password -// // })! -// -// //return &Database{ conn: connection } -// return &Database{} -// } -// -// pub fn (mut db Database) query[T](query_fn fn(conn &mysql.Connection) ![]T) ![]T { -// result := query_fn(db.conn) or { -// // Connection died, reconnect -// db.conn = mysql.connect(db.config)! -// return query_fn(db.conn)! -// } -// return result -// } -// -// pub fn Database.test() {} +fn Database.get_connection(cfg util.Config) !sqlite.DB { + conn := sqlite.connect(cfg.database)! -// pub fn test() ! { -// mut r := redis.connect(redis.Config{ -// host: 'vpn.security-command.org' -// port: 6767 -// password: 'SuperSecretPassword123' -// })! -// -// pong := r.ping() or { panic(err) } -// println(pong) -// } + if cfg.debug { + conn.synchronization_mode(.off)! + conn.journal_mode(.memory)! + } + + sql conn { + create table Users + create table Files + create table Logins + }! + + return conn +} + +pub fn Database.get_database(cfg util.Config) !&Database { + return &Database{ + cfg: cfg + conn: Database.get_connection(cfg)! + } +} + +pub fn (mut db Database) query[T](statement orm.QueryBuilder[T]) ![]T { + if !db.conn.validate() or { false } { + db.conn = Database.get_connection(db.cfg)! + } + + return statement.query()! +} + +pub fn test(cfg util.Config) !bool { + mut db := Database.get_database(cfg)! + + db.conn.validate()! + db.conn.close()! + + mut qb := orm.new_query[Users](db.conn) + + result := db.query[Users](qb)! + println(result.str()) + + return true +} diff --git a/app/src/database/tables.v b/app/src/database/tables.v index 2f3e0d9..bdbd620 100644 --- a/app/src/database/tables.v +++ b/app/src/database/tables.v @@ -1,23 +1,24 @@ module database -// -// @[table: 'users'] -// struct Users { -// id int @[primary; serial] -// name string @[nonnull; unique] -// password_hash string @[nonnull] -// } -// -// @[table: 'login_attempts'] -// struct Logins { -// ip string @[primary] -// attempts int @[nonnull] -// attempt_time string @[default: 'CURRENT_TIMESTAMP'; nonnull] -// } -// -// @[table: 'files'] -// struct Files { -// id int @[primary; serial] -// path string @[nonnull; unique] -// visible bool @[default: false; nonnull] -// } +import time + +@[table: 'users'] +struct Users { + id int @[primary; serial] + username string @[nonnull; unique] + password string @[nonnull] +} + +@[table: 'login_attempts'] +struct Logins { + ip string @[primary] + attempts int @[nonnull] + attempt_time time.Time @[nonnull] +} + +@[table: 'files'] +struct Files { + id int @[primary; serial] + path string @[nonnull; unique] + visible bool @[default: false; nonnull] +} diff --git a/app/src/jwt/algoritm.v b/app/src/jwt/algoritm.v new file mode 100644 index 0000000..88e0e9b --- /dev/null +++ b/app/src/jwt/algoritm.v @@ -0,0 +1,19 @@ +module jwt + +pub interface Algorithm { + name string + sign(contents string, secretOrKey string) !string + verify(token string, secretOrKey string) !Token +} + +pub enum AlgorithmType { + hs256 +} + +pub fn new_algorithm(algorithmType AlgorithmType) Algorithm { + match algorithmType { + .hs256 { + return HS256{} + } + } +} diff --git a/app/src/jwt/hs256.v b/app/src/jwt/hs256.v new file mode 100644 index 0000000..d10dd78 --- /dev/null +++ b/app/src/jwt/hs256.v @@ -0,0 +1,34 @@ +module jwt + +import crypto.hmac +import crypto.sha256 +import encoding.base64 + +pub struct HS256 { +pub: + name string = 'HS256' +} + +pub fn (algorithm HS256) sign(content string, secret string) !string { + signature := base64.url_encode(hmac.new(secret.bytes(), content.bytes(), sha256.sum, + sha256.block_size)) + return signature +} + +pub fn (algorithm HS256) verify(token_raw string, secret string) !Token { + token := parse_token(token_raw)! + + parts := token_raw.split('.') + header := parts[0] + claims := parts[1] + signed := algorithm.sign('${header}.${claims}', secret)! + + if signed != token.signature { + return error('Invalid token') + } + if token.is_expired() { + return error('Token already expired') + } + + return token +} diff --git a/app/src/jwt/jwt.v b/app/src/jwt/jwt.v new file mode 100644 index 0000000..dd3b3d9 --- /dev/null +++ b/app/src/jwt/jwt.v @@ -0,0 +1,45 @@ +module jwt + +import json +import time +import x.json2 +import encoding.base64 + +pub fn encode[T](claims T, algorithm Algorithm, secretOrKey string, exp int) !string { + header := new_header(algorithm) + header_b64 := base64.url_encode(json.encode(header).bytes()) + + mut claims_final := json2.raw_decode(json.encode(claims))!.as_map() + + if exp != 0 { + claims_final["exp"] = time.now().unix_time() + exp + } + + claims_b64 := base64.url_encode(claims_final.str().bytes()) + contents := '${header_b64}.${claims_b64}' + signature := algorithm.sign(contents, secretOrKey)! + + return '${contents}.${signature}' +} + +pub fn verify[T](token string, algorithm Algorithm, secretOrKey string) !T { + return algorithm.verify(token, secretOrKey)!.parse_claims() +} + +pub fn create_jwt(user_id int) string { + alg := new_algorithm(jwt.AlgorithmType.hs256) + token := jwt.encode({ 'id': user_id }, alg, secret_key, 7 * 24 * 60 * 60 * 1000) or { + error('JWT encode error: $err') + } + + return token +} + +pub fn verify_jwt(token string) !int { + alg := jwt.new_algorithm(jwt.AlgorithmType.hs256) + claims := jwt.verify[token](token, alg, secret_key) or { + return error('Invalid token: $err') + } + + return claims['id'].int() or { return error('ID not found in token') } +} diff --git a/app/src/jwt/structs.v b/app/src/jwt/structs.v new file mode 100644 index 0000000..78e1741 --- /dev/null +++ b/app/src/jwt/structs.v @@ -0,0 +1,65 @@ +module jwt + +import json +import time +import x.json2 +import encoding.base64 + +pub struct JWTHeader { +pub: + alg string + typ string = 'JWT' +} + +fn new_header(algorithm Algorithm) JWTHeader { + return JWTHeader{ + alg: algorithm.name.str().to_upper() + } +} + +struct Token { +pub: + header JWTHeader + claims string + signature string + expiration int +} + +pub fn (t Token) parse_claims[T]() !T { + return json.decode(T, t.claims) +} + +pub fn (t Token) is_expired() bool { + return t.expiration == -1 || time.unix(t.expiration) < time.now() +} + +pub fn parse_token(token_raw string) !Token { + parts := token_raw.split('.') + if parts.len != 3 { + return error('Invalid token') + } + + header_raw := if parts[0].len % 4 == 0 { parts[0] } else { parts[0] + '==' } + claims_raw := if parts[1].len % 4 == 0 { parts[1] } else { parts[1] + '==' } + + decoded_header := base64.decode_str(header_raw) + decoded_claims := base64.decode_str(claims_raw) + + claims := json2.raw_decode(decoded_claims)!.as_map() + + mut expiration_given := true + + expiration_unix := claims['exp'] or { + expiration_given = false + json2.Null{} + } + + token := Token{ + header: json.decode(JWTHeader, decoded_header)! + claims: decoded_claims + signature: parts[2] + expiration: if expiration_given { expiration_unix.int() } else { -1 } + } + + return token +} diff --git a/app/src/main.v b/app/src/main.v index 983971b..9a72c4d 100644 --- a/app/src/main.v +++ b/app/src/main.v @@ -40,8 +40,8 @@ pub: @['/:path...'] pub fn (app &App) root(mut ctx Context, path string) veb.Result { - abs_root := util.Utility.normalize_path(os.abs_path(app.cfg.root)) - abs_path := util.Utility.normalize_path(abs_root + os.abs_path(path)) + abs_root := util.Utility.normalize_path(os.real_path(app.cfg.root)) + abs_path := util.Utility.normalize_path(os.real_path(os.join_path(abs_root, path))) if !abs_path.starts_with(abs_root) && abs_path != abs_root { return ctx.forbidden() @@ -113,51 +113,35 @@ pub fn (mut ctx Context) server_error(msg string) veb.Result { fn populate() (&util.Config, &util.Embedded) { dotenv.load() - dotenv.require('CDN_DB_HOST', 'CDN_DB_PORT') executable := os.executable() def_root := os.dir(executable) def_port := int($d('port', 6767)) - def_user := $d('username', 'cdn') - def_pass := $d('password', 'totallySafeCdnDatabasePassword1235') + def_sqlt := $d('database', 'cdn_web.db') + def_jwt := $d('jwt', 'supersecurejwttokenkey') + // vfmt off return &util.Config{ exe: util.Utility.normalize_path(executable) - root: util.Utility.normalize_path(if os.getenv('CDN_ROOT') != '' { - util.Utility.resolve_path(def_root, os.getenv('CDN_ROOT').str()) - } else { - def_root - }) - port: if os.getenv('CDN_PORT') != '' { os.getenv('CDN_PORT').int() } else { def_port } - database: &util.Database{ - host: os.getenv('CDN_DB_HOST').str() - port: os.getenv('CDN_DB_PORT').int() - username: if os.getenv('CDN_DB_USERNAME') != '' { - os.getenv('CDN_DB_USERNAME').str() - } else { - def_user - } - password: if os.getenv('CDN_DB_PASSWORD') != '' { - os.getenv('CDN_DB_PASSWORD').str() - } else { - def_pass - } - } + root: util.Utility.normalize_path(if os.getenv('CDN_ROOT') != '' { util.Utility.resolve_path(def_root, os.getenv('CDN_ROOT').str()) } else { def_root }) + port: if os.getenv('CDN_PORT') != '' { os.getenv('CDN_PORT').int() } else { def_port } + jwt_key: if os.getenv('CDN_JWT_KEY') != '' { os.getenv('CDN_JWT_KEY').str() } else { def_jwt } + database: if os.getenv('CDN_SQL_DSN') != '' { os.getenv('CDN_SQL_DSN').str() } else { def_sqlt } }, &util.Embedded{ style_css: $embed_file('template/assets/style.css', .zlib).to_string() error_css: $embed_file('template/assets/error.css', .zlib).to_string() } + // vfmt on } fn main() { + // vfmt off cfg, mut embed := populate() - mut app := &App{ - cfg: cfg - embed: embed - } - mut auth := &Auth{ - app: &app - } + mut app := &App{ cfg: cfg, embed: embed } + mut auth := &Auth{ app: &app } + // vfmt on + + database.test(cfg)! app.register_controller[Auth, Context]('/auth', mut auth)! diff --git a/app/src/util/http.v b/app/src/util/http.v new file mode 100644 index 0000000..7a0f8fe --- /dev/null +++ b/app/src/util/http.v @@ -0,0 +1 @@ +module util diff --git a/app/src/util/structs.v b/app/src/util/structs.v index fcabb55..95769dd 100644 --- a/app/src/util/structs.v +++ b/app/src/util/structs.v @@ -5,20 +5,14 @@ import os import time import encoding.base64 -pub struct Database { -pub: - host string - port int - username string - password string -} - pub struct Config { pub: exe string root string port int - database Database + debug bool + jwt_key string + database string } pub struct Embedded { @@ -159,7 +153,7 @@ pub fn Utility.list_files(dir_path string, relative string, exclude []string) ![ } pub fn Utility.normalize_path(path string) string { - return path.replace('\\', '/') + return path.replace('\\', '/').replace('//', '/') } pub struct HtmlBuilder {}