tldr; nothing works

This commit is contained in:
2025-11-28 12:31:12 +01:00
parent 58849118d8
commit 9d541bd2bc
13 changed files with 426 additions and 127 deletions

View File

@@ -1,16 +1,14 @@
module crypto module cryptography
import rand import rand
import fleximus.argon2 import fleximus.argon2
pub struct Crypto {} pub fn hash_password(password string) !string {
pub fn Crypto.hash_password(password string) !string {
salt := rand.bytes(16) or { return error('failed to generate salt: ${err}') } 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}') } hash := argon2.hash(password.bytes(), salt) or { return error('argon2 hash failed: ${err}') }
return hash return hash
} }
pub fn Crypto.hash_verify(password string, hash string) !bool { pub fn hash_verify(password string, hash string) !bool {
return argon2.verify(hash, password.bytes()) or { return error('argon2 verify failed: ${err}') } return argon2.verify(hash, password.bytes()) or { return error('argon2 verify failed: ${err}') }
} }

View File

@@ -7,8 +7,6 @@ import db.sqlite
pub struct Database { pub struct Database {
cfg util.Config cfg util.Config
mut:
conn sqlite.DB
} }
pub fn get_dummy_exclusion_list(exe string, root string) []string { pub fn get_dummy_exclusion_list(exe string, root string) []string {
@@ -18,16 +16,16 @@ pub fn get_dummy_exclusion_list(exe string, root string) []string {
] ]
} }
fn get_connection(cfg util.Config) !sqlite.DB { fn (db &Database) get_connection() !sqlite.DB {
conn := sqlite.connect(cfg.database)! conn := sqlite.connect(db.cfg.database)!
if cfg.debug { if db.cfg.debug {
conn.synchronization_mode(.off)! conn.synchronization_mode(.off)!
conn.journal_mode(.memory)! conn.journal_mode(.memory)!
} }
sql conn { sql conn {
create table Users create table User
create table Files create table Files
create table Logins create table Logins
}! }!
@@ -37,33 +35,15 @@ fn get_connection(cfg util.Config) !sqlite.DB {
pub fn get_database(cfg util.Config) !&Database { pub fn get_database(cfg util.Config) !&Database {
return &Database{ return &Database{
cfg: cfg cfg: cfg
conn: get_connection(cfg)!
} }
} }
pub fn (mut db Database) query[T](statement orm.QueryBuilder[T]) ![]T { pub fn (mut db Database) query[T](statement orm.QueryBuilder[T]) ![]T {
if !db.conn.validate() or { false } {
db.conn = get_connection(db.cfg)!
}
return statement.query()! return statement.query()!
} }
pub fn (db Database) get_query_builder[T]() &orm.QueryBuilder[T] { pub fn (db Database) get_query_builder[T]() &orm.QueryBuilder[T] {
return orm.new_query[T](db.conn) conn := db.get_connection() or { panic(err) }
} return orm.new_query[T](conn)
pub fn test(cfg util.Config) !bool {
mut db := 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
} }

8
app/src/database/files.v Normal file
View File

@@ -0,0 +1,8 @@
module database
@[table: 'files']
pub struct Files {
id int @[primary; serial]
path string @[nonnull; unique]
visible bool @[default: false; nonnull]
}

46
app/src/database/logins.v Normal file
View File

@@ -0,0 +1,46 @@
module database
import time
@[table: 'login_attempts']
pub struct Logins {
mut:
ip string @[primary]
attempts int @[nonnull]
attempt_time time.Time @[nonnull]
}
pub fn Logins.by_ip(ip string, mut database_ Database) ?Logins {
query := database_.get_query_builder[Logins]().where('ip = ?', ip) or { return none }
result := database_.query[Logins](query) or { return none }
return result.first()
}
pub fn Logins.create_or_update(ip string, mut database_ Database) ?Logins {
mut login := Logins{
ip: ip
attempts: 1
attempt_time: time.now()
}
if existing := Logins.by_ip(ip, mut database_) {
login.attempts = existing.attempts + 1
login.attempt_time = time.now()
db := database_.get_connection() or { panic(err) }
sql db {
update Logins set attempts = login.attempts, attempt_time = login.attempt_time
where ip == ip
} or { return none }
} else {
db := database_.get_connection() or { panic(err) }
sql db {
insert login into Logins
} or { return none }
}
return login
}

View File

@@ -1,24 +0,0 @@
module database
import time
@[table: 'users']
pub struct Users {
id int @[primary; serial]
username string @[nonnull; unique]
password string @[nonnull]
}
@[table: 'login_attempts']
pub struct Logins {
ip string @[primary]
attempts int @[nonnull]
attempt_time time.Time @[nonnull]
}
@[table: 'files']
pub struct Files {
id int @[primary; serial]
path string @[nonnull; unique]
visible bool @[default: false; nonnull]
}

52
app/src/database/user.v Normal file
View File

@@ -0,0 +1,52 @@
module database
import orm
import cryptography
@[table: 'users']
pub struct User {
pub:
id ?int @[primary; serial]
username string @[nonnull; unique]
password string @[nonnull]
}
pub fn User.by_id(user_id orm.Primitive, mut database_ Database) ?User {
query := database_.get_query_builder[User]().where('id = ?', user_id) or { return none }
result := database_.query[User](query) or { return none }
return result.first()
}
pub fn User.by_name(username orm.Primitive, mut database_ Database) ?User {
eprintln('qb')
query := database_.get_query_builder[User]().where('username = ?', username) or { return none }
eprintln('result')
result := database_.query[User](*query) or { return none }
eprintln('first')
return result.first()
}
pub fn User.create(username string, password string, database_ Database) ?User {
hash := cryptography.hash_password(password) or { return none }
mut user := User{
id: none
username: username
password: hash
}
db := database_.get_connection() or { panic(err) }
sql db {
insert user into User
} or { return none }
return user
}
pub fn User.verify(username orm.Primitive, password string, mut database_ Database) ?User {
eprintln('by_name')
user := User.by_name(username, mut database_) or { return none }
return if cryptography.hash_verify(password, user.password) or { false } { user } else { none }
}

View File

@@ -10,9 +10,7 @@ pub:
} }
pub fn (algorithm HS256) sign(content string, secret string) !string { pub fn (algorithm HS256) sign(content string, secret string) !string {
signature := base64.url_encode(hmac.new(secret.bytes(), content.bytes(), sha256.sum, return base64.url_encode(hmac.new(secret.bytes(), content.bytes(), sha256.sum, sha256.block_size))
sha256.block_size))
return signature
} }
pub fn (algorithm HS256) verify(token_raw string, secret string) !Token { pub fn (algorithm HS256) verify(token_raw string, secret string) !Token {

View File

@@ -4,30 +4,25 @@ import os
import veb import veb
import jwt import jwt
import util import util
import sync
import database import database
import thomaspeissl.dotenv import thomaspeissl.dotenv
import sync
import orm
import time import time
import net.http
// structs // structs
pub struct User {
pub mut:
name string
id int
}
pub struct CachedUser { pub struct CachedUser {
pub: pub:
user User user database.User
expires int // unix seconds expires int
} }
pub struct Context { pub struct Context {
veb.Context veb.Context
pub mut: pub mut:
user User app &App
user database.User
} }
pub struct App { pub struct App {
@@ -35,15 +30,17 @@ pub struct App {
veb.StaticHandler veb.StaticHandler
veb.Middleware[Context] veb.Middleware[Context]
pub: pub:
cfg util.Config cfg util.Config
embed util.Embedded embed util.Embedded
pub mut:
database database.Database
cache_lock sync.RwMutex cache_lock sync.RwMutex
user_cache map[string]CachedUser user_cache map[string]CachedUser
pub mut:
database database.Database
} }
pub struct Auth { pub struct Auth {
veb.Controller
veb.Middleware[Context]
pub: pub:
app &App app &App
} }
@@ -74,19 +71,78 @@ pub fn (app &App) root(mut ctx Context, path string) veb.Result {
files, meta := util.HtmlBuilder.generate_file_list(entries, abs_path) files, meta := util.HtmlBuilder.generate_file_list(entries, abs_path)
style := app.embed.style_css style := app.embed.style_css
username := if ctx.user.username != '' { ctx.user.username } else { 'noone' }
return ctx.html($tmpl('template/dashboard.html')) return ctx.html($tmpl('template/dashboard.html'))
} }
// auth endpoints // auth endpoints
@['/']
pub fn (auth &Auth) root(mut ctx Context) veb.Result {
return ctx.redirect('/')
}
@[get; post] @[get; post]
pub fn (auth &Auth) login(mut ctx Context) veb.Result { pub fn (auth &Auth) login(mut ctx Context) veb.Result {
return ctx.text('login') if ctx.req.method == .get {
style := auth.app.embed.admin_css
return ctx.html($tmpl('template/auth/login.html'))
}
token := ctx.get_cookie('veb_session') or { '' }
if token != '' {
return ctx.redirect('/')
}
username := ctx.form['username'] or { return ctx.request_error('') }
password := ctx.form['password'] or { return ctx.request_error('') }
if password.len < 8 {
return ctx.request_error('')
}
if user := database.User.verify(username, password, mut ctx.app.database) {
id := user.id or { return ctx.server_error('') }
ctx.set_cookie(http.Cookie{
name: 'veb_session'
value: jwt.create(id.str(), ctx.app.cfg.jwt_key) or { return ctx.server_error('') }
path: '/'
secure: true
http_only: true
same_site: .same_site_strict_mode
})
} else {
// todo cache login attempts per ip as well and block early.
database.Logins.create_or_update(ctx.ip(), mut ctx.app.database)
return ctx.forbidden()
}
return ctx.redirect('/')
} }
@[get; post] @[get; post]
pub fn (auth &Auth) logout(mut ctx Context) veb.Result { pub fn (auth &Auth) logout(mut ctx Context) veb.Result {
return ctx.text('logout') if ctx.req.method == .get {
style := auth.app.embed.admin_css
return ctx.html($tmpl('template/auth/logout.html'))
}
token := ctx.get_cookie('veb_session') or { '' }
if token == '' {
return ctx.redirect('/')
}
ctx.set_cookie(http.Cookie{
name: 'veb_session'
value: ''
path: '/'
secure: true
http_only: true
same_site: .same_site_strict_mode
max_age: -1
})
return ctx.redirect('/')
} }
@[get; post; put] @[get; post; put]
@@ -96,7 +152,7 @@ pub fn (auth &Auth) register(mut ctx Context) veb.Result {
// middleware // middleware
fn (mut app &App) prepare() fn (mut Context) bool { fn (mut app App) prepare() fn (mut Context) bool {
return fn [mut app] (mut ctx Context) bool { return fn [mut app] (mut ctx Context) bool {
ctx.app = &app ctx.app = &app
return true return true
@@ -109,62 +165,45 @@ pub fn session(mut ctx Context) bool {
return true return true
} }
if id := jwt.decode(token, ctx.app.cfg.jwt_key) { if id := jwt.decode(token, ctx.app.cfg.jwt_key) {
query := ctx.app.database.get_query_builder[database.Users]().where('id = ?', id) or { return true } if user := ctx.app.get_user(id) {
result := ctx.app.database.query[database.Users](query) or { return true } ctx.user = user
}
ctx.user = result.first()
} }
return true return true
} }
// fn (mut app App) get_user_by_id_cached(id int) ?User { fn (mut app App) get_user(user_id string) ?database.User {
// key := id.str() now := time.now().unix()
// now := time.now().unix()
//
// // read lock -> quick path
// app.cache_lock.rlock()
// if cu := app.user_cache[key] {
// if cu.expires > int(now) {
// app.cache_lock.runlock()
// return cu.user
// }
// }
// app.cache_lock.runlock()
//
// // miss or expired -> fetch from DB
// // assume you have a DB pool on app or ctx.db gives you a connection
// // This example uses app-level DB access via a helper; adapt if your DB is on ctx.
// row := app.get_db().exec_param('SELECT id, name FROM users WHERE id = $1', key) or {
// return error('db error')
// }
// if row.len == 0 {
// return error('not found')
// }
//
// fetched := User{
// id: row[0].valint(0)
// name: row[0].vals[1]
// }
//
// // write lock -> populate cache
// app.cache_lock.lock()
// app.user_cache[key] = CachedUser{
// user: fetched
// expires: int(now) + 60 // TTL = 60s, tune to taste
// }
// app.cache_lock.unlock()
//
// return fetched
// }
app.cache_lock.rlock()
if cu := app.user_cache[user_id] {
if cu.expires > int(now) {
app.cache_lock.runlock()
return cu.user
}
}
app.cache_lock.runlock()
user := database.User.by_id(user_id, mut app.database) or { return none }
app.cache_lock.lock()
app.user_cache[user_id] = CachedUser{
user: user
expires: int(now) + 300
}
app.cache_lock.unlock()
return user
}
// utility // utility
fn (mut ctx Context) error_page(code int, short string, long string) string { fn (mut ctx Context) error_page(code int, short string, long string) string {
style := ctx.app.embed.error_css.clone() style := ctx.app.embed.error_css
return $tmpl('template/error.html') return $tmpl('template/error.html')
} }
@@ -209,6 +248,7 @@ fn populate() (util.Config, util.Embedded) {
}, util.Embedded{ }, util.Embedded{
style_css: $embed_file('template/assets/style.css', .zlib).to_string() style_css: $embed_file('template/assets/style.css', .zlib).to_string()
error_css: $embed_file('template/assets/error.css', .zlib).to_string() error_css: $embed_file('template/assets/error.css', .zlib).to_string()
admin_css: $embed_file('template/assets/admin.css', .zlib).to_string()
} }
// vfmt on // vfmt on
} }
@@ -217,7 +257,7 @@ fn main() {
// vfmt off // vfmt off
mut cfg, mut embed := populate() mut cfg, mut embed := populate()
mut app := &App{ cfg: cfg, embed: embed, database: database.get_database(cfg)! } mut app := &App{ cfg: cfg, embed: embed, database: database.get_database(cfg)! }
mut auth := &Auth{ app: &app } mut auth := &Auth{ app: app }
// vfmt on // vfmt on
app.register_controller[Auth, Context]('/auth', mut auth)! app.register_controller[Auth, Context]('/auth', mut auth)!

View File

@@ -0,0 +1,157 @@
/* General Reset */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
/* Light Theme */
:root[data-theme="light"] {
--bg-primary: #ffffff;
--bg-secondary: #f5f7fb;
--bg-tertiary: #eff2f5;
--text-primary: #1a202c;
--text-secondary: #6c757d;
--border-color: #d4d7de;
--accent: #0051ba;
--accent-hover: #003e8f;
}
/* Dark Theme */
:root[data-theme="dark"] {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--border-color: #30363d;
--accent: #58a6ff;
--accent-hover: #79c0ff;
}
/* Default theme */
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #e6edf3;
--text-secondary: #8b949e;
--border-color: #30363d;
--accent: #58a6ff;
--accent-hover: #79c0ff;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: var(--bg-primary);
color: var(--text-primary);
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
flex-direction: column;
text-align: center;
transition: background-color 0.3s ease, color 0.3s ease;
}
.container {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
padding: 2rem 3rem;
border-radius: 0.5rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
width: 100%;
max-width: 400px;
}
h1 {
font-size: 2rem;
margin-bottom: 1.5rem;
color: var(--accent);
}
form {
display: flex;
flex-direction: column;
gap: 1rem;
}
label {
text-align: left;
font-size: 0.875rem;
color: var(--text-secondary);
}
input[type="text"],
input[type="password"] {
padding: 0.5rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-tertiary);
color: var(--text-primary);
font-size: 1rem;
transition: all 0.2s ease;
}
input[type="text"]:focus,
input[type="password"]:focus {
border-color: var(--accent);
outline: none;
}
button {
padding: 0.5rem 1rem;
border-radius: 0.375rem;
border: 1px solid var(--border-color);
background-color: var(--bg-tertiary);
color: var(--text-primary);
cursor: pointer;
font-size: 1rem;
transition: all 0.2s ease;
}
button:hover {
background-color: var(--accent);
border-color: var(--accent);
color: #fff;
}
button:active {
transform: scale(0.98);
}
a.logout-link {
color: var(--accent);
text-decoration: none;
margin-top: 1rem;
display: inline-block;
font-size: 0.875rem;
}
a.logout-link:hover {
color: var(--accent-hover);
}
.theme-toggle {
margin-top: 1rem;
width: 1.75rem;
height: 1.75rem;
border: none;
background-color: var(--text-secondary);
border-radius: 6px;
cursor: pointer;
}
.theme-toggle:active {
transform: scale(0.98);
}
@media (max-width: 480px) {
.container {
padding: 1.5rem 2rem;
}
h1 {
font-size: 1.75rem;
}
}

View File

@@ -0,0 +1,25 @@
<!DOCTYPE html>
<!--suppress HtmlFormInputWithoutLabel -->
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login</title>
<style>@{style}</style>
</head>
<body>
<div class="container">
<h1>Login</h1>
<form action="/auth/login" method="POST">
<input placeholder="Username" type="text" id="username" name="username" required>
<input placeholder="Password" type="password" id="password" name="password" minlength="8" required>
<button type="submit">Sign In</button>
</form>
</div>
<script>
if ((savedTheme = (localStorage.getItem('theme') || 'light')))
document.documentElement.setAttribute('data-theme', savedTheme);
</script>
</body>
</html>

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Logout</title>
<style>@{style}</style>
</head>
<body>
<div class="container">
<h1>Log Out?</h1>
<form action="/auth/logout" method="POST">
<button type="submit">Logout</button>
</form>
</div>
<script>
if ((savedTheme = (localStorage.getItem('theme') || 'light')))
document.documentElement.setAttribute('data-theme', savedTheme);
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,7 @@ pub struct Embedded {
pub mut: pub mut:
style_css string style_css string
error_css string error_css string
admin_css string
} }
pub struct FileEntry { pub struct FileEntry {