summaryrefslogtreecommitdiff
path: root/backend/api/src/user.zig
diff options
context:
space:
mode:
Diffstat (limited to 'backend/api/src/user.zig')
-rw-r--r--backend/api/src/user.zig533
1 files changed, 533 insertions, 0 deletions
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( &copy, 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" } ) );
+}