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" } ) ); }