init
This commit is contained in:
commit
3560650ac1
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
@ -0,0 +1 @@
|
||||||
|
/zig-*
|
1
assets.zig
Normal file
1
assets.zig
Normal file
|
@ -0,0 +1 @@
|
||||||
|
pub const steve_skin = @embedFile("assets/steve_skin.png");
|
BIN
assets/steve_skin.png
Normal file
BIN
assets/steve_skin.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.1 KiB |
34
build.zig
Normal file
34
build.zig
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub fn build(b: *std.Build) void {
|
||||||
|
const target = b.standardTargetOptions(.{});
|
||||||
|
const optimize = b.standardOptimizeOption(.{});
|
||||||
|
|
||||||
|
const assets_mod = b.addModule("assets", .{
|
||||||
|
.root_source_file = .{ .path = "assets.zig" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const exe = b.addExecutable(.{
|
||||||
|
.name = "anvilauth",
|
||||||
|
.root_source_file = .{ .path = "src/main.zig" },
|
||||||
|
.target = target,
|
||||||
|
.optimize = optimize,
|
||||||
|
.link_libc = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
exe.root_module.addImport("assets", assets_mod);
|
||||||
|
|
||||||
|
exe.linkSystemLibrary("libpq");
|
||||||
|
exe.linkSystemLibrary("openssl");
|
||||||
|
|
||||||
|
b.installArtifact(exe);
|
||||||
|
|
||||||
|
const run_cmd = b.addRunArtifact(exe);
|
||||||
|
run_cmd.step.dependOn(b.getInstallStep());
|
||||||
|
if (b.args) |args| {
|
||||||
|
run_cmd.addArgs(args);
|
||||||
|
}
|
||||||
|
|
||||||
|
const run_step = b.step("run", "Run the app");
|
||||||
|
run_step.dependOn(&run_cmd.step);
|
||||||
|
}
|
7
build.zig.zon
Normal file
7
build.zig.zon
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
.{
|
||||||
|
.name = "anvilauth",
|
||||||
|
.version = "0.0.0",
|
||||||
|
|
||||||
|
.dependencies = .{},
|
||||||
|
.paths = .{""},
|
||||||
|
}
|
14
conf.json
Normal file
14
conf.json
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
{
|
||||||
|
"bind": {
|
||||||
|
"ip": "127.0.0.1",
|
||||||
|
"port": 8080
|
||||||
|
},
|
||||||
|
"base_url": "http://localhost:8081/",
|
||||||
|
"postgres_url": "postgres://anvilauth:alec@localhost:5432/anvilauth",
|
||||||
|
"forgejo_url": "http://localhost:8082/",
|
||||||
|
"skin_domains": [
|
||||||
|
"localhost",
|
||||||
|
".localhost"
|
||||||
|
],
|
||||||
|
"server_name": "AnvilAuth test server"
|
||||||
|
}
|
9
src/Config.zig
Normal file
9
src/Config.zig
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
bind: struct {
|
||||||
|
ip: []const u8,
|
||||||
|
port: u16,
|
||||||
|
},
|
||||||
|
base_url: []const u8,
|
||||||
|
postgres_url: [:0]const u8,
|
||||||
|
forgejo_url: []const u8,
|
||||||
|
skin_domains: []const []const u8,
|
||||||
|
server_name: []const u8,
|
140
src/Db.zig
Normal file
140
src/Db.zig
Normal file
|
@ -0,0 +1,140 @@
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
const ffi = @import("ffi.zig");
|
||||||
|
const c = ffi.c;
|
||||||
|
|
||||||
|
const Id = @import("Id.zig");
|
||||||
|
|
||||||
|
con: *c.PGconn,
|
||||||
|
|
||||||
|
const Db = @This();
|
||||||
|
|
||||||
|
pub fn initDb(self: Db) !void {
|
||||||
|
const query =
|
||||||
|
\\CREATE TABLE IF NOT EXISTS users (
|
||||||
|
\\ id UUID NOT NULL PRIMARY KEY,
|
||||||
|
\\ name VARCHAR NOT NULL UNIQUE
|
||||||
|
\\);
|
||||||
|
\\
|
||||||
|
\\CREATE TABLE IF NOT EXISTS tokens (
|
||||||
|
\\ id UUID NOT NULL PRIMARY KEY,
|
||||||
|
\\ userid UUID NOT NULL REFERENCES users (id),
|
||||||
|
\\ expiry BIGINT NOT NULL,
|
||||||
|
\\ client_token VARCHAR NOT NULL
|
||||||
|
\\);
|
||||||
|
\\
|
||||||
|
\\CREATE TABLE IF NOT EXISTS joins (
|
||||||
|
\\ userid UUID NOT NULL PRIMARY KEY REFERENCES users (id),
|
||||||
|
\\ serverid VARCHAR NOT NULL
|
||||||
|
\\);
|
||||||
|
;
|
||||||
|
|
||||||
|
const status = self.exec(query);
|
||||||
|
defer status.deinit();
|
||||||
|
try status.expectCommand();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn exec(self: Db, query: [:0]const u8) Result {
|
||||||
|
return .{ .res = c.PQexec(self.con, query.ptr) };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn execParams(self: Db, query: [:0]const u8, params: anytype) Result {
|
||||||
|
var args_buf: [1024 * 2]u8 = undefined;
|
||||||
|
var fba = std.heap.FixedBufferAllocator.init(&args_buf);
|
||||||
|
const alloc = fba.allocator();
|
||||||
|
|
||||||
|
const nparams = @typeInfo(@TypeOf(params)).Struct.fields.len;
|
||||||
|
|
||||||
|
const vals = alloc.alloc([*]const u8, nparams) catch return Result.oom;
|
||||||
|
const lengths = alloc.alloc(c_int, nparams) catch return Result.oom;
|
||||||
|
const formats = alloc.alloc(c_int, nparams) catch return Result.oom;
|
||||||
|
|
||||||
|
inline for (params, vals, lengths, formats) |param, *val, *len, *format| {
|
||||||
|
switch (@TypeOf(param)) {
|
||||||
|
[]const u8, [:0]const u8 => {
|
||||||
|
val.* = param.ptr;
|
||||||
|
len.* = @intCast(param.len);
|
||||||
|
format.* = 1;
|
||||||
|
},
|
||||||
|
[*:0]const u8 => {
|
||||||
|
val.* = param;
|
||||||
|
len.* = -1;
|
||||||
|
format.* = 0;
|
||||||
|
},
|
||||||
|
i64 => {
|
||||||
|
const bytes = std.mem.asBytes(&std.mem.nativeToBig(i64, param));
|
||||||
|
val.* = bytes.ptr;
|
||||||
|
len.* = @intCast(bytes.len);
|
||||||
|
format.* = 1;
|
||||||
|
},
|
||||||
|
Id => {
|
||||||
|
val.* = ¶m.bytes;
|
||||||
|
len.* = param.bytes.len;
|
||||||
|
format.* = 1;
|
||||||
|
},
|
||||||
|
else => @compileError("unsupported parameter type: " ++ @typeName(@TypeOf(param))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ret = c.PQexecParams(
|
||||||
|
self.con,
|
||||||
|
query,
|
||||||
|
nparams,
|
||||||
|
null,
|
||||||
|
vals.ptr,
|
||||||
|
lengths.ptr,
|
||||||
|
formats.ptr,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
return .{ .res = ret };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const Result = struct {
|
||||||
|
pub const oom = Result{ .res = null };
|
||||||
|
|
||||||
|
res: ?*c.PGresult,
|
||||||
|
|
||||||
|
pub inline fn deinit(self: Result) void {
|
||||||
|
if (self.res) |res| c.PQclear(res);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expect(self: Result, expected: c_uint) !void {
|
||||||
|
const actual = c.PQresultStatus(self.res); // safe to call with null
|
||||||
|
if (actual != expected) {
|
||||||
|
std.log.err("expected result `{s}`, got `{s}` (msg: `{s}`)", .{
|
||||||
|
c.PQresStatus(expected),
|
||||||
|
c.PQresStatus(actual),
|
||||||
|
@as([*:0]const u8, c.PQresultErrorField(self.res, c.PG_DIAG_MESSAGE_PRIMARY) orelse "<not present>"),
|
||||||
|
});
|
||||||
|
return error.UnexpectedSqlStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub inline fn expectTuples(self: Result) !void {
|
||||||
|
try self.expect(c.PGRES_TUPLES_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub inline fn expectCommand(self: Result) !void {
|
||||||
|
try self.expect(c.PGRES_COMMAND_OK);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub inline fn rows(self: Result) c_int {
|
||||||
|
return c.PQntuples(self.res);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub inline fn cols(self: Result) c_int {
|
||||||
|
return c.PQnfields(self.res);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn getOptional(self: Result, comptime T: type, row: c_int, col: c_int) ?T {
|
||||||
|
return if (c.PQgetisnull(self.res, row, col) == 1) null else self.get(T, row, col);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub inline fn get(self: Result, comptime T: type, row: c_int, col: c_int) T {
|
||||||
|
return switch (T) {
|
||||||
|
[]const u8 => c.PQgetvalue(self.res, row, col)[0..@intCast(c.PQgetlength(self.res, row, col))],
|
||||||
|
Id => .{ .bytes = c.PQgetvalue(self.res, row, col)[0..16].* },
|
||||||
|
else => @compileError("unsuppored type: " ++ @typeName(T)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
30
src/Id.zig
Normal file
30
src/Id.zig
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
/// This file implements something similar to UUIDs without the bullshit.
|
||||||
|
/// It implements a simple even U-er UID, which is simply 16 random bytes.
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
bytes: [16]u8,
|
||||||
|
|
||||||
|
const Id = @This();
|
||||||
|
|
||||||
|
/// Returns a hex encoded version of the ID.
|
||||||
|
pub fn toString(self: Id) [32]u8 {
|
||||||
|
var ret: [32]u8 = undefined;
|
||||||
|
_ = std.fmt.bufPrint(&ret, "{}", .{std.fmt.fmtSliceHexLower(&self.bytes)}) catch unreachable;
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn genRandom(rand: std.rand.Random) Id {
|
||||||
|
var bytes: [16]u8 = undefined;
|
||||||
|
rand.bytes(&bytes);
|
||||||
|
return .{ .bytes = bytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parses the given string as an ID, or returns null if it is invalid.
|
||||||
|
pub fn parse(str: []const u8) ?Id {
|
||||||
|
if (str.len != 32) return null;
|
||||||
|
var bytes: [16]u8 = undefined;
|
||||||
|
for (&bytes, 0..) |*out, ihalf| {
|
||||||
|
out.* = std.fmt.parseInt(u8, str[ihalf * 2 ..][0..2], 16) catch return null;
|
||||||
|
}
|
||||||
|
return .{ .bytes = bytes };
|
||||||
|
}
|
93
src/JsonUserProfile.zig
Normal file
93
src/JsonUserProfile.zig
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
// TODO: implement JSON serializer instead of this jank
|
||||||
|
const std = @import("std");
|
||||||
|
const c = ffi.c;
|
||||||
|
|
||||||
|
const ffi = @import("ffi.zig");
|
||||||
|
|
||||||
|
const Id = @import("Id.zig");
|
||||||
|
|
||||||
|
pub const Property = struct {
|
||||||
|
name: []const u8,
|
||||||
|
value: []const u8,
|
||||||
|
signature: ?[]const u8 = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
id: []const u8,
|
||||||
|
name: []const u8,
|
||||||
|
properties: [1]Property,
|
||||||
|
|
||||||
|
const JsonUserProfile = @This();
|
||||||
|
|
||||||
|
/// id and skin_url are copied, name is only copied for properties!
|
||||||
|
pub fn init(
|
||||||
|
alloc: std.mem.Allocator,
|
||||||
|
id: Id,
|
||||||
|
name: []const u8,
|
||||||
|
skin_url: []const u8,
|
||||||
|
rsa: ?*c.RSA,
|
||||||
|
) !JsonUserProfile {
|
||||||
|
const id_s = try alloc.dupe(u8, &id.toString());
|
||||||
|
errdefer alloc.free(id_s);
|
||||||
|
|
||||||
|
const textures_value = .{
|
||||||
|
.timestamp = std.time.timestamp(),
|
||||||
|
.profileId = id_s,
|
||||||
|
.profileName = name,
|
||||||
|
.textures = .{
|
||||||
|
.SKIN = .{
|
||||||
|
.url = skin_url,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const textures_json = try std.json.stringifyAlloc(alloc, textures_value, .{});
|
||||||
|
defer alloc.free(textures_json);
|
||||||
|
|
||||||
|
const textures_b64 = try alloc.alloc(u8, std.base64.standard.Encoder.calcSize(textures_json.len));
|
||||||
|
errdefer alloc.free(textures_b64);
|
||||||
|
std.debug.assert(std.base64.standard.Encoder.encode(textures_b64, textures_json).len == textures_b64.len);
|
||||||
|
|
||||||
|
var textures_prop = Property{
|
||||||
|
.name = "textures",
|
||||||
|
.value = textures_b64,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (rsa) |r| {
|
||||||
|
var hasher = std.crypto.hash.Sha1.init(.{});
|
||||||
|
hasher.update(textures_b64);
|
||||||
|
|
||||||
|
const retbuf = try alloc.alloc(u8, @intCast(c.RSA_size(r)));
|
||||||
|
defer alloc.free(retbuf);
|
||||||
|
|
||||||
|
var retlen: c_uint = 0;
|
||||||
|
if (c.RSA_sign(
|
||||||
|
c.NID_sha1,
|
||||||
|
&hasher.finalResult(),
|
||||||
|
std.crypto.hash.Sha1.digest_length,
|
||||||
|
retbuf.ptr,
|
||||||
|
&retlen,
|
||||||
|
r,
|
||||||
|
) != 1) return error.OpenSSLBorked;
|
||||||
|
|
||||||
|
const signature = retbuf[0..retlen];
|
||||||
|
|
||||||
|
const sig_b64 = try alloc.alloc(u8, std.base64.standard.Encoder.calcSize(signature.len));
|
||||||
|
errdefer alloc.free(sig_b64);
|
||||||
|
std.debug.assert(std.base64.standard.Encoder.encode(sig_b64, signature).len == sig_b64.len);
|
||||||
|
|
||||||
|
textures_prop.signature = sig_b64;
|
||||||
|
}
|
||||||
|
|
||||||
|
return .{
|
||||||
|
.id = id_s,
|
||||||
|
.name = name,
|
||||||
|
.properties = .{textures_prop},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: JsonUserProfile, alloc: std.mem.Allocator) void {
|
||||||
|
alloc.free(self.id);
|
||||||
|
for (self.properties) |prop| {
|
||||||
|
alloc.free(prop.value);
|
||||||
|
if (prop.signature) |sig| alloc.free(sig);
|
||||||
|
}
|
||||||
|
}
|
63
src/State.zig
Normal file
63
src/State.zig
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const c = @import("ffi.zig").c;
|
||||||
|
|
||||||
|
const Db = @import("Db.zig");
|
||||||
|
|
||||||
|
pub const SkinCache = std.StringHashMapUnmanaged(struct { has_skin: bool, expiration: i64 });
|
||||||
|
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
base_url: []const u8,
|
||||||
|
forgejo_url: []const u8,
|
||||||
|
skin_domains: []const []const u8,
|
||||||
|
server_name: []const u8,
|
||||||
|
http: std.http.Client,
|
||||||
|
db: Db,
|
||||||
|
rand: std.rand.Random,
|
||||||
|
rsa: *c.RSA,
|
||||||
|
x509: *c.X509,
|
||||||
|
default_skin_url: []const u8,
|
||||||
|
skin_cache: SkinCache,
|
||||||
|
skin_cache_mtx: std.Thread.Mutex = .{},
|
||||||
|
|
||||||
|
const State = @This();
|
||||||
|
|
||||||
|
/// Gets the skin URL for a given user, if the user has a skin URL set. May do network IO for checking.
|
||||||
|
pub fn getSkinUrl(self: *State, username: []const u8) !?[]const u8 {
|
||||||
|
self.skin_cache_mtx.lock();
|
||||||
|
defer self.skin_cache_mtx.unlock();
|
||||||
|
|
||||||
|
const url = try std.fmt.allocPrint(
|
||||||
|
self.allocator,
|
||||||
|
"{s}/{s}/.anvilauth/raw/branch/master/skin.png",
|
||||||
|
.{ self.forgejo_url, username },
|
||||||
|
);
|
||||||
|
errdefer self.allocator.free(url);
|
||||||
|
|
||||||
|
if (self.skin_cache.get(username)) |entry| {
|
||||||
|
if (std.time.milliTimestamp() < entry.expiration) {
|
||||||
|
if (entry.has_skin)
|
||||||
|
return url;
|
||||||
|
|
||||||
|
self.allocator.free(url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = try self.http.fetch(.{
|
||||||
|
.method = .HEAD,
|
||||||
|
.location = .{ .url = url },
|
||||||
|
});
|
||||||
|
|
||||||
|
const username_d = try self.allocator.dupe(u8, username);
|
||||||
|
errdefer self.allocator.free(username_d);
|
||||||
|
try self.skin_cache.put(self.allocator, username_d, .{
|
||||||
|
.has_skin = res.status == .ok,
|
||||||
|
.expiration = std.time.milliTimestamp() + std.time.ms_per_day,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status == .ok)
|
||||||
|
return url;
|
||||||
|
|
||||||
|
self.allocator.free(url);
|
||||||
|
return null;
|
||||||
|
}
|
92
src/conutil.zig
Normal file
92
src/conutil.zig
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub fn sendJsonError(
|
||||||
|
req: *std.http.Server.Request,
|
||||||
|
code: std.http.Status,
|
||||||
|
comptime fmt: []const u8,
|
||||||
|
args: anytype,
|
||||||
|
) !void {
|
||||||
|
const JsonError = struct {
|
||||||
|
@"error": []const u8,
|
||||||
|
errorMessage: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
var fmt_buf: [1024 * 4]u8 = undefined;
|
||||||
|
const json_error = JsonError{
|
||||||
|
.@"error" = code.phrase() orelse "Unknown Error",
|
||||||
|
.errorMessage = try std.fmt.bufPrint(&fmt_buf, "[AnvilAuth] " ++ fmt, args),
|
||||||
|
};
|
||||||
|
|
||||||
|
var ser_buf: [1024 * 4]u8 = undefined;
|
||||||
|
var ser_fbs = std.io.fixedBufferStream(&ser_buf);
|
||||||
|
try std.json.stringify(json_error, .{}, ser_fbs.writer());
|
||||||
|
|
||||||
|
try req.respond(ser_fbs.getWritten(), .{
|
||||||
|
.status = code,
|
||||||
|
.extra_headers = &.{.{
|
||||||
|
.name = "Content-Type",
|
||||||
|
.value = "application/json",
|
||||||
|
}},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const QueryParameterError = error{ MissingParameter, InvalidParameters };
|
||||||
|
|
||||||
|
pub fn parseQueryParametersFromUri(uri: std.Uri, comptime T: type) QueryParameterError!T {
|
||||||
|
return if (uri.query) |q| try parseQueryParameters(q, T) else error.MissingParameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parseQueryParameters(params: []const u8, comptime T: type) QueryParameterError!T {
|
||||||
|
const DefaultedT = comptime blk: {
|
||||||
|
const info = @typeInfo(T);
|
||||||
|
var opt_fields: [info.Struct.fields.len]std.builtin.Type.StructField = undefined;
|
||||||
|
|
||||||
|
for (&opt_fields, info.Struct.fields) |*ofield, field| {
|
||||||
|
ofield.* = .{
|
||||||
|
.name = field.name,
|
||||||
|
.type = ?field.type,
|
||||||
|
.default_value = @as(*const ?field.type, &null),
|
||||||
|
.is_comptime = false,
|
||||||
|
.alignment = 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
break :blk @Type(.{ .Struct = .{
|
||||||
|
.layout = .Auto,
|
||||||
|
.fields = &opt_fields,
|
||||||
|
.decls = &.{},
|
||||||
|
.is_tuple = false,
|
||||||
|
} });
|
||||||
|
};
|
||||||
|
|
||||||
|
const def_out = try parseQueryParametersOrDefaults(params, DefaultedT);
|
||||||
|
|
||||||
|
var out: T = undefined;
|
||||||
|
inline for (comptime std.meta.fieldNames(T)) |fname| {
|
||||||
|
@field(out, fname) = @field(def_out, fname) orelse return error.MissingParameter;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parseQueryParametersOrDefaults(params: []const u8, comptime T: type) QueryParameterError!T {
|
||||||
|
var out: T = .{};
|
||||||
|
|
||||||
|
var iter = std.mem.splitScalar(u8, params, '&');
|
||||||
|
while (iter.next()) |param| {
|
||||||
|
var psplit = std.mem.splitScalar(u8, param, '=');
|
||||||
|
const key = psplit.next() orelse return error.InvalidParameters;
|
||||||
|
const value = psplit.next() orelse return error.InvalidParameters;
|
||||||
|
if (psplit.next()) |_| return error.InvalidParameters;
|
||||||
|
|
||||||
|
inline for (comptime std.meta.fieldNames(T)) |fname| {
|
||||||
|
if (std.mem.eql(u8, key, fname)) {
|
||||||
|
@field(out, fname) = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
32
src/ffi.zig
Normal file
32
src/ffi.zig
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
pub const c = @cImport({
|
||||||
|
@cInclude("libpq-fe.h");
|
||||||
|
@cInclude("openssl/rsa.h");
|
||||||
|
@cInclude("openssl/bio.h");
|
||||||
|
@cInclude("openssl/pem.h");
|
||||||
|
@cInclude("openssl/x509.h");
|
||||||
|
});
|
||||||
|
|
||||||
|
const libpq_log = std.log.scoped(.libpq);
|
||||||
|
|
||||||
|
pub fn libpqNoticeReceiverCb(_: ?*anyopaque, res: ?*const c.PGresult) callconv(.C) void {
|
||||||
|
const severity_str: [*:0]const u8 = c.PQresultErrorField(res, c.PG_DIAG_SEVERITY_NONLOCALIZED) orelse
|
||||||
|
c.PQresultErrorField(res, c.PG_DIAG_SEVERITY) orelse
|
||||||
|
"LOG";
|
||||||
|
|
||||||
|
const msg = c.PQresultErrorField(res, c.PG_DIAG_MESSAGE_PRIMARY);
|
||||||
|
const sev = std.mem.span(severity_str);
|
||||||
|
if (std.mem.eql(u8, sev, "ERROR") or
|
||||||
|
std.mem.eql(u8, sev, "FATAL") or
|
||||||
|
std.mem.eql(u8, sev, "PANIC"))
|
||||||
|
{
|
||||||
|
libpq_log.err("{s}", .{msg});
|
||||||
|
} else if (std.mem.eql(u8, sev, "WARNING")) {
|
||||||
|
libpq_log.warn("{s}", .{msg});
|
||||||
|
} else if (std.mem.eql(u8, sev, "DEBUG")) {
|
||||||
|
libpq_log.debug("{s}", .{msg});
|
||||||
|
} else {
|
||||||
|
libpq_log.info("{s}", .{msg});
|
||||||
|
}
|
||||||
|
}
|
207
src/main.zig
Normal file
207
src/main.zig
Normal file
|
@ -0,0 +1,207 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const c = ffi.c;
|
||||||
|
|
||||||
|
const ffi = @import("ffi.zig");
|
||||||
|
|
||||||
|
const Config = @import("Config.zig");
|
||||||
|
const Db = @import("Db.zig");
|
||||||
|
const State = @import("State.zig");
|
||||||
|
|
||||||
|
pub const std_options = std.Options{
|
||||||
|
.log_level = if (@import("builtin").mode == .Debug) .debug else .info,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn main() !u8 {
|
||||||
|
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
|
||||||
|
defer _ = gpa.deinit();
|
||||||
|
const alloc = gpa.allocator();
|
||||||
|
|
||||||
|
const confpath = conf: {
|
||||||
|
var argiter = try std.process.argsWithAllocator(alloc);
|
||||||
|
defer argiter.deinit();
|
||||||
|
_ = argiter.next() orelse unreachable;
|
||||||
|
|
||||||
|
const confpath = argiter.next() orelse {
|
||||||
|
std.log.err("Need a config argument!", .{});
|
||||||
|
return error.InvalidArgs;
|
||||||
|
};
|
||||||
|
const confpath_d = try alloc.dupe(u8, confpath);
|
||||||
|
errdefer alloc.free(confpath_d);
|
||||||
|
|
||||||
|
if (argiter.next()) |_| {
|
||||||
|
std.log.err("Too many arguments!", .{});
|
||||||
|
return error.InvalidArgs;
|
||||||
|
}
|
||||||
|
|
||||||
|
break :conf confpath_d;
|
||||||
|
};
|
||||||
|
defer alloc.free(confpath);
|
||||||
|
|
||||||
|
var conffile = try std.fs.cwd().openFile(confpath, .{});
|
||||||
|
defer conffile.close();
|
||||||
|
|
||||||
|
var json_reader = std.json.reader(alloc, conffile.reader());
|
||||||
|
defer json_reader.deinit();
|
||||||
|
|
||||||
|
const config_parsed = try std.json.parseFromTokenSource(
|
||||||
|
Config,
|
||||||
|
alloc,
|
||||||
|
&json_reader,
|
||||||
|
.{ .ignore_unknown_fields = true },
|
||||||
|
);
|
||||||
|
defer config_parsed.deinit();
|
||||||
|
|
||||||
|
const postgres_con = c.PQconnectdb(config_parsed.value.postgres_url) orelse unreachable;
|
||||||
|
defer c.PQfinish(postgres_con);
|
||||||
|
|
||||||
|
if (c.PQstatus(postgres_con) != c.CONNECTION_OK) {
|
||||||
|
std.log.err("connecting to DB: {s}", .{c.PQerrorMessage(postgres_con)});
|
||||||
|
return error.DatabaseConnect;
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = c.PQsetNoticeReceiver(postgres_con, ffi.libpqNoticeReceiverCb, null);
|
||||||
|
|
||||||
|
const db = Db{ .con = postgres_con };
|
||||||
|
|
||||||
|
std.log.info("initializing database", .{});
|
||||||
|
try db.initDb();
|
||||||
|
|
||||||
|
var rand = rand: {
|
||||||
|
var seed: [32]u8 = undefined;
|
||||||
|
std.crypto.random.bytes(&seed);
|
||||||
|
break :rand std.rand.DefaultCsprng.init(seed);
|
||||||
|
};
|
||||||
|
|
||||||
|
const rsa = c.RSA_new() orelse return error.OutOfMemory;
|
||||||
|
{
|
||||||
|
errdefer c.RSA_free(rsa);
|
||||||
|
|
||||||
|
const bn = c.BN_new() orelse return error.OutOfMemory;
|
||||||
|
defer c.BN_free(bn);
|
||||||
|
|
||||||
|
if (c.BN_set_word(bn, c.RSA_F4) != 1) return error.OpenSSLBorked;
|
||||||
|
if (c.RSA_generate_key_ex(rsa, 1024 * 4, bn, null) != 1) return error.OpenSSLBorked;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pkey = c.EVP_PKEY_new() orelse return error.OutOfMemory;
|
||||||
|
defer c.EVP_PKEY_free(pkey);
|
||||||
|
|
||||||
|
if (c.EVP_PKEY_assign_RSA(pkey, rsa) != 1) return error.OpenSSLBorked;
|
||||||
|
|
||||||
|
const x509 = c.X509_new() orelse return error.OutOfMemory;
|
||||||
|
defer c.X509_free(x509);
|
||||||
|
|
||||||
|
{
|
||||||
|
if (c.ASN1_INTEGER_set(c.X509_get_serialNumber(x509), 1) != 1) return error.OpenSSLBorked;
|
||||||
|
_ = c.X509_gmtime_adj(c.X509_get_notBefore(x509), 0);
|
||||||
|
_ = c.X509_gmtime_adj(c.X509_get_notAfter(x509), std.time.s_per_day * 365);
|
||||||
|
if (c.X509_set_pubkey(x509, pkey) != 1) return error.OpenSSLBorked;
|
||||||
|
|
||||||
|
const subjname = c.X509_get_subject_name(x509);
|
||||||
|
if (c.X509_set_issuer_name(x509, subjname) != 1) return error.OpenSSLBorked;
|
||||||
|
if (c.X509_sign(x509, pkey, c.EVP_sha1()) == 0) return error.OpenSSLBorked;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base_url = std.mem.trimRight(u8, config_parsed.value.base_url, "/");
|
||||||
|
const default_skin_url = try std.fmt.allocPrint(alloc, "{s}/default_skin", .{base_url});
|
||||||
|
defer alloc.free(default_skin_url);
|
||||||
|
|
||||||
|
var state = State{
|
||||||
|
.allocator = alloc,
|
||||||
|
.base_url = base_url,
|
||||||
|
.forgejo_url = std.mem.trimRight(u8, config_parsed.value.forgejo_url, "/"),
|
||||||
|
.skin_domains = config_parsed.value.skin_domains,
|
||||||
|
.server_name = config_parsed.value.server_name,
|
||||||
|
.http = .{ .allocator = alloc },
|
||||||
|
.db = .{ .con = postgres_con },
|
||||||
|
.rand = rand.random(),
|
||||||
|
.rsa = rsa,
|
||||||
|
.x509 = x509,
|
||||||
|
.default_skin_url = default_skin_url,
|
||||||
|
.skin_cache = State.SkinCache{},
|
||||||
|
};
|
||||||
|
defer state.http.deinit();
|
||||||
|
defer {
|
||||||
|
var kiter = state.skin_cache.keyIterator();
|
||||||
|
while (kiter.next()) |key| {
|
||||||
|
alloc.free(key.*);
|
||||||
|
}
|
||||||
|
state.skin_cache.deinit(alloc);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addr = try std.net.Address.parseIp(config_parsed.value.bind.ip, config_parsed.value.bind.port);
|
||||||
|
var server = try addr.listen(.{});
|
||||||
|
std.log.info("listening on {}", .{addr});
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
var con = try server.accept();
|
||||||
|
errdefer con.stream.close();
|
||||||
|
|
||||||
|
const thread = try std.Thread.spawn(.{}, handleConnection, .{ con, &state });
|
||||||
|
thread.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handleConnection(con: std.net.Server.Connection, state: *State) void {
|
||||||
|
var read_buf: [1024 * 4]u8 = undefined;
|
||||||
|
const http = std.http.Server.init(con, &read_buf);
|
||||||
|
tryHandleConnection(http, state) catch |e| {
|
||||||
|
std.log.warn("error in connection handler: {}", .{e});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tryHandleConnection(srv_: std.http.Server, state: *State) !void {
|
||||||
|
var srv = srv_;
|
||||||
|
defer srv.connection.stream.close();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
var req = srv.receiveHead() catch |e| switch (e) {
|
||||||
|
error.HttpConnectionClosing => return,
|
||||||
|
else => return e,
|
||||||
|
};
|
||||||
|
|
||||||
|
const path = req.head.target;
|
||||||
|
|
||||||
|
std.log.info("{s} {s} from {}", .{
|
||||||
|
@tagName(req.head.method),
|
||||||
|
path,
|
||||||
|
srv.connection.address,
|
||||||
|
});
|
||||||
|
|
||||||
|
inline for (.{
|
||||||
|
@import("routes/root.zig"),
|
||||||
|
@import("routes/aliapi/index.zig"),
|
||||||
|
@import("routes/aliapi/api/profiles/minecraft.zig"),
|
||||||
|
@import("routes/aliapi/authserver/authenticate.zig"),
|
||||||
|
@import("routes/aliapi/sessionserver/session/minecraft/has_joined.zig"),
|
||||||
|
@import("routes/aliapi/sessionserver/session/minecraft/join.zig"),
|
||||||
|
@import("routes/aliapi/sessionserver/session/minecraft/profile.zig"),
|
||||||
|
@import("routes/default_skin.zig"),
|
||||||
|
}) |route| {
|
||||||
|
if (route.matches(path)) {
|
||||||
|
route.call(&req, state) catch |e| {
|
||||||
|
//if (res.state == .waited) {
|
||||||
|
// try @import("conutil.zig").sendJsonError(
|
||||||
|
// &res,
|
||||||
|
// .internal_server_error,
|
||||||
|
// "alec",
|
||||||
|
// .{},
|
||||||
|
// );
|
||||||
|
//}
|
||||||
|
return e;
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try req.respond("", .{ .status = .not_found });
|
||||||
|
//res.status = .not_found;
|
||||||
|
//res.transfer_encoding = .{ .content_length = 0 };
|
||||||
|
//try res.send();
|
||||||
|
//try res.finish();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (srv.state == .closing) break;
|
||||||
|
}
|
||||||
|
}
|
93
src/routes/aliapi/api/profiles/minecraft.zig
Normal file
93
src/routes/aliapi/api/profiles/minecraft.zig
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
const conutil = @import("../../../../conutil.zig");
|
||||||
|
|
||||||
|
const Id = @import("../../../../Id.zig");
|
||||||
|
const State = @import("../../../../State.zig");
|
||||||
|
|
||||||
|
pub fn matches(path: []const u8) bool {
|
||||||
|
return std.mem.eql(u8, path, "/aliapi/api/profiles/minecraft");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call(req: *std.http.Server.Request, state: *State) !void {
|
||||||
|
if (req.head.method != .POST) {
|
||||||
|
try conutil.sendJsonError(
|
||||||
|
req,
|
||||||
|
.method_not_allowed,
|
||||||
|
"only POST requests are allowed to this endpoint!",
|
||||||
|
.{},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var json_reader = std.json.reader(state.allocator, try req.reader());
|
||||||
|
defer json_reader.deinit();
|
||||||
|
|
||||||
|
const usernames_req = try std.json.parseFromTokenSource(
|
||||||
|
[][]u8,
|
||||||
|
state.allocator,
|
||||||
|
&json_reader,
|
||||||
|
.{},
|
||||||
|
);
|
||||||
|
defer usernames_req.deinit();
|
||||||
|
|
||||||
|
if (usernames_req.value.len > 10) {
|
||||||
|
try conutil.sendJsonError(
|
||||||
|
req,
|
||||||
|
.bad_request,
|
||||||
|
"Only up to 10 usernames may be requested at a time, got {}",
|
||||||
|
.{usernames_req.value.len},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (usernames_req.value) |username| {
|
||||||
|
for (username) |*char|
|
||||||
|
char.* = std.ascii.toLower(char.*);
|
||||||
|
}
|
||||||
|
|
||||||
|
var param_str = std.ArrayList(u8).init(state.allocator);
|
||||||
|
defer param_str.deinit();
|
||||||
|
try param_str.append('{');
|
||||||
|
for (usernames_req.value, 0..) |username, i| {
|
||||||
|
if (i != 0) try param_str.append(',');
|
||||||
|
try param_str.appendSlice(username);
|
||||||
|
}
|
||||||
|
try param_str.append('}');
|
||||||
|
try param_str.append(0);
|
||||||
|
|
||||||
|
const db_res = state.db.execParams(
|
||||||
|
\\SELECT id, name FROM users
|
||||||
|
\\WHERE lower(name)=any($1::varchar[]);
|
||||||
|
, .{@as([*:0]const u8, @ptrCast(param_str.items.ptr))});
|
||||||
|
defer db_res.deinit();
|
||||||
|
try db_res.expectTuples();
|
||||||
|
|
||||||
|
if (db_res.cols() != 2) return error.InvalidResultFromPostgresServer;
|
||||||
|
|
||||||
|
var response_json = std.ArrayList(u8).init(state.allocator);
|
||||||
|
defer response_json.deinit();
|
||||||
|
|
||||||
|
var json_writer = std.json.writeStream(response_json.writer(), .{});
|
||||||
|
|
||||||
|
try json_writer.beginArray();
|
||||||
|
for (0..@intCast(db_res.rows())) |rowidx| {
|
||||||
|
const id = db_res.get(Id, @intCast(rowidx), 0);
|
||||||
|
const name = db_res.get([]const u8, @intCast(rowidx), 1);
|
||||||
|
|
||||||
|
try json_writer.beginObject();
|
||||||
|
try json_writer.objectField("id");
|
||||||
|
try json_writer.write(&id.toString());
|
||||||
|
try json_writer.objectField("name");
|
||||||
|
try json_writer.write(name);
|
||||||
|
try json_writer.endObject();
|
||||||
|
}
|
||||||
|
try json_writer.endArray();
|
||||||
|
|
||||||
|
try req.respond(response_json.items, .{
|
||||||
|
.extra_headers = &.{.{
|
||||||
|
.name = "Content-Type",
|
||||||
|
.value = "application/json",
|
||||||
|
}},
|
||||||
|
});
|
||||||
|
}
|
185
src/routes/aliapi/authserver/authenticate.zig
Normal file
185
src/routes/aliapi/authserver/authenticate.zig
Normal file
|
@ -0,0 +1,185 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const c = ffi.c;
|
||||||
|
|
||||||
|
const ffi = @import("../../../ffi.zig");
|
||||||
|
const conutil = @import("../../../conutil.zig");
|
||||||
|
|
||||||
|
const Id = @import("../../../Id.zig");
|
||||||
|
const State = @import("../../../State.zig");
|
||||||
|
|
||||||
|
pub fn matches(path: []const u8) bool {
|
||||||
|
return std.mem.eql(u8, path, "/aliapi/authserver/authenticate");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call(req: *std.http.Server.Request, state: *State) !void {
|
||||||
|
if (req.head.method != .POST) {
|
||||||
|
try conutil.sendJsonError(
|
||||||
|
req,
|
||||||
|
.method_not_allowed,
|
||||||
|
"only POST requests are allowed to this endpoint!",
|
||||||
|
.{},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Request = struct {
|
||||||
|
username: [:0]const u8,
|
||||||
|
password: []const u8,
|
||||||
|
clientToken: ?[:0]const u8 = null,
|
||||||
|
requestUser: bool = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
var json_reader = std.json.reader(state.allocator, try req.reader());
|
||||||
|
defer json_reader.deinit();
|
||||||
|
const req_payload = std.json.parseFromTokenSource(Request, state.allocator, &json_reader, .{
|
||||||
|
.ignore_unknown_fields = true,
|
||||||
|
}) catch |e| {
|
||||||
|
try conutil.sendJsonError(req, .bad_request, "unable to parse JSON payload: {}", .{e});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer req_payload.deinit();
|
||||||
|
|
||||||
|
std.log.info("authentification attempt from user {s}", .{req_payload.value.username});
|
||||||
|
|
||||||
|
const valid = valid: {
|
||||||
|
const forgejo_url = try std.fmt.allocPrint(state.allocator, "{s}/api/v1/user", .{state.forgejo_url});
|
||||||
|
defer state.allocator.free(forgejo_url);
|
||||||
|
|
||||||
|
const unenc_auth = try std.fmt.allocPrint(
|
||||||
|
state.allocator,
|
||||||
|
"{s}:{s}",
|
||||||
|
.{ req_payload.value.username, req_payload.value.password },
|
||||||
|
);
|
||||||
|
defer state.allocator.free(unenc_auth);
|
||||||
|
|
||||||
|
const auth_prefix = "Basic ";
|
||||||
|
const auth_str = try state.allocator.alloc(
|
||||||
|
u8,
|
||||||
|
auth_prefix.len + std.base64.standard.Encoder.calcSize(unenc_auth.len),
|
||||||
|
);
|
||||||
|
defer state.allocator.free(auth_str);
|
||||||
|
|
||||||
|
@memcpy(auth_str[0..auth_prefix.len], auth_prefix);
|
||||||
|
_ = std.base64.standard.Encoder.encode(auth_str[auth_prefix.len..], unenc_auth);
|
||||||
|
|
||||||
|
var fres = try state.http.fetch(.{
|
||||||
|
.location = .{ .url = forgejo_url },
|
||||||
|
.extra_headers = &.{.{
|
||||||
|
.name = "Authorization",
|
||||||
|
.value = auth_str,
|
||||||
|
}},
|
||||||
|
});
|
||||||
|
|
||||||
|
break :valid fres.status.class() == .success;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (valid) {
|
||||||
|
std.log.info("issuing new token", .{});
|
||||||
|
// Ensure user record exists
|
||||||
|
const insert_result = state.db.execParams(
|
||||||
|
"INSERT INTO users (id, name) VALUES (gen_random_uuid(), $1) ON CONFLICT DO NOTHING;",
|
||||||
|
.{req_payload.value.username},
|
||||||
|
);
|
||||||
|
defer insert_result.deinit();
|
||||||
|
try insert_result.expectCommand();
|
||||||
|
|
||||||
|
// Get user UUID
|
||||||
|
const sel_result = state.db.execParams(
|
||||||
|
"SELECT id FROM users WHERE name=$1::text;",
|
||||||
|
.{req_payload.value.username},
|
||||||
|
);
|
||||||
|
defer sel_result.deinit();
|
||||||
|
try sel_result.expectTuples();
|
||||||
|
|
||||||
|
if (sel_result.rows() != 1 or sel_result.cols() != 1)
|
||||||
|
return error.InvalidResultFromPostgresServer;
|
||||||
|
|
||||||
|
const userid = sel_result.get(Id, 0, 0);
|
||||||
|
|
||||||
|
const Profile = struct {
|
||||||
|
name: []const u8,
|
||||||
|
id: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ResponsePayload = struct {
|
||||||
|
user: ?struct {
|
||||||
|
username: []const u8,
|
||||||
|
properties: []const struct {
|
||||||
|
name: []const u8,
|
||||||
|
value: []const u8,
|
||||||
|
},
|
||||||
|
id: []const u8,
|
||||||
|
} = null,
|
||||||
|
clientToken: []const u8,
|
||||||
|
accessToken: []const u8,
|
||||||
|
availableProfiles: []const Profile,
|
||||||
|
selectedProfile: Profile,
|
||||||
|
};
|
||||||
|
|
||||||
|
var gen_token_buf: [32:0]u8 = undefined;
|
||||||
|
const client_token: [:0]const u8 = req_payload.value.clientToken orelse gentoken: {
|
||||||
|
// TODO: according to https://wiki.vg/Legacy_Mojang_Authentication, the normal server
|
||||||
|
// would invalidate all existing tokens here. This makes no sense, so we don't do it.
|
||||||
|
@memcpy(&gen_token_buf, &Id.genRandom(state.rand).toString());
|
||||||
|
break :gentoken &gen_token_buf;
|
||||||
|
};
|
||||||
|
|
||||||
|
// remains valid for one week
|
||||||
|
const expiry = std.time.timestamp() + std.time.ms_per_week;
|
||||||
|
|
||||||
|
const tokenid = Id.genRandom(state.rand);
|
||||||
|
|
||||||
|
const add_tok_stat = state.db.execParams(
|
||||||
|
\\INSERT INTO tokens (id, userid, expiry, client_token)
|
||||||
|
\\ VALUES ($1::uuid, $2::uuid, $3::bigint, $4::text);
|
||||||
|
,
|
||||||
|
.{ tokenid, userid, expiry, client_token },
|
||||||
|
);
|
||||||
|
defer add_tok_stat.deinit();
|
||||||
|
try add_tok_stat.expectCommand();
|
||||||
|
|
||||||
|
const uid_hex = userid.toString();
|
||||||
|
|
||||||
|
const profile = Profile{
|
||||||
|
.name = req_payload.value.username,
|
||||||
|
.id = &uid_hex,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res_payload = ResponsePayload{
|
||||||
|
.user = if (req_payload.value.requestUser) .{
|
||||||
|
.username = req_payload.value.username,
|
||||||
|
.id = &uid_hex,
|
||||||
|
.properties = &.{
|
||||||
|
// There is no acceptable real-world use-case where this would be incorrect.
|
||||||
|
.{
|
||||||
|
.name = "preferredLanguage",
|
||||||
|
.value = "en",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} else null,
|
||||||
|
.clientToken = client_token,
|
||||||
|
.accessToken = &tokenid.toString(),
|
||||||
|
.availableProfiles = &.{profile},
|
||||||
|
.selectedProfile = profile,
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = try std.json.stringifyAlloc(
|
||||||
|
state.allocator,
|
||||||
|
res_payload,
|
||||||
|
.{ .emit_null_optional_fields = false },
|
||||||
|
);
|
||||||
|
defer state.allocator.free(data);
|
||||||
|
|
||||||
|
try req.respond(data, .{
|
||||||
|
.extra_headers = &.{.{
|
||||||
|
.name = "Content-Type",
|
||||||
|
.value = "application/json",
|
||||||
|
}},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
std.log.warn("credentials invalid", .{});
|
||||||
|
|
||||||
|
// .forbidden makes no sense here, but that was mojank's idea
|
||||||
|
try conutil.sendJsonError(req, .forbidden, "invalid credentials", .{});
|
||||||
|
}
|
||||||
|
}
|
45
src/routes/aliapi/index.zig
Normal file
45
src/routes/aliapi/index.zig
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const c = ffi.c;
|
||||||
|
|
||||||
|
const ffi = @import("../../ffi.zig");
|
||||||
|
|
||||||
|
const State = @import("../../State.zig");
|
||||||
|
|
||||||
|
pub fn matches(path: []const u8) bool {
|
||||||
|
return std.mem.eql(u8, path, "/aliapi");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call(req: *std.http.Server.Request, state: *State) !void {
|
||||||
|
const bio = c.BIO_new(c.BIO_s_mem()) orelse return error.OutOfMemory;
|
||||||
|
defer _ = c.BIO_free(bio);
|
||||||
|
|
||||||
|
if (c.PEM_write_bio_X509_PUBKEY(
|
||||||
|
bio,
|
||||||
|
c.X509_get_X509_PUBKEY(state.x509),
|
||||||
|
) != 1) return error.OpenSSLBorked;
|
||||||
|
|
||||||
|
var dataptr: ?[*]const u8 = null;
|
||||||
|
const datalen: usize = @intCast(c.BIO_get_mem_data(bio, &dataptr));
|
||||||
|
const keydata = dataptr.?[0..datalen];
|
||||||
|
|
||||||
|
const response_payload = .{
|
||||||
|
.meta = .{
|
||||||
|
.serverName = state.server_name,
|
||||||
|
.implementationName = "AnvilAuth",
|
||||||
|
.implementationVersion = "0.0.0",
|
||||||
|
.links = .{ .source = "https://git.tilera.org/Anvilcraft/AnvilAuth" },
|
||||||
|
},
|
||||||
|
.skinDomains = state.skin_domains,
|
||||||
|
.signaturePublickey = keydata,
|
||||||
|
};
|
||||||
|
|
||||||
|
const json = try std.json.stringifyAlloc(state.allocator, response_payload, .{});
|
||||||
|
defer state.allocator.free(json);
|
||||||
|
|
||||||
|
try req.respond(json, .{
|
||||||
|
.extra_headers = &.{.{
|
||||||
|
.name = "Content-Type",
|
||||||
|
.value = "application/json",
|
||||||
|
}}
|
||||||
|
});
|
||||||
|
}
|
|
@ -0,0 +1,74 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const c = ffi.c;
|
||||||
|
|
||||||
|
const conutil = @import("../../../../../conutil.zig");
|
||||||
|
const ffi = @import("../../../../../ffi.zig");
|
||||||
|
|
||||||
|
const Id = @import("../../../../../Id.zig");
|
||||||
|
const JsonUserProfile = @import("../../../../../JsonUserProfile.zig");
|
||||||
|
const State = @import("../../../../../State.zig");
|
||||||
|
|
||||||
|
pub fn matches(path: []const u8) bool {
|
||||||
|
return std.mem.startsWith(u8, path, "/aliapi/sessionserver/session/minecraft/hasJoined");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call(req: *std.http.Server.Request, state: *State) !void {
|
||||||
|
const req_url = try std.Uri.parseWithoutScheme(req.head.target);
|
||||||
|
const params = conutil.parseQueryParametersFromUri(req_url, struct {
|
||||||
|
serverId: []const u8,
|
||||||
|
username: []const u8,
|
||||||
|
}) catch |e| {
|
||||||
|
try conutil.sendJsonError(req, .bad_request, "invalid query parameters: {}", .{e});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sel_dbret = state.db.execParams(
|
||||||
|
\\SELECT users.id
|
||||||
|
\\FROM users, joins
|
||||||
|
\\WHERE
|
||||||
|
\\ joins.userid = users.id AND
|
||||||
|
\\ users.name = $1::text AND
|
||||||
|
\\ joins.serverid = $2::text;
|
||||||
|
, .{ params.username, params.serverId });
|
||||||
|
defer sel_dbret.deinit();
|
||||||
|
try sel_dbret.expectTuples();
|
||||||
|
|
||||||
|
if (sel_dbret.cols() != 1) return error.InvalidResultFromPostgresServer;
|
||||||
|
|
||||||
|
if (sel_dbret.rows() >= 1) {
|
||||||
|
const id = sel_dbret.get(Id, 0, 0);
|
||||||
|
|
||||||
|
const skin_url = try state.getSkinUrl(params.username);
|
||||||
|
defer if (skin_url) |url| state.allocator.free(url);
|
||||||
|
|
||||||
|
const profile = try JsonUserProfile.init(
|
||||||
|
state.allocator,
|
||||||
|
id,
|
||||||
|
params.username,
|
||||||
|
skin_url orelse state.default_skin_url,
|
||||||
|
state.rsa,
|
||||||
|
);
|
||||||
|
defer profile.deinit(state.allocator);
|
||||||
|
|
||||||
|
const profile_json = try std.json.stringifyAlloc(
|
||||||
|
state.allocator,
|
||||||
|
profile,
|
||||||
|
.{ .emit_null_optional_fields = false },
|
||||||
|
);
|
||||||
|
defer state.allocator.free(profile_json);
|
||||||
|
|
||||||
|
try req.respond(profile_json, .{ .extra_headers = &.{.{
|
||||||
|
.name = "Content-Type",
|
||||||
|
.value = "application/json",
|
||||||
|
}} });
|
||||||
|
|
||||||
|
const del_dbret = state.db.execParams("DELETE FROM joins WHERE userid = $1::uuid;", .{id});
|
||||||
|
defer del_dbret.deinit();
|
||||||
|
try del_dbret.expectCommand();
|
||||||
|
} else {
|
||||||
|
// task failed successfully! (good api design, mojank!)
|
||||||
|
try req.respond("", .{
|
||||||
|
.status = .no_content,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
68
src/routes/aliapi/sessionserver/session/minecraft/join.zig
Normal file
68
src/routes/aliapi/sessionserver/session/minecraft/join.zig
Normal file
|
@ -0,0 +1,68 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const c = ffi.c;
|
||||||
|
|
||||||
|
const conutil = @import("../../../../../conutil.zig");
|
||||||
|
const ffi = @import("../../../../../ffi.zig");
|
||||||
|
|
||||||
|
const Id = @import("../../../../../Id.zig");
|
||||||
|
const State = @import("../../../../../State.zig");
|
||||||
|
|
||||||
|
pub fn matches(path: []const u8) bool {
|
||||||
|
return std.mem.eql(u8, path, "/aliapi/sessionserver/session/minecraft/join");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call(req: *std.http.Server.Request, state: *State) !void {
|
||||||
|
const Request = struct {
|
||||||
|
accessToken: []const u8,
|
||||||
|
selectedProfile: []const u8,
|
||||||
|
serverId: [:0]const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
var json_reader = std.json.reader(state.allocator, try req.reader());
|
||||||
|
defer json_reader.deinit();
|
||||||
|
const req_payload = std.json.parseFromTokenSource(Request, state.allocator, &json_reader, .{
|
||||||
|
.ignore_unknown_fields = true,
|
||||||
|
}) catch |e| {
|
||||||
|
try conutil.sendJsonError(req, .bad_request, "unable to parse JSON payload: {}", .{e});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
defer req_payload.deinit();
|
||||||
|
|
||||||
|
const access_token = Id.parse(req_payload.value.accessToken) orelse {
|
||||||
|
try conutil.sendJsonError(req, .bad_request, "accessToken is not a valid ID!", .{});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sel_profile = Id.parse(req_payload.value.selectedProfile) orelse {
|
||||||
|
try conutil.sendJsonError(req, .bad_request, "selectedProfile is not a valid ID!", .{});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbret = state.db.execParams("SELECT userid FROM tokens WHERE id=$1::uuid;", .{access_token});
|
||||||
|
defer dbret.deinit();
|
||||||
|
try dbret.expectTuples();
|
||||||
|
|
||||||
|
if (dbret.cols() != 1) return error.InvalidResultFromPostgresServer;
|
||||||
|
|
||||||
|
if (dbret.rows() >= 1) {
|
||||||
|
const token_user = dbret.get(Id, 0, 0);
|
||||||
|
if (std.mem.eql(u8, &sel_profile.bytes, &token_user.bytes)) {
|
||||||
|
const ins_dbret = state.db.execParams(
|
||||||
|
\\INSERT INTO joins (userid, serverid)
|
||||||
|
\\VALUES ($1::uuid, $2::text)
|
||||||
|
\\ON CONFLICT (userid) DO
|
||||||
|
\\UPDATE SET serverid = EXCLUDED.serverid;
|
||||||
|
, .{ token_user, req_payload.value.serverId });
|
||||||
|
defer ins_dbret.deinit();
|
||||||
|
try ins_dbret.expectCommand();
|
||||||
|
|
||||||
|
try req.respond("", .{ .status = .no_content });
|
||||||
|
} else {
|
||||||
|
// acces token belongs to other user (hehe)
|
||||||
|
try conutil.sendJsonError(req, .forbidden, "invalid access token!", .{});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// invalid access token
|
||||||
|
try conutil.sendJsonError(req, .forbidden, "invalid access token!", .{});
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,99 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const c = ffi.c;
|
||||||
|
|
||||||
|
const ffi = @import("../../../../../ffi.zig");
|
||||||
|
const conutil = @import("../../../../../conutil.zig");
|
||||||
|
|
||||||
|
const Id = @import("../../../../../Id.zig");
|
||||||
|
const JsonUserProfile = @import("../../../../../JsonUserProfile.zig");
|
||||||
|
const State = @import("../../../../../State.zig");
|
||||||
|
|
||||||
|
const path_prefix = "/aliapi/sessionserver/session/minecraft/profile/";
|
||||||
|
|
||||||
|
pub fn matches(path: []const u8) bool {
|
||||||
|
return std.mem.startsWith(u8, path, path_prefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call(req: *std.http.Server.Request, state: *State) !void {
|
||||||
|
const req_url = try std.Uri.parseWithoutScheme(req.head.target);
|
||||||
|
|
||||||
|
// This is sound as we only go here if the path starts with path_prefix.
|
||||||
|
const profile_id = Id.parse(req_url.path[path_prefix.len..]) orelse {
|
||||||
|
try conutil.sendJsonError(
|
||||||
|
req,
|
||||||
|
.bad_request,
|
||||||
|
"not a valid UUID: {s} (NOTE: AnvilAuth technically doesn't use UUIDs, this endpoint expects 16 hex-encoded, undelimited bytes.)",
|
||||||
|
.{req_url.path[path_prefix.len..]},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
const unsigned = unsigned: {
|
||||||
|
if (req_url.query == null) break :unsigned true;
|
||||||
|
|
||||||
|
const params = conutil.parseQueryParametersOrDefaults(
|
||||||
|
req_url.query.?,
|
||||||
|
struct { unsigned: []const u8 = "true" },
|
||||||
|
) catch |e| {
|
||||||
|
try conutil.sendJsonError(req, .bad_request, "invalid query parameters: {}", .{e});
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (std.mem.eql(u8, params.unsigned, "true")) {
|
||||||
|
break :unsigned true;
|
||||||
|
} else if (std.mem.eql(u8, params.unsigned, "false")) {
|
||||||
|
break :unsigned false;
|
||||||
|
} else {
|
||||||
|
try conutil.sendJsonError(
|
||||||
|
req,
|
||||||
|
.bad_request,
|
||||||
|
"`unsigned` parameter must be either `true` or `false`, got `{s}`",
|
||||||
|
.{params.unsigned},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = state.db.execParams(
|
||||||
|
"SELECT name FROM users WHERE id=$1::uuid;",
|
||||||
|
.{profile_id},
|
||||||
|
);
|
||||||
|
defer status.deinit();
|
||||||
|
try status.expectTuples();
|
||||||
|
|
||||||
|
if (status.cols() != 1) return error.InvalidResultFromPostgresServer;
|
||||||
|
|
||||||
|
if (status.rows() >= 1) {
|
||||||
|
const username = status.get([]const u8, 0, 0);
|
||||||
|
|
||||||
|
const skin_url = try state.getSkinUrl(username);
|
||||||
|
defer if (skin_url) |url| state.allocator.free(url);
|
||||||
|
|
||||||
|
const uprofile = try JsonUserProfile.init(
|
||||||
|
state.allocator,
|
||||||
|
profile_id,
|
||||||
|
username,
|
||||||
|
skin_url orelse state.default_skin_url,
|
||||||
|
if (unsigned) null else state.rsa,
|
||||||
|
);
|
||||||
|
defer uprofile.deinit(state.allocator);
|
||||||
|
|
||||||
|
const response_data = try std.json.stringifyAlloc(
|
||||||
|
state.allocator,
|
||||||
|
uprofile,
|
||||||
|
.{ .emit_null_optional_fields = false },
|
||||||
|
);
|
||||||
|
defer state.allocator.free(response_data);
|
||||||
|
|
||||||
|
try req.respond(response_data, .{
|
||||||
|
.extra_headers = &.{.{
|
||||||
|
.name = "Content-Type",
|
||||||
|
.value = "application/json",
|
||||||
|
}},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// -> User not found
|
||||||
|
// This retarded API design brought to you by Mojang!
|
||||||
|
try req.respond("", .{ .status = .no_content });
|
||||||
|
}
|
||||||
|
}
|
19
src/routes/default_skin.zig
Normal file
19
src/routes/default_skin.zig
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
const std = @import("std");
|
||||||
|
const assets = @import("assets");
|
||||||
|
|
||||||
|
const State = @import("../State.zig");
|
||||||
|
|
||||||
|
pub fn matches(path: []const u8) bool {
|
||||||
|
return std.mem.eql(u8, path, "/default_skin");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call(req: *std.http.Server.Request, state: *State) !void {
|
||||||
|
_ = state;
|
||||||
|
|
||||||
|
try req.respond(assets.steve_skin, .{
|
||||||
|
.extra_headers = &.{.{
|
||||||
|
.name = "Content-Type",
|
||||||
|
.value = "image/png",
|
||||||
|
}},
|
||||||
|
});
|
||||||
|
}
|
19
src/routes/root.zig
Normal file
19
src/routes/root.zig
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
const State = @import("../State.zig");
|
||||||
|
|
||||||
|
pub fn matches(path: []const u8) bool {
|
||||||
|
return std.mem.eql(u8, path, "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn call(req: *std.http.Server.Request, state: *State) !void {
|
||||||
|
const base_url = try std.fmt.allocPrint(state.allocator, "{s}/aliapi", .{state.base_url});
|
||||||
|
defer state.allocator.free(base_url);
|
||||||
|
|
||||||
|
try req.respond("", .{
|
||||||
|
.extra_headers = &.{.{
|
||||||
|
.name = "x-authlib-injector-api-location",
|
||||||
|
.value = base_url,
|
||||||
|
}},
|
||||||
|
});
|
||||||
|
}
|
Loading…
Reference in a new issue