]> gitweb.ps.run Git - chirp/commitdiff
work
authorpatrick-scho <patrick.schoenberger@posteo.de>
Fri, 28 Feb 2025 20:48:40 +0000 (21:48 +0100)
committerpatrick-scho <patrick.schoenberger@posteo.de>
Fri, 28 Feb 2025 20:48:40 +0000 (21:48 +0100)
src/main.zig
todo.md

index 65dab0798228554b0cbacd70e96cad0b2fb66ea6..f8113dc108ae1f5f1474f3b0bd02bc85033cd022 100644 (file)
@@ -28,12 +28,17 @@ const User = struct {
     // TODO: choose sizes
     id: UserId,
     name: Username,
+    display_name: DisplayName,
     password_hash: PasswordHash,
     posts: PostList,
+    following: UserList,
+    followers: UserList,
 };
 
 const Post = struct {
     id: PostId,
+    parent_id: ?PostId,
+    quote_id: ?PostId,
 
     user_id: UserId,
     time: Timestamp,
@@ -42,7 +47,7 @@ const Post = struct {
     downvotes: u64 = 0,
     votes: VoteList,
     comments: PostList,
-    // quote posts
+    quotes: PostList,
 
     text: PostText,
 };
@@ -57,21 +62,94 @@ const Vote = struct {
 const Id = u64;
 const Login = struct {
     user: User,
-    user_id: UserId,
     session_token: SessionToken,
 };
 const UserId = enum(u64) { _ };
 const PostId = enum(u64) { _ };
 const Timestamp = i64;
 const Username = std.BoundedArray(u8, 16);
+const DisplayName = std.BoundedArray(u8, 64);
 const PasswordHash = std.BoundedArray(u8, 128);
 const SessionToken = u64;
 const CookieValue = std.BoundedArray(u8, 128);
 const PostText = std.BoundedArray(u8, 1024);
-const PostList = db.SetList(PostId, void);
-const UserList = db.SetList(UserId, User);
+const PostList = db.Set(PostId);
+const UserList = db.Set(UserId);
 const VoteList = db.SetList(UserId, Vote);
 
+fn parse_enum(comptime E: type, buf: []const u8, base: u8) !E {
+    return @enumFromInt(try std.fmt.parseUnsigned(@typeInfo(E).Enum.tag_type, buf, base));
+}
+
+// https://developer.mozilla.org/en-US/docs/Glossary/Percent-encoding
+fn reencode(text: []const u8) !PostText {
+    var result = try PostText.init(0);
+
+    const len = @min(text.len, 1024); // TODO: PostText length
+
+    var idx: usize = 0;
+    while (idx < len) : (idx += 1) {
+        const c = text[idx];
+        if (c == '+') {
+            try result.append(' ');
+        } else if (c == '%') {
+            // special case of &#...
+            // assume only &#, no &#x
+            if (idx + 6 < text.len and std.mem.eql(u8, text[idx .. idx + 6], "%26%23")) {
+                const num_start = idx + 6;
+                var num_end = num_start;
+                while (num_end < text.len and std.ascii.isDigit(text[num_end])) {
+                    num_end += 1;
+                }
+
+                if (num_end + 2 < text.len and
+                    text[num_end] == '%' and
+                    text[num_end + 1] == '3' and
+                    std.ascii.toLower(text[num_end + 2]) == 'b')
+                {
+                    try std.fmt.format(result.writer(), "&#{s};", .{text[num_start..num_end]});
+                    idx = num_end + 2;
+                    continue;
+                }
+            }
+
+            try std.fmt.format(result.writer(), "&#x{s};", .{text[idx + 1 .. idx + 3]});
+            idx += 2;
+        } else {
+            try result.append(c);
+        }
+    }
+
+    return result;
+}
+
+fn decode(text: []const u8) !std.BoundedArray(u8, 32) {
+    var result = try std.BoundedArray(u8, 32).init(0);
+
+    const max_len = @min(text.len, 1024); // TODO: PostText length
+
+    var idx: usize = 0;
+    var len: usize = 0;
+    while (len < max_len and idx < text.len) : ({
+        idx += 1;
+        len += 1;
+    }) {
+        const c = text[idx];
+        if (c == '+') {
+            try result.append(' ');
+        } else if (c == '%') {
+            if (idx + 2 < text.len) {
+                try std.fmt.format(result.writer(), "{c}", .{try std.fmt.parseUnsigned(u8, text[idx + 1 .. idx + 3], 16)});
+            }
+            idx += 2;
+        } else {
+            try result.append(c);
+        }
+    }
+
+    return result;
+}
+
 const Chirp = struct {
     pub fn hash_password(password: []const u8) !PasswordHash {
         var hash_buffer = try PasswordHash.init(128);
@@ -108,6 +186,7 @@ const Chirp = struct {
 
     pub fn register_user(env: *lmdb.Env, username: []const u8, password: []const u8) !bool {
         const username_array = try Username.fromSlice(username);
+        const display_name = try DisplayName.fromSlice(username);
 
         const txn = try env.txn();
         defer txn.commit() catch {};
@@ -119,13 +198,15 @@ const Chirp = struct {
             return false;
         } else {
             const user_id = try db.Prng.gen(users.dbi, UserId);
-            const posts = try Db.posts(txn);
 
             try users.put(user_id, User{
                 .id = user_id,
                 .name = username_array,
+                .display_name = display_name,
                 .password_hash = try hash_password(password),
-                .posts = try PostList.init(posts.dbi),
+                .posts = try PostList.init(txn),
+                .following = try UserList.init(txn),
+                .followers = try UserList.init(txn),
             });
 
             try user_ids.put(username_array, user_id);
@@ -169,7 +250,7 @@ const Chirp = struct {
         try sessions.del(session_token);
     }
 
-    fn post(env: *lmdb.Env, user_id: UserId, text: []const u8) !void {
+    fn append_post(env: *lmdb.Env, user_id: UserId, post_list: PostList, parent_id: ?PostId, quote_id: ?PostId, text: []const u8) !void {
         var post_id: PostId = undefined;
 
         // TODO: do this in one commit
@@ -182,14 +263,18 @@ const Chirp = struct {
 
             const posts = try Db.posts(txn);
             post_id = try db.Prng.gen(posts.dbi, PostId);
-            const votes = try txn.dbi("votes");
+
+            const decoded_text = try reencode(text);
             try posts.put(post_id, Post{
                 .id = post_id,
+                .parent_id = parent_id,
+                .quote_id = quote_id,
                 .user_id = user_id,
                 .time = std.time.timestamp(),
-                .votes = try VoteList.init(votes),
-                .comments = try PostList.init(posts.dbi),
-                .text = try PostText.fromSlice(text),
+                .votes = try VoteList.init(txn),
+                .comments = try PostList.init(txn),
+                .quotes = try PostList.init(txn),
+                .text = decoded_text,
             });
         }
 
@@ -198,75 +283,114 @@ const Chirp = struct {
             txn = try env.txn();
             defer txn.commit() catch {};
 
-            const users = try Db.users(txn);
-            var user = try users.get(user_id);
+            var posts_view = try post_list.open(txn);
+            try posts_view.append(post_id, {});
+        }
+
+        if (quote_id != null) {
+            txn = try env.txn();
+            defer txn.commit() catch {};
 
             const posts = try Db.posts(txn);
-            var posts_view = try user.posts.open(posts.dbi);
-            try posts_view.append(post_id, {});
+            const quote_post = try posts.get(quote_id.?);
+            var quotes = try quote_post.quotes.open(txn);
+            try quotes.append(post_id, {});
         }
     }
 
+    fn post(env: *lmdb.Env, user_id: UserId, text: []const u8) !void {
+        const txn = try env.txn();
+
+        const users = try Db.users(txn);
+        const user = try users.get(user_id);
+
+        txn.abort();
+
+        try append_post(env, user_id, user.posts, null, null, text);
+    }
+
+    fn comment(env: *lmdb.Env, user_id: UserId, parent_post_id: PostId, text: []const u8) !void {
+        const txn = try env.txn();
+
+        const posts = try Db.posts(txn);
+        const parent_post = try posts.get(parent_post_id);
+
+        txn.abort();
+
+        try append_post(env, user_id, parent_post.comments, parent_post_id, null, text);
+    }
+
+    fn quote(env: *lmdb.Env, user_id: UserId, quote_post_id: PostId, text: []const u8) !void {
+        const txn = try env.txn();
+
+        const users = try Db.users(txn);
+        const user = try users.get(user_id);
+
+        txn.abort();
+
+        try append_post(env, user_id, user.posts, null, quote_post_id, text);
+    }
+
     fn vote(env: *lmdb.Env, post_id: PostId, user_id: UserId, kind: Vote.Kind) !void {
         const txn = try env.txn();
         defer txn.commit() catch {};
 
         const posts = try Db.posts(txn);
-        const votes = try txn.dbi("votes");
 
         var p = try posts.get(post_id);
-        var votes_view = try p.votes.open(votes);
+        var votes_view = try p.votes.open(txn);
+
+        var add_vote = true;
 
         if (try votes_view.has(user_id)) {
             const old_vote = try votes_view.get(user_id);
 
-            if (old_vote.kind == kind) {
-                return;
-            } else {
-                try votes_view.del(user_id);
+            add_vote = old_vote.kind != kind;
 
-                if (old_vote.kind == .Up) {
-                    p.upvotes -= 1;
-                } else {
-                    p.downvotes -= 1;
-                }
-                try posts.put(post_id, p);
+            try votes_view.del(user_id);
+
+            switch (old_vote.kind) {
+                .Up => p.upvotes -= 1,
+                .Down => p.downvotes -= 1,
             }
+            try posts.put(post_id, p);
         }
-        try votes_view.append(user_id, Vote{
-            .kind = kind,
-            .time = std.time.timestamp(),
-        });
 
-        if (kind == .Up) {
-            p.upvotes += 1;
-        } else {
-            p.downvotes += 1;
+        if (add_vote) {
+            try votes_view.append(user_id, Vote{
+                .kind = kind,
+                .time = std.time.timestamp(),
+            });
+
+            if (kind == .Up) {
+                p.upvotes += 1;
+            } else {
+                p.downvotes += 1;
+            }
+            try posts.put(post_id, p);
         }
-        try posts.put(post_id, p);
     }
 
-    fn unvote(env: *lmdb.Env, post_id: PostId, user_id: UserId) !void {
+    fn follow(env: *lmdb.Env, user_id: UserId, user_id_to_follow: UserId) !void {
         const txn = try env.txn();
         defer txn.commit() catch {};
 
-        const posts = try Db.posts(txn);
-        const votes = try txn.dbi("votes");
+        const users = try Db.users(txn);
 
-        var p = try posts.get(post_id);
-        var votes_view = try p.votes.open(votes);
+        const user = try users.get(user_id);
+        const user_to_follow = try users.get(user_id_to_follow);
 
-        if (try votes_view.has(user_id)) {
-            const v = try votes_view.get(user_id);
+        var user_following = try user.following.open(txn);
+        var user_to_follow_followers = try user_to_follow.followers.open(txn);
 
-            if (v.kind == .Up) {
-                p.upvotes -= 1;
-            } else {
-                p.downvotes -= 1;
-            }
-            try posts.put(post_id, p);
-
-            try votes_view.del(user_id);
+        if ((user_following.has(user_id_to_follow) catch false) and (user_to_follow_followers.has(user_id) catch false)) {
+            try user_following.del(user_id_to_follow);
+            try user_to_follow_followers.del(user_id);
+        } else if (!(user_following.has(user_id_to_follow) catch true) and !(user_to_follow_followers.has(user_id) catch true)) {
+            try user_following.append(user_id_to_follow, {});
+            try user_to_follow_followers.append(user_id, {});
+        } else {
+            std.debug.print("Something went wrong when trying to unfollow\n", .{});
         }
     }
 
@@ -292,7 +416,7 @@ const Chirp = struct {
 
 // html {{{
 fn html_form(res: *http.Response, comptime fmt_action: []const u8, args_action: anytype, inputs: anytype) !void {
-    try res.write("<form style=\"display: inline-block!important;\" action=\"", .{});
+    try res.write("<form action=\"", .{});
     try res.write(fmt_action, args_action);
     try res.write("\" method=\"post\">", .{});
 
@@ -316,82 +440,295 @@ fn html_form(res: *http.Response, comptime fmt_action: []const u8, args_action:
 // }}}
 
 // write {{{
+const TimeStr = std.BoundedArray(u8, 256);
+// fn time_str(_t: i64) TimeStr {
+//     var result = TimeStr.init(0) catch unreachable;
+
+//     const nthSecond: u64 = @intCast(_t);
+//     const nthDay = nthSecond / std.time.s_per_day;
+//     const secondInDay = nthSecond - nthDay * std.time.s_per_day;
+//     const hourInDay = secondInDay / std.time.s_per_hour;
+//     const secondInHour = secondInDay % std.time.s_per_hour;
+//     const minuteInHour = secondInHour / 60;
+//     const secondInMinute = secondInDay % std.time.s_per_min;
+
+//     const nthYear = nthDay / 365; // maybe check
+//     const year = 1970 + nthYear;
+//     const leapdays = year / 4 - year / 100 + year / 400;
+//     const secondInYear = nthSecond - (nthYear * 365 + leapdays) * std.time.s_per_day;
+//     const dayInYear = secondInYear / std.time.s_per_day;
+
+//     std.fmt.format(result.writer(), "<time>{:0>2}:{:0>2}:{:0>2} {:0>2}.{:0>2}.{:0>4} UTC</time>", .{
+//         hourInDay,
+//         minuteInHour,
+//         secondInMinute,
+//         dayInYear,
+//         0,
+//         year,
+//     }) catch unreachable;
+
+//     return result;
+// }
+// http://howardhinnant.github.io/date_algorithms.html
+fn time_str(_t: i64) TimeStr {
+    const t: u64 = @intCast(_t);
+    var result = TimeStr.init(0) catch unreachable;
+
+    const nD = @divFloor(t, std.time.s_per_day);
+    const z: u64 = nD + 719468;
+    const era: u64 = (if (z >= 0) z else z - 146096) / 146097;
+    const doe: u64 = z - era * 146097; // [0, 146096]
+    const yoe: u64 = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
+    const Y: u64 = yoe + era * 400;
+    const doy: u64 = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
+    const mp: u64 = (5 * doy + 2) / 153; // [0, 11]
+    const D: u64 = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
+    const M: u64 = if (mp < 10) mp + 3 else mp - 9;
+
+    const h: u64 = @divFloor(t - nD * std.time.s_per_day, std.time.s_per_hour);
+    const m: u64 = @divFloor(t - nD * std.time.s_per_day - h * std.time.s_per_hour, std.time.s_per_min);
+    const s: u64 = t - nD * std.time.s_per_day - h * std.time.s_per_hour - m * std.time.s_per_min;
+
+    std.fmt.format(result.writer(), "<time><span>{:0>4}-{:0>2}-{:0>2} {:0>2}:{:0>2}:{:0>2} UTC</span><script>document.currentScript.parentElement.innerHTML=new Date({}).toLocaleString()</script></time>", .{ Y, M, D, h, m, s, t * 1000 }) catch unreachable;
+
+    return result;
+}
 fn write_header(res: *http.Response, logged_in: ?Login) !void {
     if (logged_in) |login| {
         try res.write(
-            \\<a href="/user/{s}">Home</a><br />
+            \\<a href="/">Home</a><br />
+        , .{});
+        try res.write(
+            \\<a href="/user/{s}">Profile</a><br />
         , .{login.user.name.constSlice()});
+        try res.write(
+            \\<a href="/post">Post</a><br />
+        , .{});
+        try res.write(
+            \\<a href="/edit">Edit</a><br />
+        , .{});
         try html_form(res, "/logout", .{}, .{
             \\type="submit" value="Logout"
         });
         try html_form(res, "/quit", .{}, .{
             \\type="submit" value="Quit"
         });
-        try res.write("<br />", .{});
-        try html_form(res, "/post", .{}, .{
-            \\type="text" name="text"
-            ,
-            \\type="submit" value="Post"
-        });
+        try res.write("<br /><br />", .{});
     } else {
         try res.write(
             \\<a href="/">Home</a><br />
-            \\<a href="/register">Register</a>
-            \\<a href="/login">Login</a><br />
+            \\<form action="/register" method="post">
+            \\<input type="text" name="username" />
+            \\<input type="password" name="password" />
+            \\<input type="submit" value="Register" />
+            \\</form>
+            \\<form action="/login" method="post">
+            \\<input type="text" name="username" />
+            \\<input type="password" name="password" />
+            \\<input type="submit" value="Login" />
+            \\</form>
         , .{});
         try html_form(res, "/quit", .{}, .{
             \\type="submit" value="Quit"
         });
+        try res.write("<br /><br />", .{});
     }
 }
-fn write_posts(res: *http.Response, txn: lmdb.Txn, user: User, login: ?Login) !void {
-    const votes_dbi = try txn.dbi("votes");
+fn write_start(res: *http.Response) !void {
+    try res.write(
+        \\<!doctype html>
+        \\<html>
+        \\<head>
+        \\<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>&#128035;</text></svg>">
+        \\<style>
+        \\  form {
+        \\    display: inline-block;
+        \\  }
+        \\  .toggle > form > input:placeholder-shown {
+        \\    display: none;
+        \\  }
+        \\  .toggle:hover > form > input {
+        \\    display: inline-block;
+        \\  }
+        \\  .toggle > form > input:placeholder-shown + input {
+        \\    display: none;
+        \\  }
+        \\  .toggle:hover > form > input + input {
+        \\    display: inline-block;
+        \\  }
+        \\</style>
+        \\</head>
+        \\<body>
+    , .{});
+}
+fn write_end(res: *http.Response) !void {
+    try res.write("</body></html>", .{});
+}
+fn write_post(res: *http.Response, txn: lmdb.Txn, logged_in: ?Login, post_id: PostId, recurse: enum { No, Once, Yes }) !void {
     const posts = try Db.posts(txn);
-    const posts_view = try user.posts.open(posts.dbi);
+    const post = posts.get(post_id) catch {
+        res.redirect("/") catch {};
+        return;
+    };
+    const users = try Db.users(txn);
+    const user = try users.get(post.user_id);
+
+    try res.write(
+        \\<div>
+        \\<span><a href="/user/{s}">{s}</a> {s}</span><br />
+        \\<span>{s}</span><br />
+    , .{ user.name.constSlice(), user.display_name.constSlice(), time_str(post.time).constSlice(), post.text.constSlice() });
+
+    if (recurse != .No and post.quote_id != null) {
+        try res.write("<div style=\"border: 1px solid black;\">", .{});
+        try write_post(res, txn, logged_in, post.quote_id.?, .No);
+        try res.write("</div>", .{});
+    }
 
-    var it = posts_view.iterator();
+    const comments_view = try post.comments.open(txn);
+    const quotes_view = try post.quotes.open(txn);
+    const votes_view = try post.votes.open(txn);
+
+    // Votes
+    const vote: ?Vote = if (logged_in != null and try votes_view.has(logged_in.?.user.id)) try votes_view.get(logged_in.?.user.id) else null;
+
+    if (vote != null and vote.?.kind == .Up) {
+        try html_form(res, "/upvote", .{}, .{
+            .{ "type=\"hidden\" value=\"{x}\" name=\"post_id\"", .{@intFromEnum(post.id)} },
+            .{ "type=\"submit\" value=\"&#x2B06; {}\"", .{post.upvotes} },
+        });
+    } else {
+        try html_form(res, "/upvote", .{}, .{
+            .{ "type=\"hidden\" value=\"{x}\" name=\"post_id\"", .{@intFromEnum(post.id)} },
+            .{ "type=\"submit\" value=\"&#x21E7; {}\"", .{post.upvotes} },
+        });
+    }
+    if (vote != null and vote.?.kind == .Down) {
+        try html_form(res, "/downvote", .{}, .{
+            .{ "type=\"hidden\" value=\"{x}\" name=\"post_id\"", .{@intFromEnum(post.id)} },
+            .{ "type=\"submit\" value=\"&#x2B07; {}\"", .{post.downvotes} },
+        });
+    } else {
+        try html_form(res, "/downvote", .{}, .{
+            .{ "type=\"hidden\" value=\"{x}\" name=\"post_id\"", .{@intFromEnum(post.id)} },
+            .{ "type=\"submit\" value=\"&#x21E9; {}\"", .{post.downvotes} },
+        });
+    }
+
+    // Comment Count
+    try res.write(
+        \\<span class="toggle">
+        \\<a href="/post/{x}">&#x1F4AD; {}</a> 
+    , .{ @intFromEnum(post.id), comments_view.len() });
+    if (logged_in != null) {
+        try html_form(res, "/comment", .{}, .{
+            .{ "type=\"hidden\" value=\"{x}\" name=\"post_id\"", .{@intFromEnum(post.id)} },
+            "type=\"text\" name=\"text\" placeholder=\"Text\"",
+            "type=\"submit\" value=\"Comment\"",
+        });
+    }
+    try res.write(
+        \\</span>
+    , .{});
+
+    // Quote
+    try res.write(
+        \\<span class="toggle">
+        \\<a href="/quotes/{x}">&#x1F501; {}</a> 
+    , .{ @intFromEnum(post.id), quotes_view.len() });
+    try html_form(res, "/quote", .{}, .{
+        .{ "type=\"hidden\" value=\"{x}\" name=\"post_id\"", .{@intFromEnum(post.id)} },
+        "type=\"text\" name=\"text\" placeholder=\"Text\"",
+        "type=\"submit\" value=\"Quote\"",
+    });
+    try res.write(
+        \\</span>
+        \\<br />
+    , .{});
+
+    // Comments
+    if (recurse != .No and comments_view.len() > 0) {
+        try res.write(
+            \\<details>
+            \\<summary>Comments</summary>
+        , .{});
+        try res.write("<div style=\"margin: 10px;\">", .{});
+        var it = comments_view.iterator();
+        var count: u8 = 0;
+        while (it.next()) |kv| {
+            try write_post(res, txn, logged_in, kv.key, switch (recurse) {
+                .Yes => .Yes,
+                .Once => .No,
+                else => unreachable,
+            });
+            try res.write("<br />", .{});
+            if (recurse == .Once) {
+                count += 1;
+                if (count >= 3) break;
+            }
+        }
+        try res.write(
+            \\</div>
+            \\</details>
+        , .{});
+    }
+
+    try res.write(
+        \\</div>
+    , .{});
+}
+fn write_posts(res: *http.Response, txn: lmdb.Txn, logged_in: ?Login, user: User) !void {
+    const posts_view = try user.posts.open(txn);
+
+    var it = posts_view.reverse_iterator();
     while (it.next()) |kv| {
         const post_id = kv.key;
-        const post = try posts.get(post_id);
 
-        try res.write(
-            \\<div>
-            \\<p>{s}</p>
-        , .{post.text.constSlice()});
+        try write_post(res, txn, logged_in, post_id, .Once);
+        try res.write("<br />", .{});
+    }
+}
+fn write_timeline(res: *http.Response, txn: lmdb.Txn, logged_in: ?Login, user: User) !void {
+    const users = try Db.users(txn);
+    const posts = try Db.posts(txn);
 
-        const votes_view = try post.votes.open(votes_dbi);
-        const comments_view = try post.comments.open(posts.dbi);
+    var newest_post_ids = try std.BoundedArray(PostId, 10).init(0); // TODO: TimelinePostsCount
+    var prev_newest_post: ?Post = null;
 
-        var has_voted: ?Vote.Kind = null;
+    const following = try user.following.open(txn);
 
-        if (login != null and try votes_view.has(login.?.user_id)) {
-            const vote = try votes_view.get(login.?.user_id);
+    while (true) {
+        var newest_post: ?Post = null;
 
-            has_voted = vote.kind;
-        }
+        var it = following.iterator();
+        while (it.next()) |kv| {
+            const followed_user = try users.get(kv.key);
+            const followed_posts = try followed_user.posts.open(txn);
 
-        if (has_voted != null and has_voted.? == .Up) {
-            try html_form(res, "/unupvote/{}", .{@intFromEnum(post_id)}, .{
-                .{ "type=\"submit\" value=\"&#x2B06; {}\"", .{post.upvotes} },
-            });
-        } else {
-            try html_form(res, "/upvote/{}", .{@intFromEnum(post_id)}, .{
-                .{ "type=\"submit\" value=\"&#x2B06; {}\"", .{post.upvotes} },
-            });
+            if (followed_posts.len() == 0) {
+                continue;
+            }
+
+            var followed_posts_it = followed_posts.reverse_iterator();
+            while (followed_posts_it.next()) |followed_post_kv| {
+                const last_post = try posts.get(followed_post_kv.key);
+
+                if ((prev_newest_post == null or last_post.time < prev_newest_post.?.time) and (newest_post == null or newest_post.?.time < last_post.time)) {
+                    newest_post = last_post;
+                }
+            }
         }
-        if (has_voted != null and has_voted.? == .Down) {
-            try html_form(res, "/undownvote/{}", .{@intFromEnum(post_id)}, .{
-                .{ "type=\"submit\" value=\"&#x2B07; {}\"", .{post.downvotes} },
-            });
+        if (newest_post) |post| {
+            newest_post_ids.append(post.id) catch break;
+            prev_newest_post = post;
         } else {
-            try html_form(res, "/downvote/{}", .{@intFromEnum(post_id)}, .{
-                .{ "type=\"submit\" value=\"&#x2B07; {}\"", .{post.downvotes} },
-            });
+            break;
         }
-        try res.write(
-            \\<span>&#x1F4AD; {}</span>
-            \\</div>
-        , .{comments_view.len()});
+    }
+
+    for (newest_post_ids.constSlice()) |post_id| {
+        try write_post(res, txn, logged_in, post_id, .Once);
+        try res.write("<br />", .{});
     }
 }
 // }}}
@@ -452,8 +789,8 @@ fn list_posts(env: lmdb.Env) !void {
 }
 
 const ReqBufferSize = 4096;
-const ResHeadBufferSize = 1024 * 16;
-const ResBodyBufferSize = 1024 * 16;
+const ResHeadBufferSize = 1024 * 64;
+const ResBodyBufferSize = 1024 * 64;
 
 pub fn main() !void {
     // server
@@ -464,14 +801,14 @@ pub fn main() !void {
     var env = try lmdb.Env.open("db", 1024 * 1024 * 10);
     defer env.close();
 
-    // std.debug.print("Users:\n", .{});
-    // try list_users(env);
-    // std.debug.print("User IDs:\n", .{});
-    // try list_user_ids(env);
-    // std.debug.print("Sessions:\n", .{});
-    // try list_sessions(env);
-    // std.debug.print("Posts:\n", .{});
-    // try list_posts(env);
+    std.debug.print("Users:\n", .{});
+    try list_users(env);
+    std.debug.print("User IDs:\n", .{});
+    try list_user_ids(env);
+    std.debug.print("Sessions:\n", .{});
+    try list_sessions(env);
+    std.debug.print("Posts:\n", .{});
+    try list_posts(env);
 
     try handle_connection(&server, &env);
     // const ThreadCount = 1;
@@ -496,7 +833,8 @@ fn handle_connection(server: *http.Server, env: *lmdb.Env) !void {
     accept: while (true) {
         server.wait();
 
-        while (try server.next_request(&req_buffer)) |req| {
+        while (try server.next_request(&req_buffer)) |*_req| {
+            var req: *http.Request = @constCast(_req);
             // std.debug.print("[{}]: {s}\n", .{ req.method, req.target });
 
             // reponse
@@ -506,22 +844,26 @@ fn handle_connection(server: *http.Server, env: *lmdb.Env) !void {
             var logged_in: ?Login = null;
 
             if (req.get_cookie("session_token")) |session_token_str| {
-                const session_token = try std.fmt.parseUnsigned(SessionToken, session_token_str, 16);
-                // const session_token = try std.fmt.parseUnsigned(SessionToken, session_token_str, 10);
-                // const session_token = std.mem.bytesToValue(SessionToken, session_token_str);
-                if (Chirp.get_session_user_id(env, session_token)) |user_id| {
-                    const txn = try env.txn();
-                    defer txn.abort();
-                    const users = try Db.users(txn);
+                var remove_session_token = true;
 
-                    logged_in = .{
-                        .user = try users.get(user_id),
-                        .user_id = user_id,
-                        .session_token = session_token,
-                    };
-                } else |err| {
-                    std.debug.print("get_session_user err: {}\n", .{err});
+                if (std.fmt.parseUnsigned(SessionToken, session_token_str, 16) catch null) |session_token| {
+                    // const session_token = try std.fmt.parseUnsigned(SessionToken, session_token_str, 10);
+                    // const session_token = std.mem.bytesToValue(SessionToken, session_token_str);
+                    if (Chirp.get_session_user_id(env, session_token) catch null) |user_id| {
+                        const txn = try env.txn();
+                        defer txn.abort();
+                        const users = try Db.users(txn);
+
+                        logged_in = .{
+                            .user = try users.get(user_id),
+                            .session_token = session_token,
+                        };
+
+                        remove_session_token = false;
+                    }
+                }
 
+                if (remove_session_token) {
                     try res.add_header(
                         "Set-Cookie",
                         .{"session_token=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT"},
@@ -529,60 +871,177 @@ fn handle_connection(server: *http.Server, env: *lmdb.Env) !void {
                 }
             }
 
+            // TODO: refactor into functions
+            // TODO: make sure we send a reply
+
             // html
             if (req.method == .GET) {
+                try write_start(&res);
                 try write_header(&res, logged_in);
 
-                if (std.mem.eql(u8, req.target, "/register")) {
-                    try res.write(
-                        \\<form action="/register" method="post">
-                        \\<input type="text" name="username" />
-                        \\<input type="password" name="password" />
-                        \\<input type="submit" value="Register" />
-                        \\</form>
-                    , .{});
-                    try res.send();
-                } else if (std.mem.eql(u8, req.target, "/login")) {
-                    try res.write(
-                        \\<form action="/login" method="post">
-                        \\<input type="text" name="username" />
-                        \\<input type="password" name="password" />
-                        \\<input type="submit" value="Login" />
-                        \\</form>
-                    , .{});
-                    try res.send();
-                } else if (std.mem.startsWith(u8, req.target, "/user/")) {
+                const txn = try env.txn();
+                defer txn.abort();
+
+                if (std.mem.startsWith(u8, req.target, "/user/")) {
                     const username = req.target[6..req.target.len];
 
-                    const txn = try env.txn();
-                    defer txn.abort();
+                    const user_ids = try Db.user_ids(txn);
+                    if (user_ids.get(try Username.fromSlice(username))) |user_id| {
+                        const users = try Db.users(txn);
+                        const user = try users.get(user_id);
+
+                        const following = try user.following.open(txn);
+                        const followers = try user.followers.open(txn);
+
+                        try res.write(
+                            \\<a href="/user/{s}">{s}</a>
+                        , .{
+                            user.name.constSlice(), user.display_name.constSlice(),
+                        });
+                        if (logged_in != null and user_id != logged_in.?.user.id) {
+                            if (try followers.has(logged_in.?.user.id)) {
+                                try html_form(&res, "/follow", .{}, .{
+                                    .{ "type=\"hidden\" name=\"user_id\" value=\"{x}\"", .{@intFromEnum(user_id)} },
+                                    \\type="submit" value="Unfollow"
+                                });
+                            } else {
+                                try html_form(&res, "/follow", .{}, .{
+                                    .{ "type=\"hidden\" name=\"user_id\" value=\"{x}\"", .{@intFromEnum(user_id)} },
+                                    \\type="submit" value="Follow"
+                                });
+                            }
+                        }
+                        try res.write(
+                            \\ <a href="/following/{s}">{} following</a>
+                            \\ <a href="/followers/{s}">{} followers</a>
+                        , .{
+                            user.name.constSlice(), following.len(),
+                            user.name.constSlice(), followers.len(),
+                        });
+
+                        try write_posts(&res, txn, logged_in, user);
+                    } else |err| {
+                        try res.write(
+                            \\<p>User not found [{}]</p>
+                        , .{err});
+                    }
+                } else if (std.mem.startsWith(u8, req.target, "/following/")) {
+                    const username = req.target[11..req.target.len];
 
                     const user_ids = try Db.user_ids(txn);
                     if (user_ids.get(try Username.fromSlice(username))) |user_id| {
                         const users = try Db.users(txn);
                         const user = try users.get(user_id);
-                        try write_posts(&res, txn, user, logged_in);
+
+                        const following = try user.following.open(txn);
+                        var it = following.iterator();
+
+                        try res.write(
+                            \\<h2><a href="/user/{s}">{s}</a> follows:</h2>
+                        , .{ user.name.constSlice(), user.display_name.constSlice() });
+
+                        while (it.next()) |kv| {
+                            const following_user = try users.get(kv.key);
+
+                            try res.write(
+                                \\<a href="/user/{s}">{s}</a><br />
+                            , .{ following_user.name.constSlice(), following_user.display_name.constSlice() });
+                        }
                     } else |err| {
                         try res.write(
                             \\<p>User not found [{}]</p>
                         , .{err});
                     }
-                    try res.send();
-                } else {
-                    if (logged_in) |login| {
-                        const user = try Chirp.get_user(env, login.user_id);
+                } else if (std.mem.startsWith(u8, req.target, "/followers/")) {
+                    const username = req.target[11..req.target.len];
 
-                        const txn = try env.txn();
-                        defer txn.abort();
+                    const user_ids = try Db.user_ids(txn);
+                    if (user_ids.get(try Username.fromSlice(username))) |user_id| {
+                        const users = try Db.users(txn);
+                        const user = try users.get(user_id);
+
+                        const followers = try user.followers.open(txn);
+                        var it = followers.iterator();
+
+                        try res.write(
+                            \\<h2><a href="/user/{s}">{s}</a> followers:</h2>
+                        , .{ user.name.constSlice(), user.display_name.constSlice() });
+
+                        while (it.next()) |kv| {
+                            const follower_user = try users.get(kv.key);
 
-                        try write_posts(&res, txn, user, logged_in);
+                            try res.write(
+                                \\<a href="/user/{s}">{s}</a><br />
+                            , .{ follower_user.name.constSlice(), follower_user.display_name.constSlice() });
+                        }
+                    } else |err| {
+                        try res.write(
+                            \\<p>User not found [{}]</p>
+                        , .{err});
+                    }
+                } else if (std.mem.startsWith(u8, req.target, "/post/")) {
+                    const post_id_str = req.target[6..req.target.len];
+                    const post_id = try parse_enum(PostId, post_id_str, 16);
+
+                    try write_post(&res, txn, logged_in, post_id, .Yes);
+                } else if (std.mem.startsWith(u8, req.target, "/quotes/")) {
+                    const post_id_str = req.target[8..req.target.len];
+                    const post_id = try parse_enum(PostId, post_id_str, 16);
 
-                        try res.send();
+                    const posts = try Db.posts(txn);
+                    const post = try posts.get(post_id);
+
+                    const quotes_view = try post.quotes.open(txn);
+                    var it = quotes_view.iterator();
+                    while (it.next()) |kv| {
+                        try write_post(&res, txn, logged_in, kv.key, .Once);
+                        try res.write("<br />", .{});
+                    }
+                } else if (std.mem.eql(u8, req.target, "/post")) {
+                    if (logged_in) |login| {
+                        _ = login;
+                        const referer = if (req.get_header("Referer")) |ref| ref else "/post";
+
+                        try html_form(&res, "/post", .{}, .{
+                            .{ "type=\"hidden\" name=\"referer\" value=\"{s}\"", .{referer} },
+                            "type=\"text\" name=\"text\"",
+                            "type=\"submit\" value=\"Post\"",
+                        });
                     } else {
-                        try res.write("[GET] {s}", .{req.target});
-                        try res.send();
+                        try res.write("not logged in", .{});
                     }
+                } else if (std.mem.eql(u8, req.target, "/edit")) {
+                    if (logged_in) |login| {
+                        try res.write("<br />Username: ", .{});
+                        try html_form(&res, "/set_username", .{}, .{
+                            .{ "type=\"text\" name=\"username\" placeholder=\"{s}\"", .{login.user.name.constSlice()} },
+                            "type=\"submit\" value=\"Change\"",
+                        });
+                        try res.write("<br />Display Name: ", .{});
+                        try html_form(&res, "/set_display_name", .{}, .{
+                            .{ "type=\"text\" name=\"display_name\" placeholder=\"{s}\"", .{login.user.display_name.constSlice()} },
+                            "type=\"submit\" value=\"Change\"",
+                        });
+                        try res.write("<br />Password: ", .{});
+                        try html_form(&res, "/set_password", .{}, .{
+                            "type=\"text\" name=\"password\"",
+                            "type=\"submit\" value=\"Change\"",
+                        });
+                    } else {
+                        try res.write("not logged in", .{});
+                    }
+                } else if (std.mem.eql(u8, req.target, "/")) {
+                    if (logged_in) |login| {
+                        try write_timeline(&res, txn, logged_in, login.user);
+                    } else {
+                        // TODO: generic home
+                        try res.write("Homepage", .{});
+                    }
+                } else {
+                    try res.redirect("/");
                 }
+                try write_end(&res);
+                try res.send();
             }
             // api
             else {
@@ -592,12 +1051,7 @@ fn handle_connection(server: *http.Server, env: *lmdb.Env) !void {
                     const password = req.get_value("password").?;
 
                     std.debug.print("New user: {s} {s}\n", .{ username, password });
-                    if (try Chirp.register_user(env, username, password)) {
-                        try res.redirect("/login");
-                    } else {
-                        try res.redirect("/register");
-                    }
-                    try res.send();
+                    _ = try Chirp.register_user(env, username, password);
                 } else if (std.mem.eql(u8, req.target, "/login")) {
                     // TODO: handle args not supplied
                     const username = req.get_value("username").?;
@@ -606,20 +1060,12 @@ fn handle_connection(server: *http.Server, env: *lmdb.Env) !void {
                     std.debug.print("New login: {s} {s}\n", .{ username, password });
                     if (Chirp.login_user(env, username, password)) |session_token| {
                         res.status = .see_other;
-                        try res.add_header(
-                            "Location",
-                            .{ "/user/{s}", .{username} },
-                        );
                         try res.add_header(
                             "Set-Cookie",
-                            .{ "session_token={x}; Secure; HttpOnly", .{session_token} },
+                            .{ "session_token={x}; HttpOnly", .{session_token} },
                         );
-
-                        try res.send();
                     } else |err| {
                         std.debug.print("login_user err: {}\n", .{err});
-                        try res.redirect("/login");
-                        try res.send();
                     }
                 } else if (std.mem.eql(u8, req.target, "/logout")) {
                     if (logged_in) |login| {
@@ -629,78 +1075,116 @@ fn handle_connection(server: *http.Server, env: *lmdb.Env) !void {
                             "Set-Cookie",
                             .{"session_token=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT"},
                         );
-
-                        try res.redirect("/");
-                        try res.send();
-                    }
-                } else if (std.mem.eql(u8, req.target, "/post")) {
-                    if (logged_in) |login| {
-                        const text = req.get_value("text").?;
-                        try Chirp.post(env, login.user_id, text);
-
-                        try res.redirect("/");
-                        try res.send();
                     }
-                } else if (std.mem.eql(u8, req.target, "/quit")) {
-                    try res.redirect("/");
-                    try res.send();
-                    break :accept;
-                } else if (std.mem.startsWith(u8, req.target, "/upvote/")) {
+                } else if (std.mem.eql(u8, req.target, "/set_username")) {
                     const login = logged_in orelse return error.NotLoggedIn;
+                    const username_str = req.get_value("username") orelse return error.NoUsername;
+                    const username = try Username.fromSlice(username_str);
 
-                    const post_id_str = req.target[8..req.target.len];
-                    const post_id: PostId = @enumFromInt(try std.fmt.parseUnsigned(u64, post_id_str, 10));
+                    const txn = try env.txn();
+                    defer txn.commit() catch {};
 
-                    try Chirp.vote(env, post_id, login.user_id, .Up);
+                    const user_ids = try Db.user_ids(txn);
 
-                    if (req.get_header("Referer")) |ref| {
-                        try res.redirect(ref);
+                    if (!try user_ids.has(username)) {
+                        try user_ids.del(login.user.name);
+                        try user_ids.put(username, login.user.id);
+
+                        const users = try Db.users(txn);
+                        var user = login.user;
+                        user.name = username;
+                        try users.put(login.user.id, user);
                     }
-                    try res.send();
-                } else if (std.mem.startsWith(u8, req.target, "/downvote/")) {
+                } else if (std.mem.eql(u8, req.target, "/set_display_name")) {
                     const login = logged_in orelse return error.NotLoggedIn;
+                    const display_name_str = req.get_value("display_name") orelse return error.NoDisplayName;
+                    const display_name = try DisplayName.fromSlice(display_name_str);
 
-                    const post_id_str = req.target[10..req.target.len];
-                    const post_id: PostId = @enumFromInt(try std.fmt.parseUnsigned(u64, post_id_str, 10));
-
-                    try Chirp.vote(env, post_id, login.user_id, .Down);
+                    const txn = try env.txn();
+                    defer txn.commit() catch {};
 
-                    if (req.get_header("Referer")) |ref| {
-                        try res.redirect(ref);
-                    }
-                    try res.send();
-                } else if (std.mem.startsWith(u8, req.target, "/unupvote/")) {
-                    // TODO: maybe move to one /unvote?
+                    const users = try Db.users(txn);
+                    var user = login.user;
+                    user.display_name = display_name;
+                    try users.put(login.user.id, user);
+                } else if (std.mem.eql(u8, req.target, "/set_password")) {
                     const login = logged_in orelse return error.NotLoggedIn;
+                    const password_str = req.get_value("password") orelse return error.NoPassword;
 
-                    const post_id_str = req.target[10..req.target.len];
-                    const post_id: PostId = @enumFromInt(try std.fmt.parseUnsigned(u64, post_id_str, 10));
+                    const txn = try env.txn();
+                    defer txn.commit() catch {};
 
-                    try Chirp.unvote(env, post_id, login.user_id);
+                    const users = try Db.users(txn);
+                    var user = login.user;
+                    user.password_hash = try Chirp.hash_password(password_str);
+                    try users.put(login.user.id, user);
+                } else if (std.mem.eql(u8, req.target, "/post")) {
+                    if (logged_in) |login| {
+                        const text = req.get_value("text").?;
+                        const has_referer = req.get_value("referer");
+                        try Chirp.post(env, login.user.id, text);
+                        if (has_referer) |r| {
+                            const decoded = try decode(r);
+                            try res.redirect(decoded.constSlice());
+                        }
+                    }
+                } else if (std.mem.eql(u8, req.target, "/comment")) {
+                    if (logged_in) |login| {
+                        const text = req.get_value("text") orelse return error.NoText;
+                        const post_id_str = req.get_value("post_id") orelse return error.NoPostId;
+                        const post_id = try parse_enum(PostId, post_id_str, 16);
 
-                    if (req.get_header("Referer")) |ref| {
-                        try res.redirect(ref);
+                        try Chirp.comment(env, login.user.id, post_id, text);
                     }
-                    try res.send();
-                } else if (std.mem.startsWith(u8, req.target, "/undownvote/")) {
+                } else if (std.mem.eql(u8, req.target, "/quote")) {
+                    if (logged_in) |login| {
+                        const text = req.get_value("text") orelse return error.NoText;
+                        const post_id_str = req.get_value("post_id") orelse return error.NoPostId;
+                        const post_id = try parse_enum(PostId, post_id_str, 16);
+
+                        try Chirp.quote(env, login.user.id, post_id, text);
+                    }
+                } else if (std.mem.eql(u8, req.target, "/upvote")) {
                     const login = logged_in orelse return error.NotLoggedIn;
 
-                    const post_id_str = req.target[12..req.target.len];
-                    const post_id: PostId = @enumFromInt(try std.fmt.parseUnsigned(u64, post_id_str, 10));
+                    const post_id_str = req.get_value("post_id") orelse return error.NoPostId;
+                    const post_id: PostId = @enumFromInt(try std.fmt.parseUnsigned(u64, post_id_str, 16));
 
-                    try Chirp.unvote(env, post_id, login.user_id);
+                    try Chirp.vote(env, post_id, login.user.id, .Up);
+                } else if (std.mem.eql(u8, req.target, "/downvote")) {
+                    const login = logged_in orelse return error.NotLoggedIn;
 
+                    const post_id_str = req.get_value("post_id") orelse return error.NoPostId;
+                    const post_id: PostId = @enumFromInt(try std.fmt.parseUnsigned(u64, post_id_str, 16));
+
+                    try Chirp.vote(env, post_id, login.user.id, .Down);
+                } else if (std.mem.eql(u8, req.target, "/follow")) {
+                    const login = logged_in orelse return error.NotLoggedIn;
+
+                    const user_id_str = req.get_value("user_id") orelse return error.NoUserId;
+                    const user_id: UserId = @enumFromInt(try std.fmt.parseUnsigned(u64, user_id_str, 16));
+
+                    try Chirp.follow(env, login.user.id, user_id);
+                } else if (std.mem.eql(u8, req.target, "/quit")) {
                     if (req.get_header("Referer")) |ref| {
                         try res.redirect(ref);
+                    } else {
+                        try res.redirect("/");
                     }
                     try res.send();
+                    break :accept;
                 } else {
-                    // try req.respond(
-                    //     \\<p>POST</p>
-                    // , .{});
                     try res.write("<p>[POST] {s}</p>", .{req.target});
-                    try res.send();
                 }
+
+                if (!res.has_header("Location")) {
+                    if (req.get_header("Referer")) |ref| {
+                        try res.redirect(ref);
+                    } else {
+                        try res.redirect("/");
+                    }
+                }
+                try res.send();
             }
         }
     }
diff --git a/todo.md b/todo.md
index 1ec237ac5b0fe0d6be82c4a8245abc72d165beff..46259dd13d96ce46fbaeba82a651acbc0124ef89 100644 (file)
--- a/todo.md
+++ b/todo.md
@@ -1,22 +1,26 @@
-## Todo
-- lmdb
-  - get maybe !? weil nicht vorhanden und fehler unterschiedlich behandelt werden sollten
-  - Listen in eigene dbi weil dbi's sonst nicht sinnvoll iteriert werden können
-- Anstatt Posts in posts dbi, vielleicht ownen die PostList's der User die Posts?
-  - aber dann können wir wieder nicht iterieren...
-  - anhand der Key Länge unterscheiden zw. SetList und Item und Post?
-  - alternativ eigene dbi's für SetList, item und dann posts, users usw.
-- Change respond (respondoptions -> request, fmt+args)
-- Rewrite get_value
+# Todo
+
+## Technical
 - Account
-  - Change username
-  - Change password
   - Public-Key-Crypto
-- Home
-- Post
-- Comment
-- Like
-- Repost
-- Timeline
-  - Follow
 - TUI
+
+## Functional
+- maybe instead of hovering to quote/comment, only allow on post link and show statically
+  - post on separate link
+  - quote on separate link
+- maintain scroll
+- search users
+- search posts
+- @ users
+- save posts
+- create views
+- profile description
+- custom css
+
+## Optional
+- images (media)
+- trending page
+- DMs
+- Limit Posts/Comments and paginate
+- Calculate max buffer sizes