fediglam/src/main/api.zig

329 lines
11 KiB
Zig

const std = @import("std");
const util = @import("util");
const builtin = @import("builtin");
const db = @import("./db.zig");
const models = @import("./db/models.zig");
pub const DateTime = util.DateTime;
pub const Uuid = util.Uuid;
const Config = @import("./main.zig").Config;
const services = struct {
const communities = @import("./api/communities.zig");
const users = @import("./api/users.zig");
const auth = @import("./api/auth.zig");
const invites = @import("./api/invites.zig");
const notes = @import("./api/notes.zig");
};
pub const RegistrationRequest = struct {
username: []const u8,
password: []const u8,
invite_code: []const u8,
email: ?[]const u8 = null,
};
pub const InviteRequest = struct {
pub const Type = services.invites.InviteType;
name: ?[]const u8 = null,
expires_at: ?DateTime = null, // TODO: Change this to lifespan
max_uses: ?usize = null,
invite_type: Type = .user, // must be user unless the creator is an admin
to_community: ?[]const u8 = null, // only valid on admin community
};
pub const LoginResponse = struct {
token: services.auth.tokens.Token.Value,
user_id: Uuid,
issued_at: DateTime,
};
pub const UserResponse = struct {
id: Uuid,
username: []const u8,
host: []const u8,
created_at: DateTime,
};
pub const NoteResponse = struct {
id: Uuid,
author: struct {
id: Uuid,
username: []const u8,
host: []const u8,
},
content: []const u8,
created_at: DateTime,
};
// Frees an api struct and its fields allocated from alloc
pub fn free(alloc: std.mem.Allocator, val: anytype) void {
switch (@typeInfo(@TypeOf(val))) {
.Pointer => |ptr_info| switch (ptr_info.size) {
.One => {
free(alloc, val.*);
alloc.destroy(val);
},
.Slice => {
for (val) |elem| free(alloc, elem);
alloc.free(val);
},
else => unreachable,
},
.Struct => inline for (std.meta.fields(@TypeOf(val))) |f| free(alloc, @field(val, f.name)),
.Array => for (val) |elem| free(alloc, elem),
.Optional => if (val) |opt| free(alloc, opt),
.Bool, .Int, .Float, .Enum => {},
else => unreachable,
}
}
threadlocal var prng: std.rand.DefaultPrng = undefined;
pub fn initThreadPrng(seed: u64) void {
prng = std.rand.DefaultPrng.init(seed +% std.Thread.getCurrentId());
}
pub fn getRandom() std.rand.Random {
return prng.random();
}
pub const ApiSource = struct {
db: db.Database,
internal_alloc: std.mem.Allocator,
config: Config,
pub const Conn = ApiConn(db.Database);
const root_username = "root";
pub fn init(alloc: std.mem.Allocator, cfg: Config, root_password: ?[]const u8) !ApiSource {
var self = ApiSource{
.db = try db.Database.init(cfg.db.sqlite.db_file),
.internal_alloc = alloc,
.config = cfg,
};
if ((try services.users.lookupByUsername(&self.db, root_username, null)) == null) {
std.log.info("No cluster root user detected. Creating...", .{});
// TODO: Fix this
const password = root_password orelse return error.NeedRootPassword;
std.debug.print("\npassword: {s}\n", .{password});
var arena = std.heap.ArenaAllocator.init(alloc);
defer arena.deinit();
const user_id = try services.users.create(&self.db, root_username, password, null, .{}, arena.allocator());
std.debug.print("Created {s} ID {}", .{ root_username, user_id });
}
return self;
}
fn getCommunityFromHost(self: *ApiSource, host: []const u8) !?Uuid {
if (try self.db.execRow(
&.{Uuid},
"SELECT id FROM community WHERE host = ?",
.{host},
null,
)) |result| return result[0];
// Test for cluster admin community
if (util.ciutf8.eql(self.config.cluster_host, host)) {
return null;
}
return error.NoCommunity;
}
pub fn connectUnauthorized(self: *ApiSource, host: []const u8, alloc: std.mem.Allocator) !Conn {
const community_id = try self.getCommunityFromHost(host);
return Conn{
.db = self.db,
.internal_alloc = self.internal_alloc,
.user_id = null,
.community_id = community_id,
.arena = std.heap.ArenaAllocator.init(alloc),
};
}
pub fn connectToken(self: *ApiSource, host: []const u8, token: []const u8, alloc: std.mem.Allocator) !Conn {
const community_id = try self.getCommunityFromHost(host);
const token_info = try services.auth.tokens.verify(&self.db, token, community_id);
return Conn{
.db = self.db,
.internal_alloc = self.internal_alloc,
.user_id = token_info.user_id,
.community_id = community_id,
.arena = std.heap.ArenaAllocator.init(alloc),
};
}
};
fn ApiConn(comptime DbConn: type) type {
return struct {
const Self = @This();
db: DbConn,
internal_alloc: std.mem.Allocator, // used *only* for large, internal buffers
user_id: ?Uuid,
community_id: ?Uuid,
arena: std.heap.ArenaAllocator,
pub fn close(self: *Self) void {
self.arena.deinit();
}
fn isAdmin(self: *Self) bool {
// TODO
return self.user_id != null and self.community_id == null;
}
pub fn login(self: *Self, username: []const u8, password: []const u8) !LoginResponse {
const user_id = (try services.users.lookupByUsername(&self.db, username, self.community_id)) orelse return error.InvalidLogin;
try services.auth.passwords.verify(&self.db, user_id, password, self.internal_alloc);
const token = try services.auth.tokens.create(&self.db, user_id);
return LoginResponse{
.user_id = user_id,
.token = token.value,
.issued_at = token.info.issued_at,
};
}
const TokenInfo = struct {
username: []const u8,
};
pub fn getTokenInfo(self: *Self) !TokenInfo {
if (self.user_id) |user_id| {
const result = (try self.db.execRow(
&.{[]const u8},
"SELECT username FROM user WHERE id = ?",
.{user_id},
self.arena.allocator(),
)) orelse {
return error.UserNotFound;
};
return TokenInfo{ .username = result[0] };
}
return error.Unauthorized;
}
pub fn createCommunity(self: *Self, origin: []const u8) !services.communities.Community {
if (!self.isAdmin()) {
return error.PermissionDenied;
}
return services.communities.create(&self.db, origin, null);
}
pub fn createInvite(self: *Self, options: InviteRequest) !services.invites.Invite {
// Only logged in users can make invites
const user_id = self.user_id orelse return error.TokenRequired;
const community_id = if (options.to_community) |host| blk: {
// You can only specify a different community if you're on the admin domain
if (self.community_id != null) return error.WrongCommunity;
// Only admins can invite on the admin domain
if (!self.isAdmin()) return error.PermissionDenied;
break :blk (try services.communities.getByHost(&self.db, host, self.arena.allocator())).id;
} else self.community_id;
// Users can only make user invites
if (options.invite_type != .user and !self.isAdmin()) return error.PermissionDenied;
return try services.invites.create(&self.db, user_id, community_id, .{
.name = options.name,
.expires_at = options.expires_at,
.max_uses = options.max_uses,
.invite_type = options.invite_type,
}, self.arena.allocator());
}
pub fn register(self: *Self, request: RegistrationRequest) !UserResponse {
std.log.debug("registering user {s} with code {s}", .{ request.username, request.invite_code });
const invite = try services.invites.getByCode(&self.db, request.invite_code, self.arena.allocator());
if (!Uuid.eql(invite.to_community, self.community_id)) return error.NotFound;
if (invite.max_uses != null and invite.times_used >= invite.max_uses.?) return error.InviteExpired;
if (invite.expires_at != null and DateTime.now().isAfter(invite.expires_at.?)) return error.InviteExpired;
if (self.community_id == null) @panic("Unimplmented");
const user_id = try services.users.create(&self.db, request.username, request.password, self.community_id, .{ .invite_id = invite.id, .email = request.email }, self.internal_alloc);
switch (invite.invite_type) {
.user => {},
.system => @panic("System user invites unimplemented"),
.community_owner => {
try services.communities.transferOwnership(&self.db, self.community_id.?, user_id);
},
}
return self.getUser(user_id) catch |err| switch (err) {
error.NotFound => error.Unexpected,
else => err,
};
}
pub fn getUser(self: *Self, user_id: Uuid) !UserResponse {
const user = try services.users.get(&self.db, user_id, self.arena.allocator());
if (self.user_id == null) {
if (!Uuid.eql(self.community_id, user.community_id)) return error.NotFound;
}
return UserResponse{
.id = user.id,
.username = user.username,
.host = user.host,
.created_at = user.created_at,
};
}
pub fn createNote(self: *Self, content: []const u8) !NoteResponse {
if (self.community_id == null) return error.WrongCommunity;
const user_id = self.user_id orelse return error.TokenRequired;
const note_id = try services.notes.create(&self.db, user_id, content);
return self.getNote(note_id) catch |err| switch (err) {
error.NotFound => error.Unexpected,
else => err,
};
}
pub fn getNote(self: *Self, note_id: Uuid) !NoteResponse {
const note = try services.notes.get(&self.db, note_id, self.arena.allocator());
const user = try services.users.get(&self.db, note.author_id, self.arena.allocator());
// Only serve community-specific notes on unauthenticated requests
if (self.user_id == null) {
if (!Uuid.eql(self.community_id, user.community_id)) return error.NotFound;
}
return NoteResponse{
.id = note.id,
.author = .{
.id = user.id,
.username = user.username,
.host = user.host,
},
.content = note.content,
.created_at = note.created_at,
};
}
};
}