From 636b0323075225c584b62719ed51e75521bb7ffb Mon Sep 17 00:00:00 2001 From: aura Date: Tue, 17 Feb 2026 22:39:42 +0100 Subject: push source --- backend/api/src/user.zig | 533 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100644 backend/api/src/user.zig (limited to 'backend/api/src/user.zig') diff --git a/backend/api/src/user.zig b/backend/api/src/user.zig new file mode 100644 index 0000000..bec6e9c --- /dev/null +++ b/backend/api/src/user.zig @@ -0,0 +1,533 @@ +const z = @import( "std" ); +const db = @import( "db.zig" ); +const net = @import( "net-util.zig" ); +const zap = @import( "zap" ); +const jwt = @import( "jwt" ); +const mail = @import( "mail.zig" ); +const config = @import( "config.zig" ); +const u = struct { + usingnamespace @import( "util.zig" ); + usingnamespace @import( "userdefs.zig" ); +}; + +const ctime = @cImport( @cInclude( "time.h" ) ); + +const ArenaAllocator = z.heap.ArenaAllocator; +const ErrorRes = net.ErrorResponse; +const Status = zap.StatusCode; +const ArrayList = z.ArrayList; +const OkRes = net.OkResponse; +const Request = zap.Request; +const JWT = jwt.JWT; + +const UserDbEntry = u.UserDbEntry; +const LoginToken = u.LoginToken; +const UserEntry = u.UserEntry; +const AuthToken = u.AuthToken; +const ApiToken = u.ApiToken; + +const uuidv4 = net.uuidv4; +const alloc = u.alloc; + +const login_link_email = +\\Visit the following link to log in: +\\{s}/login?token={s} +; + +const reminder_email = +\\Your plan will expire on: {s}. +\\Subscriptions do not renew automatically. +\\Don't forget to resubscribe at {s}/upgrade +\\Or if you don't want to, let us know why at https://x.com/axonbox +; + +pub const routes = .{ + .@"login" = login, + .@"get-tokens" = getTokens, + .@"create-token" = createToken, + .@"delete-token" = deleteToken, + .@"delete-tokens" = deleteTokens, + .@"send-login-link" = sendLoginLink, + .@"invalidate-session" = invalidateSession, + .@"invalidate-all-sessions" = invalidateAllSessions, +}; + +fn createNew( email: []const u8, uuid: []const u8 ) !UserEntry { + try u.checkDbOnThread(); + var ret: UserEntry = undefined; + + ret.uuid = uuid; + ret.email = email; + ret.tokens = &[_][]const u8{}; + ret.api_tokens = &[_][]const u8{}; + ret.login_token = ""; + ret.token_resetdate = z.time.timestamp(); + ret.created_at = z.time.timestamp(); + ret.subscription_data = .{ .plan = "free", .endTime = -1 }; + ret.db_entry = null; + + return ret; +} + +pub fn updateEntry( user: UserEntry ) !void { + try u.checkDbOnThread(); + + var arena = ArenaAllocator.init( alloc ); + defer arena.deinit(); + const db_entry = try u.userToDb( user, &arena ); + + const query = + \\INSERT OR REPLACE INTO users ( uuid, email, tokens, login_token, token_resetdate, api_tokens, created_at, subscription_data ) + \\VALUES ( ?, ?, ?, ?, ?, ?, ?, ? ) + ; + + try u.dbi.put( query, db_entry ); +} + +pub fn checkSubscription( user: *UserEntry ) !void { + const time = z.time.milliTimestamp(); + const end_time = user.subscription_data.endTime; + + if( time > end_time and end_time > -1 ) { + user.subscription_data.endTime = -1; + user.subscription_data.plan = "free"; + try updateEntry( user.* ); + } +} + +///freed by caller +pub fn getEntryFromEmail( email: []const u8 ) !UserEntry { + try u.checkDbOnThread(); + + const query = "SELECT * FROM users WHERE email = ?"; + var res = u.dbi.oneAlloc( UserDbEntry, query, .{ .email = email } ) catch |e| { + switch( e ) { + error.QueryError => return error.UserNotFound, + else => return error.DatabaseError + } + }; + return u.dbToUser( &res ) catch return error.UserParse; +} + +pub fn getEntry( uuid: []const u8 ) !UserEntry { + try u.checkDbOnThread(); + + const query = "SELECT * FROM users WHERE uuid = ?"; + var res = try u.dbi.oneAlloc( UserDbEntry, query, .{ .uuid = uuid } ); + return u.dbToUser( &res ); +} + +///returns generated token +fn updateLoginToken( user: UserEntry ) ![]const u8 { + try u.checkDbOnThread(); + var copy = user; + _= try updateAuthTokens( ©, false ); + const token = u.generateLoginToken( user.uuid ) catch return error.TokenError; + copy.login_token = token; + updateEntry( copy ) catch { + alloc.free( token ); + return error.DatabaseError; + }; + + return token; +} + +fn setRemindedStatus( user: *UserEntry, value: bool ) !void { + user.subscription_data.reminded = value; + try updateEntry( user.* ); +} + +fn sendReminderEmail( user: UserDbEntry ) !void { + var arena = ArenaAllocator.init( alloc ); + defer arena.deinit(); + + var row = try u.entryToRow( user, &arena ); + var entry = try u.dbToUser( &row ); + + const t = @divFloor( entry.subscription_data.endTime, 1000 ); // ms to s + const lt = ctime.localtime( &t ); + const fmt = "%a %d %b %Y %I:%M:%S %p %Z"; + + // this is supposed to be user.subscription_data's "endTime" ( unix epoch milliseconds ) as a date-string, not the current time + var dt_str_buf: [40]u8 = undefined; + @memset( &dt_str_buf, 0 ); + + const len = ctime.strftime( &dt_str_buf, dt_str_buf.len - 1, fmt, lt ); + const dt_str = dt_str_buf[ 0 .. len ]; + + var buf: [4096]u8 = undefined; + const slice = z.fmt.bufPrintZ( &buf, reminder_email, .{ dt_str, config.server_url } ) catch return; + try mail.send( user.email, "Expiration Notice", slice ); + try setRemindedStatus( &entry, true ); +} + +fn isTime( hour: u8, minute: u8, timezone: i64 ) bool { + const seconds_in_day = 24 * 60 * 60; + const time = z.time.timestamp(); + const local_time = time + timezone; + const seconds_today = @mod( local_time, seconds_in_day ); + const target = @as( i64, hour ) * 3600 + @as( i64, minute ) * 60; + const diff: i64 = if ( seconds_today >= target ) seconds_today - target else target - seconds_today; + const delta: f64 = @floatFromInt( diff ); + return delta <= 2.5; +} + +pub fn sendReminderEmails() !void { + if ( !isTime( 0, 0, -18000 ) ) // midnight +-2.5s adjusted for CST timezone during DST + return; + + try u.checkDbOnThread(); + const time = z.time.milliTimestamp(); + const sevenDays = ( 1000 * 60 * 60 * 24 * 7 ); + + const checkEndTime = time + sevenDays; + const query = +\\SELECT * FROM users WHERE +\\json_extract( subscription_data, '$.endTime' ) <= ? +\\AND +\\json_extract( subscription_data, '$.reminded' ) = false +; + + const res = try u.dbi.all( UserDbEntry, query, .{ .endTime = checkEndTime } ); + defer u.alloc.free( res ); + if ( res.len == 0 ) + return; + + for ( res ) |r| + try sendReminderEmail( r ); +} + +fn sendEmail( email: []const u8, token: []const u8 ) !void { + var buf: [4096]u8 = undefined; + const slice = z.fmt.bufPrintZ( &buf, login_link_email, .{ config.server_url, token } ) catch return; + try mail.send( email, "Login link", slice ); +} + +///route @/send-login-link +fn sendLoginLink( r: Request ) void { + if( net.handleInvalidPostReq( r ) ) return; + u.checkDbOnThread() catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ); + }; + + const body = r.body.?; + const json = u.jsonParse( struct{ email: []const u8 }, body ) catch { + return net.sendJson( r, .bad_request, ErrorRes{ .msg = "missing email field" } ); + }; defer json.deinit(); + + const email = json.v.email; + if( !u.Validator.isValidStrEmail( email ) ) + return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid email format" } ); + + const user = getEntryFromEmail( email ) catch |e| r: { + switch( e ) { + error.UserNotFound => { + const uuid = uuidv4(); + break :r createNew( email, &uuid ) catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "could not create user" } ); + }; + }, + else => { + z.debug.print( "error getting entry for {s} {any} {any}", .{ email, e, @errorReturnTrace() } ); + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "server error" } ); + } + } + }; defer { if( user.db_entry ) |entry| entry.deinit(); } + + const token = updateLoginToken( user ) catch |e| { + z.debug.print( "error updating login token {s} {any} {any}", .{ user.uuid, e, @errorReturnTrace() } ); + switch( e ) { + error.TokenError => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to generate token" } ), + else => return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to update user" } ) + } + }; defer alloc.free( token ); + + sendEmail( email, token ) catch { + return net.sendJson( r, .bad_request, ErrorRes{ .msg = "failed to send email" } ); + }; + net.sendJson( r, .ok, OkRes( .{ .msg = "email sent" } ) ); +} + +///returns generated token +fn updateAuthTokens( user: *UserEntry, create_new: bool ) !?[]const u8 { + const tokens = user.tokens; + var list = ArrayList( []const u8 ).init( alloc ); + defer list.deinit(); + // should not be possible, if it happened we either had a messed up clock + // or the db was somehow invalid. fix it up. + if( user.token_resetdate > z.time.timestamp() ) { + user.token_resetdate = z.time.timestamp(); + } + + for( tokens ) |token| { + var t = u.verifyToken( AuthToken, token ) catch { + continue; + }; + defer t.deinit(); + list.append( token ) catch {}; + } + + var new_token: ?[]const u8 = null; + if( create_new ) { + new_token = u.generateAuthToken( user.uuid, user.email ) catch { + return error.TokenError; + }; + + list.append( new_token.? ) catch {}; + user.tokens = list.items; + } + updateEntry( user.* ) catch return error.DatabaseError; + return new_token; +} + +///route @/login +fn login( r: Request ) void { + if( r.methodAsEnum() != .GET ) + return net.sendJson( r, .bad_request, ErrorRes{ .msg = "method not allowed" } ); + r.parseQuery(); + + u.checkDbOnThread() catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ); + }; + + var decoded = u.tokenFromGetReq( r ) catch return; + defer decoded.deinit(); + + var user = getEntry( decoded.claims.uuid ) catch { + return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } ); + }; defer user.db_entry.?.deinit(); + + const token_str = r.getParamStr( alloc, "token", true ) catch return orelse return; + defer token_str.deinit(); + if( !z.mem.eql( u8, token_str.str, user.login_token ) ) { + return net.sendJson( r, .unauthorized, ErrorRes{ .msg = "invalid token" } ); + } + user.login_token = ""; + checkSubscription( &user ) catch return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "subscription error" } ); + + + const new_token = updateAuthTokens( &user, true ) catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to update token" } ); + }; defer alloc.free( new_token.? ); + + net.sendJson( r, .ok, OkRes( .{ + .session = new_token.?, + .msg = "signed in" + } ) ); +} + +///route @/invalidate-session +fn invalidateSession( r: Request ) void { + if( net.handleInvalidPostReq( r ) ) return; + u.checkDbOnThread() catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ); + }; + + var decoded = u.tokenFromPostReq( r ) catch return; + defer decoded.deinit(); + + var user = getEntry( decoded.claims.uuid ) catch { + return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } ); + }; defer user.db_entry.?.deinit(); + + var found = false; + var list = ArrayList( []const u8 ).init( alloc ); + defer list.deinit(); + for( user.tokens ) |token| { + var t = u.verifyToken( AuthToken, token ) catch { + continue; + }; defer t.deinit(); + + if( t.claims.iat == decoded.claims.iat ) { + found = true; + continue; + } + list.append( token ) catch {}; + } + + user.tokens = list.items; + updateEntry( user ) catch |e| { + z.debug.print( "error updating user entry {s} {any} {any}\n", .{ user.uuid, e, @errorReturnTrace() } ); + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = @errorName(e) } ); + }; + + if( found ) { + net.sendJson( r, .ok, OkRes( .{ .msg = "session invalidated" } ) ); + } + else { + net.sendJson( r, .bad_request, ErrorRes{ .msg = "token not found" } ); + } +} + +///route @/invalidate-all-sessions +fn invalidateAllSessions( r: Request ) void { + if( net.handleInvalidPostReq( r ) ) return; + u.checkDbOnThread() catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ); + }; + + var decoded = u.tokenFromPostReq( r ) catch return; + defer decoded.deinit(); + + var user = getEntry( decoded.claims.uuid ) catch { + return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } ); + }; defer user.db_entry.?.deinit(); + + user.token_resetdate = z.time.timestamp(); + user.tokens = &[_][]const u8{}; + updateEntry( user ) catch |e| { + z.debug.print( "error updating user entry {s} {any} {any}\n", .{ user.uuid, e, @errorReturnTrace() } ); + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = @errorName(e) } ); + }; + + net.sendJson( r, .ok, OkRes( .{ .msg = "sessions invalidated" } ) ); +} + +///returns only the api tokens +///route @/get-tokens +fn getTokens( r: Request ) void { + if( net.handleInvalidPostReq( r ) ) return; + u.checkDbOnThread() catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ); + }; + + var decoded = u.tokenFromPostReq( r ) catch return; + defer decoded.deinit(); + + var user = getEntry( decoded.claims.uuid ) catch { + return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } ); + }; defer user.db_entry.?.deinit(); + + var arena = ArenaAllocator.init( alloc ); + defer arena.deinit(); + const a = arena.allocator(); + + var list = ArrayList( struct { id: u32, value: []const u8 } ).init( a ); + var i: u32 = 0; + for( user.api_tokens ) |tok| { + var t = u.verifyToken( ApiToken, tok ) catch |e| { + z.debug.print( "token {s} invalid {any}\n", .{tok, e} ); + continue; + }; defer t.deinit(); + + const slice = z.fmt.allocPrint( a, "...{s}", .{tok[tok.len-5..tok.len-1]} ) catch ""; + list.append( .{ + .id = i, + .value = slice, + } ) catch {}; + i += 1; + } + + return net.sendJson( r, .ok, OkRes( .{ + .msg = "tokens retrieved", + .tokens = list.items, + } ) ); +} + +///creates a new api token, not an auth token +///route @/create-token +fn createToken( r: Request ) void { + if( net.handleInvalidPostReq( r ) ) return; + u.checkDbOnThread() catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ); + }; + + var decoded = u.tokenFromPostReq( r ) catch return; + defer decoded.deinit(); + + var user = getEntry( decoded.claims.uuid ) catch { + return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } ); + }; defer user.db_entry.?.deinit(); + + var list = ArrayList( []const u8 ).init( alloc ); + defer list.deinit(); + for( user.api_tokens ) |tok| { + var t = u.verifyToken( ApiToken, tok ) catch { + continue; + }; + list.append( tok ) catch {}; + t.deinit(); + } + + const newtoken = u.generateApiToken( user.uuid ) catch |e| { + z.debug.print( "error generating api token for {s} {any} {any}\n", .{ user.uuid, e, @errorReturnTrace() } ); + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "error creating token" } ); + }; defer alloc.free( newtoken ); + + list.append( newtoken ) catch {}; + user.api_tokens = list.items; + + updateEntry( user ) catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ); + }; + + net.sendJson( r, .ok, OkRes( .{ .msg = "token created", .token = newtoken } ) ); +} + +///deletes an api token, not an auth token +///route @/delete-token +fn deleteToken( r: Request ) void { + if( net.handleInvalidPostReq( r ) ) return; + u.checkDbOnThread() catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ); + }; + + const body = r.body.?; + const json = u.jsonParse( struct{ token: []const u8, id: u64 }, body ) catch |e| { + z.debug.print( "error parsing json {any} {any}\n", .{ e, @errorReturnTrace() } ); + return net.sendJson( r, .bad_request, ErrorRes{ .msg = "invalid json" } ); + }; + + var decoded = u.verifyToken( AuthToken, json.v.token ) catch { + return net.sendJson( r, .unauthorized, ErrorRes{ .msg = "invalid token" } ); + }; defer decoded.deinit(); + + var user = getEntry( decoded.claims.uuid ) catch { + return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } ); + }; defer user.db_entry.?.deinit(); + + const tokens = user.api_tokens; + var list = ArrayList( []const u8 ).init( alloc ); + defer list.deinit(); + for( tokens, 0.. ) |tok, i| { + if( i == json.v.id ) continue; + var t = u.verifyToken( ApiToken, tok ) catch { + continue; + }; + list.append( tok ) catch {}; + t.deinit(); + } + + user.api_tokens = list.items; + updateEntry( user ) catch |e| { + z.debug.print( "failed to update entry {s} {any} {any}", .{ user.uuid, e, @errorReturnTrace() } ); + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to update user" } ); + }; + + net.sendJson( r, .ok, OkRes( .{ .msg = "token deleted" } ) ); +} + +///wipes all api tokens, not auth tokens +///route @/delete-tokens +fn deleteTokens( r: Request ) void { + u.checkDbOnThread() catch { + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "internal server error" } ); + }; + if( net.handleInvalidPostReq( r ) ) return; + + var decoded = u.tokenFromPostReq( r ) catch return; + defer decoded.deinit(); + + var user = getEntry( decoded.claims.uuid ) catch { + return net.sendJson( r, .not_found, ErrorRes{ .msg = "user not found" } ); + }; defer user.db_entry.?.deinit(); + + var empty_tokens = [_][]const u8{}; + user.api_tokens = &empty_tokens; + updateEntry( user ) catch |e| { + z.debug.print( "failed to update user {s} {any} {any}", .{ user.uuid, e, @errorReturnTrace() } ); + return net.sendJson( r, .internal_server_error, ErrorRes{ .msg = "failed to update user" } ); + }; + + net.sendJson( r, .ok, OkRes( .{ .msg = "tokens deleted" } ) ); +} -- cgit v1.2.3