fediglam/src/api/lib.zig

351 lines
11 KiB
Zig

const std = @import("std");
const util = @import("util");
const sql = @import("sql");
const DateTime = util.DateTime;
const Uuid = util.Uuid;
const services = struct {
const communities = @import("./services/communities.zig");
const actors = @import("./services/actors.zig");
const auth = @import("./services/auth.zig");
const invites = @import("./services/invites.zig");
const notes = @import("./services/notes.zig");
};
pub const RegistrationOptions = struct {
invite_code: ?[]const u8 = null,
email: ?[]const u8 = null,
};
pub const InviteOptions = struct {
pub const Kind = services.invites.Kind;
name: ?[]const u8 = null,
lifespan: ?DateTime.Duration = null,
max_uses: ?u16 = null,
// admin only options
kind: Kind = .user,
to_community: ?Uuid = null,
};
pub const LoginResponse = services.auth.LoginResult;
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,
};
pub const Community = services.communities.Community;
pub const CommunityQueryArgs = services.communities.QueryArgs;
pub const CommunityQueryResult = services.communities.QueryResult;
pub fn isAdminSetup(db: sql.Db) !bool {
_ = services.communities.adminCommunityId(db) catch |err| switch (err) {
error.NotFound => return false,
else => return err,
};
return true;
}
pub fn setupAdmin(db: sql.Db, origin: []const u8, username: []const u8, password: []const u8, allocator: std.mem.Allocator) anyerror!void {
const tx = try db.begin();
errdefer tx.rollback();
var arena = std.heap.ArenaAllocator.init(allocator);
defer arena.deinit();
try tx.setConstraintMode(.deferred);
const community_id = try services.communities.create(
tx,
origin,
.{ .name = "Cluster Admin", .kind = .admin },
arena.allocator(),
);
const user = try services.auth.register(tx, username, password, community_id, .{ .kind = .admin }, arena.allocator());
try services.communities.transferOwnership(tx, community_id, user);
try tx.commit();
std.log.info(
"Created admin user {s} (id {}) with cluster admin origin {s} (id {})",
.{ username, user, origin, community_id },
);
}
pub const ApiSource = struct {
db_conn_pool: *sql.ConnPool,
pub const Conn = ApiConn(sql.Db);
const root_username = "root";
pub fn init(pool: *sql.ConnPool) !ApiSource {
return ApiSource{
.db_conn_pool = pool,
};
}
pub fn connectUnauthorized(self: *ApiSource, host: []const u8, alloc: std.mem.Allocator) !Conn {
var arena = std.heap.ArenaAllocator.init(alloc);
errdefer arena.deinit();
const db = try self.db_conn_pool.acquire();
const community = try services.communities.getByHost(db, host, arena.allocator());
return Conn{
.db = db,
.user_id = null,
.community = community,
.arena = arena,
};
}
pub fn connectToken(self: *ApiSource, host: []const u8, token: []const u8, alloc: std.mem.Allocator) !Conn {
var arena = std.heap.ArenaAllocator.init(alloc);
errdefer arena.deinit();
const db = try self.db_conn_pool.acquire();
const community = try services.communities.getByHost(db, host, arena.allocator());
const token_info = try services.auth.verifyToken(
db,
token,
community.id,
arena.allocator(),
);
return Conn{
.db = db,
.token_info = token_info,
.user_id = token_info.user_id,
.community = community,
.arena = arena,
};
}
};
fn ApiConn(comptime DbConn: type) type {
return struct {
const Self = @This();
db: DbConn,
token_info: ?services.auth.TokenInfo = null,
user_id: ?Uuid = null,
community: services.communities.Community,
arena: std.heap.ArenaAllocator,
pub fn close(self: *Self) void {
self.arena.deinit();
self.db.releaseConnection();
}
fn isAdmin(self: *Self) bool {
// TODO
return self.user_id != null and self.community.kind == .admin;
}
pub fn login(self: *Self, username: []const u8, password: []const u8) !LoginResponse {
return services.auth.login(
self.db,
username,
self.community.id,
password,
self.arena.allocator(),
);
}
pub const AuthorizationInfo = struct {
id: Uuid,
username: []const u8,
community_id: Uuid,
host: []const u8,
issued_at: DateTime,
};
pub fn verifyAuthorization(self: *Self) !AuthorizationInfo {
if (self.token_info) |info| {
const user = try services.actors.get(self.db, info.user_id, self.arena.allocator());
return AuthorizationInfo{
.id = user.id,
.username = user.username,
.community_id = self.community.id,
.host = self.community.host,
.issued_at = info.issued_at,
};
}
return error.TokenRequired;
}
pub fn createCommunity(self: *Self, origin: []const u8) !services.communities.Community {
if (!self.isAdmin()) {
return error.PermissionDenied;
}
const tx = try self.db.begin();
errdefer tx.rollback();
const community_id = try services.communities.create(
tx,
origin,
.{},
self.arena.allocator(),
);
const community = services.communities.get(
tx,
community_id,
self.arena.allocator(),
) catch |err| return switch (err) {
error.NotFound => error.DatabaseError,
else => |err2| err2,
};
try tx.commit();
return community;
}
pub fn createInvite(self: *Self, options: InviteOptions) !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) |id| blk: {
// Only admins can send invites for other communities
if (!self.isAdmin()) return error.PermissionDenied;
break :blk id;
} else self.community.id;
// Users can only make user invites
if (options.kind != .user and !self.isAdmin()) return error.PermissionDenied;
const invite_id = try services.invites.create(self.db, user_id, community_id, .{
.name = options.name,
.lifespan = options.lifespan,
.max_uses = options.max_uses,
.kind = options.kind,
}, self.arena.allocator());
return try services.invites.get(self.db, invite_id, self.arena.allocator());
}
pub fn register(self: *Self, username: []const u8, password: []const u8, opt: RegistrationOptions) !UserResponse {
std.log.debug("registering user {s} with code {?s}", .{ username, opt.invite_code });
const maybe_invite = if (opt.invite_code) |code|
try services.invites.getByCode(self.db, code, self.community.id, self.arena.allocator())
else
null;
if (maybe_invite) |invite| {
if (!Uuid.eql(invite.community_id, self.community.id)) return error.WrongCommunity;
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;
}
const invite_kind = if (maybe_invite) |inv| inv.kind else .user;
if (self.community.kind == .admin) @panic("Unimplmented");
const user_id = try services.auth.register(
self.db,
username,
password,
self.community.id,
.{ .invite_id = if (maybe_invite) |inv| inv.id else null, .email = opt.email },
self.arena.allocator(),
);
switch (invite_kind) {
.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.actors.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 {
// You cannot post on admin accounts
if (self.community.kind == .admin) return error.WrongCommunity;
// Only authenticated users can post
const user_id = self.user_id orelse return error.TokenRequired;
const note_id = try services.notes.create(self.db, user_id, content, self.arena.allocator());
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.actors.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,
};
}
pub fn queryCommunities(self: *Self, args: services.communities.QueryArgs) !CommunityQueryResult {
if (!self.isAdmin()) return error.PermissionDenied;
return try services.communities.query(self.db, args, self.arena.allocator());
}
};
}