]> gitweb.ps.run Git - chirp/blob - src/main.zig
get things working again
[chirp] / src / main.zig
1 const std = @import("std");
2 const lmdb = @import("lmdb");
3 const db = @import("db");
4 const http = @import("http");
5
6 // db {{{
7
8 const Db = struct {
9     fn users(txn: lmdb.Txn) !db.Db(UserId, User) {
10         return try db.Db(UserId, User).init(txn, "users");
11     }
12     fn user_ids(txn: lmdb.Txn) !db.Db(Username, UserId) {
13         return try db.Db(Username, UserId).init(txn, "user_ids");
14     }
15     fn sessions(txn: lmdb.Txn) !db.Db(SessionToken, UserId) {
16         return try db.Db(SessionToken, UserId).init(txn, "sessions");
17     }
18     fn posts(txn: lmdb.Txn) !db.Db(PostId, Post) {
19         return try db.Db(PostId, Post).init(txn, "posts");
20     }
21 };
22
23 // }}}
24
25 // content {{{
26
27 const User = struct {
28     // TODO: choose sizes
29     id: UserId,
30     name: Username,
31     password_hash: PasswordHash,
32     posts: PostList,
33 };
34
35 const Post = struct {
36     id: PostId,
37
38     user_id: UserId,
39     time: Timestamp,
40
41     upvotes: u64 = 0,
42     downvotes: u64 = 0,
43     votes: VoteList,
44     comments: PostList,
45     // quote posts
46
47     text: PostText,
48 };
49
50 const Vote = struct {
51     const Kind = enum { Up, Down };
52
53     kind: Kind,
54     time: Timestamp,
55 };
56
57 const Id = u64;
58 const Login = struct {
59     user: User,
60     user_id: UserId,
61     session_token: SessionToken,
62 };
63 const UserId = enum(u64) { _ };
64 const PostId = enum(u64) { _ };
65 const Timestamp = i64;
66 const Username = std.BoundedArray(u8, 16);
67 const PasswordHash = std.BoundedArray(u8, 128);
68 const SessionToken = u64;
69 const CookieValue = std.BoundedArray(u8, 128);
70 const PostText = std.BoundedArray(u8, 1024);
71 const PostList = db.SetList(PostId, void);
72 const UserList = db.SetList(UserId, User);
73 const VoteList = db.SetList(UserId, Vote);
74
75 const Chirp = struct {
76     pub fn hash_password(password: []const u8) !PasswordHash {
77         var hash_buffer = try PasswordHash.init(128);
78
79         // TODO: choose buffer size
80         // TODO: dont allocate on stack, maybe zero memory?
81         var buffer: [1024 * 10]u8 = undefined;
82         var alloc = std.heap.FixedBufferAllocator.init(&buffer);
83
84         // TODO: choose limits
85         const result = try std.crypto.pwhash.argon2.strHash(password, .{
86             .allocator = alloc.allocator(),
87             .params = std.crypto.pwhash.argon2.Params.fromLimits(1000, 1024),
88         }, hash_buffer.slice());
89
90         try hash_buffer.resize(result.len);
91
92         return hash_buffer;
93     }
94
95     pub fn verify_password(password: []const u8, hash: PasswordHash) bool {
96         var buffer: [1024 * 10]u8 = undefined;
97         var alloc = std.heap.FixedBufferAllocator.init(&buffer);
98
99         if (std.crypto.pwhash.argon2.strVerify(hash.constSlice(), password, .{
100             .allocator = alloc.allocator(),
101         })) {
102             return true;
103         } else |err| {
104             std.debug.print("verify error: {}\n", .{err});
105             return false;
106         }
107     }
108
109     pub fn register_user(env: *lmdb.Env, username: []const u8, password: []const u8) !bool {
110         const username_array = try Username.fromSlice(username);
111
112         const txn = try env.txn();
113         defer txn.commit() catch {};
114
115         const users = try Db.users(txn);
116         const user_ids = try Db.user_ids(txn);
117
118         if (try user_ids.has(username_array)) {
119             return false;
120         } else {
121             const user_id = try db.Prng.gen(users.dbi, UserId);
122             const posts = try Db.posts(txn);
123
124             try users.put(user_id, User{
125                 .id = user_id,
126                 .name = username_array,
127                 .password_hash = try hash_password(password),
128                 .posts = try PostList.init(posts.dbi),
129             });
130
131             try user_ids.put(username_array, user_id);
132
133             return true;
134         }
135     }
136
137     pub fn login_user(
138         env: *lmdb.Env,
139         username: []const u8,
140         password: []const u8,
141     ) !SessionToken {
142         const username_array = try Username.fromSlice(username);
143
144         const txn = try env.txn();
145         defer txn.commit() catch {};
146
147         const user_ids = try Db.user_ids(txn);
148         const user_id = try user_ids.get(username_array);
149         std.debug.print("user logging in, id: {}\n", .{user_id});
150
151         const users = try Db.users(txn);
152         const user = try users.get(user_id);
153
154         if (verify_password(password, user.password_hash)) {
155             const sessions = try Db.sessions(txn);
156             const session_token = try db.Prng.gen(sessions.dbi, SessionToken);
157             try sessions.put(session_token, user_id);
158             return session_token;
159         } else {
160             return error.IncorrectPassword;
161         }
162     }
163
164     fn logout_user(env: *lmdb.Env, session_token: SessionToken) !void {
165         const txn = try env.txn();
166         defer txn.commit() catch {};
167
168         const sessions = try Db.sessions(txn);
169         try sessions.del(session_token);
170     }
171
172     fn post(env: *lmdb.Env, user_id: UserId, text: []const u8) !void {
173         var post_id: PostId = undefined;
174
175         // TODO: do this in one commit
176
177         var txn: lmdb.Txn = undefined;
178         {
179             // create post
180             txn = try env.txn();
181             defer txn.commit() catch {};
182
183             const posts = try Db.posts(txn);
184             post_id = try db.Prng.gen(posts.dbi, PostId);
185             const votes = try txn.dbi("votes");
186             try posts.put(post_id, Post{
187                 .id = post_id,
188                 .user_id = user_id,
189                 .time = std.time.timestamp(),
190                 .votes = try VoteList.init(votes),
191                 .comments = try PostList.init(posts.dbi),
192                 .text = try PostText.fromSlice(text),
193             });
194         }
195
196         {
197             // append to user's posts
198             txn = try env.txn();
199             defer txn.commit() catch {};
200
201             const users = try Db.users(txn);
202             var user = try users.get(user_id);
203
204             const posts = try Db.posts(txn);
205             var posts_view = try user.posts.open(posts.dbi);
206             try posts_view.append(post_id, {});
207         }
208     }
209
210     fn vote(env: *lmdb.Env, post_id: PostId, user_id: UserId, kind: Vote.Kind) !void {
211         const txn = try env.txn();
212         defer txn.commit() catch {};
213
214         const posts = try Db.posts(txn);
215         const votes = try txn.dbi("votes");
216
217         var p = try posts.get(post_id);
218         var votes_view = try p.votes.open(votes);
219
220         if (try votes_view.has(user_id)) {
221             const old_vote = try votes_view.get(user_id);
222
223             if (old_vote.kind == kind) {
224                 return;
225             } else {
226                 try votes_view.del(user_id);
227
228                 if (old_vote.kind == .Up) {
229                     p.upvotes -= 1;
230                 } else {
231                     p.downvotes -= 1;
232                 }
233                 try posts.put(post_id, p);
234             }
235         }
236         try votes_view.append(user_id, Vote{
237             .kind = kind,
238             .time = std.time.timestamp(),
239         });
240
241         if (kind == .Up) {
242             p.upvotes += 1;
243         } else {
244             p.downvotes += 1;
245         }
246         try posts.put(post_id, p);
247     }
248
249     fn unvote(env: *lmdb.Env, post_id: PostId, user_id: UserId) !void {
250         const txn = try env.txn();
251         defer txn.commit() catch {};
252
253         const posts = try Db.posts(txn);
254         const votes = try txn.dbi("votes");
255
256         var p = try posts.get(post_id);
257         var votes_view = try p.votes.open(votes);
258
259         if (try votes_view.has(user_id)) {
260             const v = try votes_view.get(user_id);
261
262             if (v.kind == .Up) {
263                 p.upvotes -= 1;
264             } else {
265                 p.downvotes -= 1;
266             }
267             try posts.put(post_id, p);
268
269             try votes_view.del(user_id);
270         }
271     }
272
273     fn get_session_user_id(env: *lmdb.Env, session_token: SessionToken) !UserId {
274         const txn = try env.txn();
275         defer txn.abort();
276
277         const sessions = try Db.sessions(txn);
278
279         return try sessions.get(session_token);
280     }
281
282     fn get_user(env: *lmdb.Env, user_id: UserId) !User {
283         const txn = try env.txn();
284         defer txn.abort();
285
286         const users = try Db.users(txn);
287         return try users.get(user_id);
288     }
289 };
290
291 // }}}
292
293 // html {{{
294 fn html_form(res: *http.Response, comptime fmt_action: []const u8, args_action: anytype, inputs: anytype) !void {
295     try res.write("<form style=\"display: inline-block!important;\" action=\"", .{});
296     try res.write(fmt_action, args_action);
297     try res.write("\" method=\"post\">", .{});
298
299     inline for (inputs) |input| {
300         switch (@typeInfo(@TypeOf(input))) {
301             .Struct => {
302                 try res.write("<input ", .{});
303                 try res.write(input[0], input[1]);
304                 try res.write(" />", .{});
305             },
306             else => {
307                 try res.write("<input ", .{});
308                 try res.write(input, .{});
309                 try res.write(" />", .{});
310             },
311         }
312     }
313
314     try res.write("</form>", .{});
315 }
316 // }}}
317
318 // write {{{
319 fn write_header(res: *http.Response, logged_in: ?Login) !void {
320     if (logged_in) |login| {
321         try res.write(
322             \\<a href="/user/{s}">Home</a><br />
323         , .{login.user.name.constSlice()});
324         try html_form(res, "/logout", .{}, .{
325             \\type="submit" value="Logout"
326         });
327         try html_form(res, "/quit", .{}, .{
328             \\type="submit" value="Quit"
329         });
330         try res.write("<br />", .{});
331         try html_form(res, "/post", .{}, .{
332             \\type="text" name="text"
333             ,
334             \\type="submit" value="Post"
335         });
336     } else {
337         try res.write(
338             \\<a href="/">Home</a><br />
339             \\<a href="/register">Register</a>
340             \\<a href="/login">Login</a><br />
341         , .{});
342         try html_form(res, "/quit", .{}, .{
343             \\type="submit" value="Quit"
344         });
345     }
346 }
347 fn write_posts(res: *http.Response, txn: lmdb.Txn, user: User, login: ?Login) !void {
348     const votes_dbi = try txn.dbi("votes");
349     const posts = try Db.posts(txn);
350     const posts_view = try user.posts.open(posts.dbi);
351
352     var it = posts_view.iterator();
353     while (it.next()) |kv| {
354         const post_id = kv.key;
355         const post = try posts.get(post_id);
356
357         try res.write(
358             \\<div>
359             \\<p>{s}</p>
360         , .{post.text.constSlice()});
361
362         const votes_view = try post.votes.open(votes_dbi);
363         const comments_view = try post.comments.open(posts.dbi);
364
365         var has_voted: ?Vote.Kind = null;
366
367         if (login != null and try votes_view.has(login.?.user_id)) {
368             const vote = try votes_view.get(login.?.user_id);
369
370             has_voted = vote.kind;
371         }
372
373         if (has_voted != null and has_voted.? == .Up) {
374             try html_form(res, "/unupvote/{}", .{@intFromEnum(post_id)}, .{
375                 .{ "type=\"submit\" value=\"&#x2B06; {}\"", .{post.upvotes} },
376             });
377         } else {
378             try html_form(res, "/upvote/{}", .{@intFromEnum(post_id)}, .{
379                 .{ "type=\"submit\" value=\"&#x2B06; {}\"", .{post.upvotes} },
380             });
381         }
382         if (has_voted != null and has_voted.? == .Down) {
383             try html_form(res, "/undownvote/{}", .{@intFromEnum(post_id)}, .{
384                 .{ "type=\"submit\" value=\"&#x2B07; {}\"", .{post.downvotes} },
385             });
386         } else {
387             try html_form(res, "/downvote/{}", .{@intFromEnum(post_id)}, .{
388                 .{ "type=\"submit\" value=\"&#x2B07; {}\"", .{post.downvotes} },
389             });
390         }
391         try res.write(
392             \\<span>&#x1F4AD; {}</span>
393             \\</div>
394         , .{comments_view.len()});
395     }
396 }
397 // }}}
398
399 fn list_users(env: lmdb.Env) !void {
400     const txn = try env.txn();
401     defer txn.abort();
402
403     const users = try Db.users(txn);
404     var it = try users.iterator();
405
406     while (it.next()) |kv| {
407         const key = kv.key;
408         const user = kv.val;
409         std.debug.print("[{}] {s}\n", .{ key, user.name.constSlice() });
410     }
411 }
412 fn list_user_ids(env: lmdb.Env) !void {
413     const txn = try env.txn();
414     defer txn.abort();
415
416     const user_ids = try Db.user_ids(txn);
417     var it = try user_ids.iterator();
418
419     while (it.next()) |kv| {
420         const key = kv.key;
421         const user_id = kv.val;
422         std.debug.print("[{s}] {}\n", .{ key.constSlice(), user_id });
423     }
424 }
425
426 fn list_sessions(env: lmdb.Env) !void {
427     const txn = try env.txn();
428     defer txn.abort();
429
430     const sessions = try Db.sessions(txn);
431     var it = try sessions.iterator();
432
433     while (it.next()) |kv| {
434         const key = kv.key;
435         const user_id = kv.val;
436         std.debug.print("[{x}] {}\n", .{ key, user_id });
437     }
438 }
439
440 fn list_posts(env: lmdb.Env) !void {
441     const txn = try env.txn();
442     defer txn.abort();
443
444     const posts = try Db.posts(txn);
445     var it = try posts.iterator();
446
447     while (it.next()) |kv| {
448         const key = kv.key;
449         const post = kv.val;
450         std.debug.print("[{}] {s}\n", .{ key, post.text.constSlice() });
451     }
452 }
453
454 const ReqBufferSize = 4096;
455 const ResHeadBufferSize = 1024 * 16;
456 const ResBodyBufferSize = 1024 * 16;
457
458 pub fn main() !void {
459     // server
460     var server = try http.Server.init("::", 8080);
461     defer server.deinit();
462
463     // lmdb
464     var env = try lmdb.Env.open("db", 1024 * 1024 * 10);
465     defer env.close();
466
467     // std.debug.print("Users:\n", .{});
468     // try list_users(env);
469     // std.debug.print("User IDs:\n", .{});
470     // try list_user_ids(env);
471     // std.debug.print("Sessions:\n", .{});
472     // try list_sessions(env);
473     // std.debug.print("Posts:\n", .{});
474     // try list_posts(env);
475
476     try handle_connection(&server, &env);
477     // const ThreadCount = 1;
478     // var ts: [ThreadCount]std.Thread = undefined;
479
480     // for (0..ThreadCount) |i| {
481     //     ts[i] = try std.Thread.spawn(.{}, handle_connection, .{ &server, &env });
482     // }
483     // for (0..ThreadCount) |i| {
484     //     ts[i].join();
485     // }
486
487     std.debug.print("done\n", .{});
488 }
489
490 fn handle_connection(server: *http.Server, env: *lmdb.Env) !void {
491     // TODO: static?
492     var req_buffer: [ReqBufferSize]u8 = undefined;
493     var res_head_buffer: [ResHeadBufferSize]u8 = undefined;
494     var res_body_buffer: [ResBodyBufferSize]u8 = undefined;
495
496     accept: while (true) {
497         server.wait();
498
499         while (try server.next_request(&req_buffer)) |req| {
500             // std.debug.print("[{}]: {s}\n", .{ req.method, req.target });
501
502             // reponse
503             var res = http.Response.init(req.fd, &res_head_buffer, &res_body_buffer);
504
505             // check session token
506             var logged_in: ?Login = null;
507
508             if (req.get_cookie("session_token")) |session_token_str| {
509                 const session_token = try std.fmt.parseUnsigned(SessionToken, session_token_str, 16);
510                 // const session_token = try std.fmt.parseUnsigned(SessionToken, session_token_str, 10);
511                 // const session_token = std.mem.bytesToValue(SessionToken, session_token_str);
512                 if (Chirp.get_session_user_id(env, session_token)) |user_id| {
513                     const txn = try env.txn();
514                     defer txn.abort();
515                     const users = try Db.users(txn);
516
517                     logged_in = .{
518                         .user = try users.get(user_id),
519                         .user_id = user_id,
520                         .session_token = session_token,
521                     };
522                 } else |err| {
523                     std.debug.print("get_session_user err: {}\n", .{err});
524
525                     try res.add_header(
526                         "Set-Cookie",
527                         .{"session_token=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT"},
528                     );
529                 }
530             }
531
532             // html
533             if (req.method == .GET) {
534                 try write_header(&res, logged_in);
535
536                 if (std.mem.eql(u8, req.target, "/register")) {
537                     try res.write(
538                         \\<form action="/register" method="post">
539                         \\<input type="text" name="username" />
540                         \\<input type="password" name="password" />
541                         \\<input type="submit" value="Register" />
542                         \\</form>
543                     , .{});
544                     try res.send();
545                 } else if (std.mem.eql(u8, req.target, "/login")) {
546                     try res.write(
547                         \\<form action="/login" method="post">
548                         \\<input type="text" name="username" />
549                         \\<input type="password" name="password" />
550                         \\<input type="submit" value="Login" />
551                         \\</form>
552                     , .{});
553                     try res.send();
554                 } else if (std.mem.startsWith(u8, req.target, "/user/")) {
555                     const username = req.target[6..req.target.len];
556
557                     const txn = try env.txn();
558                     defer txn.abort();
559
560                     const user_ids = try Db.user_ids(txn);
561                     if (user_ids.get(try Username.fromSlice(username))) |user_id| {
562                         const users = try Db.users(txn);
563                         const user = try users.get(user_id);
564                         try write_posts(&res, txn, user, logged_in);
565                     } else |err| {
566                         try res.write(
567                             \\<p>User not found [{}]</p>
568                         , .{err});
569                     }
570                     try res.send();
571                 } else {
572                     if (logged_in) |login| {
573                         const user = try Chirp.get_user(env, login.user_id);
574
575                         const txn = try env.txn();
576                         defer txn.abort();
577
578                         try write_posts(&res, txn, user, logged_in);
579
580                         try res.send();
581                     } else {
582                         try res.write("[GET] {s}", .{req.target});
583                         try res.send();
584                     }
585                 }
586             }
587             // api
588             else {
589                 if (std.mem.eql(u8, req.target, "/register")) {
590                     // TODO: handle args not supplied
591                     const username = req.get_value("username").?;
592                     const password = req.get_value("password").?;
593
594                     std.debug.print("New user: {s} {s}\n", .{ username, password });
595                     if (try Chirp.register_user(env, username, password)) {
596                         try res.redirect("/login");
597                     } else {
598                         try res.redirect("/register");
599                     }
600                     try res.send();
601                 } else if (std.mem.eql(u8, req.target, "/login")) {
602                     // TODO: handle args not supplied
603                     const username = req.get_value("username").?;
604                     const password = req.get_value("password").?;
605
606                     std.debug.print("New login: {s} {s}\n", .{ username, password });
607                     if (Chirp.login_user(env, username, password)) |session_token| {
608                         res.status = .see_other;
609                         try res.add_header(
610                             "Location",
611                             .{ "/user/{s}", .{username} },
612                         );
613                         try res.add_header(
614                             "Set-Cookie",
615                             .{ "session_token={x}; Secure; HttpOnly", .{session_token} },
616                         );
617
618                         try res.send();
619                     } else |err| {
620                         std.debug.print("login_user err: {}\n", .{err});
621                         try res.redirect("/login");
622                         try res.send();
623                     }
624                 } else if (std.mem.eql(u8, req.target, "/logout")) {
625                     if (logged_in) |login| {
626                         try Chirp.logout_user(env, login.session_token);
627
628                         try res.add_header(
629                             "Set-Cookie",
630                             .{"session_token=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT"},
631                         );
632
633                         try res.redirect("/");
634                         try res.send();
635                     }
636                 } else if (std.mem.eql(u8, req.target, "/post")) {
637                     if (logged_in) |login| {
638                         const text = req.get_value("text").?;
639                         try Chirp.post(env, login.user_id, text);
640
641                         try res.redirect("/");
642                         try res.send();
643                     }
644                 } else if (std.mem.eql(u8, req.target, "/quit")) {
645                     try res.redirect("/");
646                     try res.send();
647                     break :accept;
648                 } else if (std.mem.startsWith(u8, req.target, "/upvote/")) {
649                     const login = logged_in orelse return error.NotLoggedIn;
650
651                     const post_id_str = req.target[8..req.target.len];
652                     const post_id: PostId = @enumFromInt(try std.fmt.parseUnsigned(u64, post_id_str, 10));
653
654                     try Chirp.vote(env, post_id, login.user_id, .Up);
655
656                     if (req.get_header("Referer")) |ref| {
657                         try res.redirect(ref);
658                     }
659                     try res.send();
660                 } else if (std.mem.startsWith(u8, req.target, "/downvote/")) {
661                     const login = logged_in orelse return error.NotLoggedIn;
662
663                     const post_id_str = req.target[10..req.target.len];
664                     const post_id: PostId = @enumFromInt(try std.fmt.parseUnsigned(u64, post_id_str, 10));
665
666                     try Chirp.vote(env, post_id, login.user_id, .Down);
667
668                     if (req.get_header("Referer")) |ref| {
669                         try res.redirect(ref);
670                     }
671                     try res.send();
672                 } else if (std.mem.startsWith(u8, req.target, "/unupvote/")) {
673                     // TODO: maybe move to one /unvote?
674                     const login = logged_in orelse return error.NotLoggedIn;
675
676                     const post_id_str = req.target[10..req.target.len];
677                     const post_id: PostId = @enumFromInt(try std.fmt.parseUnsigned(u64, post_id_str, 10));
678
679                     try Chirp.unvote(env, post_id, login.user_id);
680
681                     if (req.get_header("Referer")) |ref| {
682                         try res.redirect(ref);
683                     }
684                     try res.send();
685                 } else if (std.mem.startsWith(u8, req.target, "/undownvote/")) {
686                     const login = logged_in orelse return error.NotLoggedIn;
687
688                     const post_id_str = req.target[12..req.target.len];
689                     const post_id: PostId = @enumFromInt(try std.fmt.parseUnsigned(u64, post_id_str, 10));
690
691                     try Chirp.unvote(env, post_id, login.user_id);
692
693                     if (req.get_header("Referer")) |ref| {
694                         try res.redirect(ref);
695                     }
696                     try res.send();
697                 } else {
698                     // try req.respond(
699                     //     \\<p>POST</p>
700                     // , .{});
701                     try res.write("<p>[POST] {s}</p>", .{req.target});
702                     try res.send();
703                 }
704             }
705         }
706     }
707 }