Add post voting

This commit is contained in:
2024-10-16 17:52:50 +02:00
parent df926c815e
commit ffbbc709ce
12 changed files with 4237 additions and 49 deletions
+1 -1
View File
@@ -34,7 +34,7 @@ request_timeout_sec 5
access_log yes access_log yes
# These are debug options # These are debug options
show_io yes show_io no
show_requests no show_requests no
# Address and port the HTTP server will listen on. # Address and port the HTTP server will listen on.
+4 -2
View File
@@ -3,7 +3,7 @@
<title>Log in</title> <title>Log in</title>
<link rel="stylesheet" media="screen" href="/static/global.css" /> <link rel="stylesheet" media="screen" href="/static/global.css" />
</head> </head>
<body> <body hx-boost="true">
<main> <main>
<nav> <nav>
<table> <table>
@@ -20,7 +20,9 @@
</table> </table>
</nav> </nav>
<form method="POST" action="/action/login"> <script src="/static/htmx.js"></script>
<div id="login-error-message"></div>
<form hx-post="/action/login" hx-target="#login-error-message">
<input type="text" name="name" placeholder="name" /> <input type="text" name="name" placeholder="name" />
<input type="password" name="pass" placeholder="pass" /> <input type="password" name="pass" placeholder="pass" />
<input type="submit" value="login" /> <input type="submit" value="login" />
+8 -7
View File
@@ -3,7 +3,7 @@
<title>{{title}}</title> <title>{{title}}</title>
<link rel="stylesheet" media="screen" href="/static/global.css" /> <link rel="stylesheet" media="screen" href="/static/global.css" />
</head> </head>
<body> <body hx-boost="true">
<main> <main>
<nav> <nav>
<table> <table>
@@ -26,8 +26,11 @@
</nav> </nav>
<article> <article>
<div class="post">
<h1>{{title}} (by {{author}})</h1> <h1>{{title}} (by {{author}})</h1>
<p>{{content}}</p> <p>{{content}}</p>
</div>
{% if login %} {% if login %}
<form action="/posts/{{id}}/comments" method="POST"> <form action="/posts/{{id}}/comments" method="POST">
@@ -36,14 +39,12 @@
</form> </form>
{% end %} {% end %}
<table>
{% for comment in comments %} {% for comment in comments %}
<tr> <div class="comment">
<td>{{comment.author}}:</td> <a href="/users/{{comment.author}}">{{comment.author}}</a>:
<td>{{comment.content}}</td> {{comment.content}}
</tr> </div>
{% end %} {% end %}
</table>
</article> </article>
</main> </main>
</body> </body>
+45 -10
View File
@@ -3,7 +3,7 @@
<title>Posts</title> <title>Posts</title>
<link rel="stylesheet" media="screen" href="/static/global.css" /> <link rel="stylesheet" media="screen" href="/static/global.css" />
</head> </head>
<body> <body hx-boost="true">
<main> <main>
<nav> <nav>
<table> <table>
@@ -15,6 +15,7 @@
<table> <table>
<tr> <tr>
{% if login %} {% if login %}
<td><a href="#" id="button-create-post">create post</a></td>
<td><a href="/users/{{login_username}}">{{login_username}}</a></td> <td><a href="/users/{{login_username}}">{{login_username}}</a></td>
<td><a href="/action/logout">logout</a></td> <td><a href="/action/logout">logout</a></td>
{% else %} {% else %}
@@ -25,19 +26,53 @@
</table> </table>
</nav> </nav>
{% if login %}
<form action="/action/post" method="POST">
<input type="text" name="title" placeholder="title" />
<textarea name="content" placeholder="content"></textarea>
<input type="submit" value="publish" />
</form>
{% end %}
{% for post in posts %} {% for post in posts %}
<div class="post-preview"> <div class="post-preview">
<a href="/posts/{{post.id}}">{{post.title}}</a> by <a href="/users/{{post.author}}">{{post.author}}</a> <div class="post-preview-title">
<a href="/posts/{{post.id}}">
{{post.title}}
</a>
</div>
<div class="post-preview-details">
<table>
<tr>
<td>
<a hx-post="/posts/{{post.id}}/upvotes" hx-target='.post-preview-details' hx-swap='outerHTML'>{{post.upvotes}}</a>
</td>
<td>
<a hx-post="/posts/{{post.id}}/downvotes" hx-target='.post-preview-details' hx-swap='outerHTML'>{{post.downvotes}}</a>
</td>
<td>
<span>by <a href="/users/{{post.author}}">{{post.author}}</a> at {{post.created_}}</span>
</td>
<td>
<span>{{post.num_comments}} comments</span>
</td>
</tr>
</table>
</div>
</div> </div>
{% end %} {% end %}
</main> </main>
{% if login %}
<dialog id="modal-create-post">
<form method="dialog" id="modal-create-post-close-dialog-form"></form>
<form action="/action/post" method="POST" id="modal-create-post-publish-form">
<input type="text" name="title" placeholder="title" />
<textarea name="content" placeholder="content"></textarea>
</form>
<button form="modal-create-post-close-dialog-form">cancel</button>
<input type="submit" value="publish" form="modal-create-post-publish-form" />
</dialog>
{% end %}
<script src="/static/htmx.js"></script>
<script>
const modal_create_post = document.querySelector("#modal-create-post");
const button_create_post = document.querySelector("#button-create-post")
button_create_post.onclick = function (event) { modal_create_post.showModal(); }
</script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -3,7 +3,7 @@
<title>Sign up</title> <title>Sign up</title>
<link rel="stylesheet" media="screen" href="/static/global.css" /> <link rel="stylesheet" media="screen" href="/static/global.css" />
</head> </head>
<body> <body hx-boost="true">
<main> <main>
<nav> <nav>
<table> <table>
+1 -1
View File
@@ -3,7 +3,7 @@
<title>{{name}}'s profile</title> <title>{{name}}'s profile</title>
<link rel="stylesheet" media="screen" href="/static/global.css" /> <link rel="stylesheet" media="screen" href="/static/global.css" />
</head> </head>
<body> <body hx-boost="true">
<main> <main>
<nav> <nav>
<table> <table>
+1 -1
View File
@@ -3,7 +3,7 @@
<title>Users</title> <title>Users</title>
<link rel="stylesheet" media="screen" href="/static/global.css" /> <link rel="stylesheet" media="screen" href="/static/global.css" />
</head> </head>
<body> <body hx-boost="true">
<main> <main>
<nav> <nav>
<table> <table>
+157 -7
View File
@@ -44,6 +44,7 @@ char schema[] =
" title TEXT NOT NULL,\n" " title TEXT NOT NULL,\n"
" content TEXT NOT NULL,\n" " content TEXT NOT NULL,\n"
" author TEXT NOT NULL,\n" " author TEXT NOT NULL,\n"
" created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,"
" FOREIGN KEY (author) REFERENCES Users(name)\n" " FOREIGN KEY (author) REFERENCES Users(name)\n"
");\n" ");\n"
"CREATE TABLE IF NOT EXISTS Comments(\n" "CREATE TABLE IF NOT EXISTS Comments(\n"
@@ -51,9 +52,26 @@ char schema[] =
" content TEXT NOT NULL,\n" " content TEXT NOT NULL,\n"
" author TEXT NOT NULL,\n" " author TEXT NOT NULL,\n"
" parent INTEGER NOT NULL,\n" " parent INTEGER NOT NULL,\n"
" created TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,"
" FOREIGN KEY (author) REFERENCES Users(name),\n" " FOREIGN KEY (author) REFERENCES Users(name),\n"
" FOREIGN KEY (parent) REFERENCES Posts(id)\n" " FOREIGN KEY (parent) REFERENCES Posts(id)\n"
");\n" ");\n"
"CREATE TABLE IF NOT EXISTS PostVotes(\n"
" user TEXT,\n"
" post INTEGER,\n"
" up BOOLEAN NOT NULL,\n"
" PRIMARY KEY (user, post),\n"
" FOREIGN KEY (user) REFERENCES Users(name),\n"
" FOREIGN KEY (post) REFERENCES Posts(id)\n"
");\n"
"CREATE TABLE IF NOT EXISTS CommentVotes(\n"
" user TEXT,\n"
" comment INTEGER,\n"
" up BOOLEAN NOT NULL,\n"
" PRIMARY KEY (user, comment),\n"
" FOREIGN KEY (user) REFERENCES Users(name),\n"
" FOREIGN KEY (comment) REFERENCES Comments(id)\n"
");\n"
"PRAGMA foreign_keys = ON;\n"; "PRAGMA foreign_keys = ON;\n";
Session sessions[MAX_SESSIONS]; Session sessions[MAX_SESSIONS];
@@ -89,6 +107,128 @@ void free_endpoints(void)
sqlite3_close(db); sqlite3_close(db);
} }
void respond_with_post_details_html(ResponseBuilder *b, int details_post_id)
{
sqlite3_stmt *stmt = sqlite3_utils_prepare(db,
"SELECT P.author, "
" STRFTIME('%d/%m/%Y, %H.%M', P.created) AS created_, "
" (SELECT COUNT(*) FROM Comments AS C WHERE C.parent = P.id) AS num_comments, "
" (SELECT COUNT(*) FROM PostVotes AS PV WHERE PV.post = P.id AND PV.up=1) AS upvotes, "
" (SELECT COUNT(*) FROM PostVotes AS PV WHERE PV.post = P.id AND PV.up=0) AS downvotes "
"FROM Posts AS P");
if (stmt == NULL) {
status_line(b, 500);
return;
}
string author;
string created;
int num_comments;
int upvotes;
int downvotes;
if (sqlite3_utils_fetch(stmt, "ssiii", &author, &created, &num_comments, &upvotes, &downvotes)) {
sqlite3_finalize(stmt);
status_line(b, 500);
return;
}
status_line(b, 200);
append_content_f(b,
"<div class='post-preview-details'> \
<table> \
<tr> \
<td> \
<a hx-post='/posts/%d/upvotes' hx-target='.post-preview-details' hx-swap='outerHTML'>%d</a> \
</td> \
<td> \
<a hx-post='/posts/%d/downvotes' hx-target='.post-preview-details' hx-swap='outerHTML'>%d</a> \
</td> \
<td> \
<span>by <a href='/users/%.*s'>%.*s</a> at %.*s</span> \
</td> \
<td> \
<span>%d comments</span> \
</td> \
</tr> \
</table> \
</div>",
details_post_id, upvotes, details_post_id, downvotes,
(int) author.size, author.data,
(int) author.size, author.data,
(int) created.size, created.data,
num_comments);
sqlite3_finalize(stmt);
}
// Returns true iff the request was handled
static bool voting_endpoints(Request request, ResponseBuilder *b, string login_username)
{
int vote_post_id;
if (!match_path_format(request.url.path, "/posts/:n/upvotes", &vote_post_id)) {
if (login_username.size == 0) {
status_line(b, 400);
return true;
}
int up = 1;
// Create the UP row
if (sqlite3_utils_exec(db, "INSERT INTO PostVotes(user, post, up) VALUES (:s, :i, :i)", login_username, vote_post_id, up)) {
respond_with_post_details_html(b, vote_post_id);
return true;
}
// If we failed, maybe we need to delete the current UP row
if (sqlite3_utils_exec(db, "DELETE FROM PostVotes WHERE up=:i AND user=:s AND post=:i", up, login_username, vote_post_id) && sqlite3_changes(db) > 0) {
respond_with_post_details_html(b, vote_post_id);
return true;
}
// If we failed, maybe we need to invert the UP row
if (sqlite3_utils_exec(db, "UPDATE PostVotes SET up=:i WHERE user=:s AND post=:i", up, login_username, vote_post_id)) {
respond_with_post_details_html(b, vote_post_id);
return true;
}
respond_with_post_details_html(b, vote_post_id);
return true;
}
if (!match_path_format(request.url.path, "/posts/:n/downvotes", &vote_post_id)) {
if (login_username.size == 0) {
status_line(b, 400);
return true;
}
int up = 0;
// Create the UP row
if (sqlite3_utils_exec(db, "INSERT INTO PostVotes(user, post, up) VALUES (:s, :i, :i)", login_username, vote_post_id, up)) {
respond_with_post_details_html(b, vote_post_id);
return true;
}
// If we failed, maybe we need to delete the current UP row
if (sqlite3_utils_exec(db, "DELETE FROM PostVotes WHERE up=:i AND user=:s AND post=:i", up, login_username, vote_post_id) && sqlite3_changes(db) > 0) {
respond_with_post_details_html(b, vote_post_id);
return true;
}
// If we failed, maybe we need to invert the UP row
if (sqlite3_utils_exec(db, "UPDATE PostVotes SET up=:i WHERE user=:s AND post=:i", up, login_username, vote_post_id)) {
respond_with_post_details_html(b, vote_post_id);
return true;
}
respond_with_post_details_html(b, vote_post_id);
return true;
}
return false;
}
void respond(Request request, ResponseBuilder *b) void respond(Request request, ResponseBuilder *b)
{ {
if (request.major != 1 || request.minor > 1) { if (request.major != 1 || request.minor > 1) {
@@ -108,44 +248,48 @@ void respond(Request request, ResponseBuilder *b)
add_header(b, LIT("Location: /")); add_header(b, LIT("Location: /"));
return; return;
} }
char namebuf[MAX_USER_NAME]; char namebuf[MAX_USER_NAME];
char passbuf[MAX_USER_PASS]; char passbuf[MAX_USER_PASS];
string name; string pass; string name; string pass;
if (!get_query_string_param(request.content, LIT("name"), LIT(namebuf), &name)) { if (!get_query_string_param(request.content, LIT("name"), LIT(namebuf), &name)) {
status_line(b, 500); status_line(b, 200);
append_content_s(b, LIT("Invalid name")); append_content_s(b, LIT("Invalid name"));
return; return;
} }
if (!get_query_string_param(request.content, LIT("pass"), LIT(passbuf), &pass)) { if (!get_query_string_param(request.content, LIT("pass"), LIT(passbuf), &pass)) {
status_line(b, 500); status_line(b, 200);
append_content_s(b, LIT("Invalid pass")); append_content_s(b, LIT("Invalid pass"));
return; return;
} }
int res = sqlite3_utils_rows_exist(db, "SELECT name FROM Users WHERE name=:s AND pass=:s", name, pass); int res = sqlite3_utils_rows_exist(db, "SELECT name FROM Users WHERE name=:s AND pass=:s", name, pass);
if (res == -1) { if (res == -1) {
status_line(b, 500); status_line(b, 200);
append_content_s(b, LIT("Internal error")); append_content_s(b, LIT("Internal error"));
return; return;
} }
if (res == 1) { if (res == 1) {
// No such user // No such user
status_line(b, 400); status_line(b, 200);
append_content_s(b, LIT("Invalid credentials")); append_content_s(b, LIT("Invalid credentials"));
return; return;
} }
SessionID sessid = create_session(name); SessionID sessid = create_session(name);
if (sessid == NO_SESSION) { if (sessid == NO_SESSION) {
status_line(b, 500); status_line(b, 200);
append_content_s(b, LIT("Internal error")); append_content_s(b, LIT("Internal error"));
return; return;
} }
// User exist // User exist
status_line(b, 303); status_line(b, 303);
add_header_f(b, "Set-Cookie: sessid=%d; Path=/", sessid); add_header_f(b, "Set-Cookie: sessid=%d; Path=/", sessid);
add_header(b, LIT("Location: /")); add_header(b, LIT("HX-Redirect: /"));
return; return;
} }
if (voting_endpoints(request, b, login_username))
return;
if (streq(request.url.path, LIT("/action/signup"))) { if (streq(request.url.path, LIT("/action/signup"))) {
if (login_username.size > 0) { if (login_username.size > 0) {
status_line(b, 303); status_line(b, 303);
@@ -245,7 +389,13 @@ void respond(Request request, ResponseBuilder *b)
} }
if (!match_path_format(request.url.path, "/posts")) { if (!match_path_format(request.url.path, "/posts")) {
sqlite3_stmt *stmt = sqlite3_utils_prepare(db, "SELECT id, title, author FROM Posts"); sqlite3_stmt *stmt = sqlite3_utils_prepare(db,
"SELECT P.id, P.title, P.author, "
" STRFTIME('%d/%m/%Y, %H.%M', P.created) AS created_, "
" (SELECT COUNT(*) FROM Comments AS C WHERE C.parent = P.id) AS num_comments, "
" (SELECT COUNT(*) FROM PostVotes AS PV WHERE PV.post = P.id AND PV.up=1) AS upvotes, "
" (SELECT COUNT(*) FROM PostVotes AS PV WHERE PV.post = P.id AND PV.up=0) AS downvotes "
"FROM Posts AS P");
if (stmt == NULL) { if (stmt == NULL) {
status_line(b, 500); status_line(b, 500);
return; return;
-3
View File
@@ -138,7 +138,6 @@ static sqlite3_stmt *vprep(sqlite3 *handle, const char *fmt, va_list args)
case 'i': case 'i':
{ {
int v = va_arg(args, int); int v = va_arg(args, int);
DEBUG("binding param %d to int %d\n", i+1, v);
code = sqlite3_bind_int (stmt, i+1, v); code = sqlite3_bind_int (stmt, i+1, v);
} }
break; break;
@@ -146,7 +145,6 @@ static sqlite3_stmt *vprep(sqlite3 *handle, const char *fmt, va_list args)
case 's': case 's':
{ {
string str = va_arg(args, string); string str = va_arg(args, string);
DEBUG("binding param %d to str %.*s\n", i+1, (int) str.size, str.data);
code = sqlite3_bind_text(stmt, i+1, str.data, str.size, NULL); code = sqlite3_bind_text(stmt, i+1, str.data, str.size, NULL);
} }
break; break;
@@ -155,7 +153,6 @@ static sqlite3_stmt *vprep(sqlite3 *handle, const char *fmt, va_list args)
{ {
void *ptr = va_arg(args, void*); void *ptr = va_arg(args, void*);
size_t len = va_arg(args, size_t); size_t len = va_arg(args, size_t);
DEBUG("binding param %d to blob %p %d\n", i+1, ptr, (int) len);
code = sqlite3_bind_blob(stmt, i+1, ptr, len, NULL); code = sqlite3_bind_blob(stmt, i+1, ptr, len, NULL);
} }
break; break;
+2 -2
View File
@@ -84,7 +84,7 @@ bool append_template(ResponseBuilder *b, string file, TemplateParam *params)
status = tinytemplate_compile(template_str.data, template_str.size, program, COUNTOF(program), &num_instr, errmsg, sizeof(errmsg)); status = tinytemplate_compile(template_str.data, template_str.size, program, COUNTOF(program), &num_instr, errmsg, sizeof(errmsg));
if (status != TINYTEMPLATE_STATUS_DONE) { if (status != TINYTEMPLATE_STATUS_DONE) {
log_data(STR(errmsg)); log_format("Template Compile Error: %s", errmsg);
myfree(template_str.data, template_str.size); myfree(template_str.data, template_str.size);
return false; return false;
} }
@@ -94,7 +94,7 @@ bool append_template(ResponseBuilder *b, string file, TemplateParam *params)
context.params = params; context.params = params;
status = tinytemplate_eval(template_str.data, program, &context, template_param_callback, template_output_callback, errmsg, sizeof(errmsg)); status = tinytemplate_eval(template_str.data, program, &context, template_param_callback, template_output_callback, errmsg, sizeof(errmsg));
if (status != TINYTEMPLATE_STATUS_DONE) if (status != TINYTEMPLATE_STATUS_DONE)
log_data(STR(errmsg)); log_format("Template Runtime Error: %s", errmsg);
myfree(template_str.data, template_str.size); myfree(template_str.data, template_str.size);
return true; return true;
} }
+84 -6
View File
@@ -1,3 +1,7 @@
body {
background-color: #282a36;
}
main { main {
width: 600px; width: 600px;
margin: auto; margin: auto;
@@ -5,22 +9,39 @@ main {
nav { nav {
overflow: auto; overflow: auto;
background: #fab8b8;
border: 1px solid #c16464;
border-radius: 3px;
margin-bottom: 10px; margin-bottom: 10px;
} }
nav table { nav table {
float: left; float: left;
border-collapse: collapse; border-collapse: collapse;
} }
nav table:last-child { nav table:last-child {
float: right; float: right;
} }
nav table td { nav table td {
padding: 10px; padding: 10px;
} }
nav table td a {
color: white;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
text-decoration: none;
}
::backdrop {
background-image: linear-gradient(
45deg,
magenta,
rebeccapurple,
dodgerblue,
green
);
opacity: 0.75;
}
form { form {
width: calc(100% - 20px); width: calc(100% - 20px);
padding: 10px; padding: 10px;
@@ -29,6 +50,7 @@ form {
border-radius: 3px; border-radius: 3px;
border: 1px solid #76c97d; border: 1px solid #76c97d;
} }
form input, form input,
form textarea { form textarea {
width: 100%; width: 100%;
@@ -37,18 +59,33 @@ form textarea {
border-radius: 3px; border-radius: 3px;
border: 1px solid #76c97d; border: 1px solid #76c97d;
} }
form input:last-child, form input:last-child,
form textarea:last-child { form textarea:last-child {
margin-bottom: 0; margin-bottom: 0;
} }
form textarea { form textarea {
height: 150px; height: 150px;
} }
form input[type=submit] { form input[type=submit] {
width: 200px; width: 200px;
float: right; float: right;
} }
#login-error-message {
padding: 10px;
background: white;
border: 1px solid red;
border-radius: 3px;
margin-bottom: 10px;
}
#login-error-message:empty {
display: none;
}
.user-preview { .user-preview {
margin-top: 10px; margin-top: 10px;
padding: 10px; padding: 10px;
@@ -59,8 +96,49 @@ form input[type=submit] {
.post-preview { .post-preview {
margin-top: 10px; margin-top: 10px;
padding: 10px; border: 1px solid #ccc;
background: #ccd6fb;
border: 1px solid #6478c1;
border-radius: 3px; border-radius: 3px;
} }
.post-preview-title {
margin: 10px;
}
.post-preview-title a {
color: white;
font-size: 30px;
text-decoration: none;
}
.post-preview-details {
border-top: 1px solid #ccc;
}
.post-preview-details table {
border-collapse: collapse;
}
.post-preview-details table td {
border-right: 1px solid #ccc;
padding: 10px;
}
.post-preview-details span,
.post-preview-details a {
color: #ccc;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
}
.post {
padding: 10px;
background: white;
border-radius: 3px;
margin-top: 10px;
}
.comment {
padding: 10px;
background: #ccd6fb;
border-radius: 3px;
margin-top: 10px;
}
+3925
View File
File diff suppressed because it is too large Load Diff