jcs
/subtext
/amendments
/116
board: Add message boards!
jcs made amendment 116 over 3 years ago
--- board.c	Fri Jun  3 14:01:37 2022
+++ board.c	Fri Jun  3 14:01:37 2022
@@ -0,0 +1,771 @@
+/*
+ * Copyright (c) 2022 joshua stein <jcs@jcs.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#include "subtext.h"
+#include "ansi.h"
+#include "board.h"
+#include "user.h"
+
+#define POSTS_PER_PAGE		10
+
+#define POST_READ_RETURN_DONE	-1
+#define POST_READ_RETURN_LIST	-2
+#define POST_READ_RETURN_FIND	-3
+
+struct struct_field board_fields[] = {
+	{ "Board ID",			CONFIG_TYPE_LONG,
+		offsetof(struct board, id),
+		1, ULONG_MAX },
+	{ "Name",				CONFIG_TYPE_STRING,
+		offsetof(struct board, name),
+		1, member_size(struct board, name) },
+	{ "Description",		CONFIG_TYPE_STRING,
+		offsetof(struct board, description),
+		0, member_size(struct board, description) },
+	{ "Restricted Posting",	CONFIG_TYPE_BOOLEAN,
+		offsetof(struct board, restricted_posting),
+		0, 0 },
+	{ "Restricted Viewing",	CONFIG_TYPE_BOOLEAN,
+		offsetof(struct board, restricted_viewing),
+		0, 0 },
+	{ "Days of Retention",	CONFIG_TYPE_SHORT,
+		offsetof(struct board, retention_days),
+		0, USHRT_MAX },
+};
+size_t nboard_fields = nitems(board_fields);
+
+struct bile_object_field board_object_fields[] = {
+	{ offsetof(struct board, id),
+		member_size(struct board, id), -1 },
+	{ offsetof(struct board, name),
+		member_size(struct board, name), -1 },
+	{ offsetof(struct board, description),
+		member_size(struct board, description), -1 },
+	{ offsetof(struct board, restricted_posting),
+		member_size(struct board, restricted_posting), -1 },
+	{ offsetof(struct board, restricted_viewing),
+		member_size(struct board, restricted_viewing), -1 },
+	{ offsetof(struct board, retention_days),
+		member_size(struct board, retention_days), -1 },
+	{ offsetof(struct board, last_post_at),
+		member_size(struct board, last_post_at), -1 },
+	{ offsetof(struct board, post_count),
+		member_size(struct board, post_count), -1 },
+};
+size_t nboard_object_fields = nitems(board_object_fields);
+
+struct bile_object_field board_post_object_fields[] = {
+	{ offsetof(struct board_post, id),
+		member_size(struct board_post, id), -1 },
+	{ offsetof(struct board_post, thread_id),
+		member_size(struct board_post, thread_id), -1 },
+	{ offsetof(struct board_post, time),
+		member_size(struct board_post, time), -1 },
+	{ offsetof(struct board_post, sender_user_id),
+		member_size(struct board_post, sender_user_id), -1 },
+	{ offsetof(struct board_post, body_size),
+		member_size(struct board_post, body_size), -1 },
+	{ offsetof(struct board_post, body),
+		-1, offsetof(struct board_post, body_size) },
+	{ offsetof(struct board_post, parent_post_id),
+		member_size(struct board_post, parent_post_id), -1 },
+};
+size_t nboard_post_object_fields = nitems(board_post_object_fields);
+
+struct bile_object_field board_thread_object_fields[] = {
+	{ offsetof(struct board_thread, thread_id),
+		member_size(struct board_thread, thread_id), -1 },
+	{ offsetof(struct board_thread, last_post_at),
+		member_size(struct board_thread, last_post_at), -1 },
+	{ offsetof(struct board_thread, subject_size),
+		member_size(struct board_thread, subject_size), -1 },
+	{ offsetof(struct board_thread, subject),
+		-1, offsetof(struct board_thread, subject_size) },
+	{ offsetof(struct board_thread, nposts),
+		member_size(struct board_thread, nposts), -1 },
+	{ offsetof(struct board_thread, post_ids),
+		-(sizeof(long)), offsetof(struct board_thread, nposts) },
+	{ offsetof(struct board_thread, parent_post_ids),
+		-(sizeof(long)), offsetof(struct board_thread, nposts) },
+};
+size_t nboard_thread_object_fields = nitems(board_thread_object_fields);
+
+unsigned long board_compose(struct session *s, struct board *board,
+  struct board_thread *thread, struct board_post *parent_post,
+  char *initial_subject, char *initial_body);
+void board_list_posts(struct session *s, struct board *board,
+  size_t npost_Ids, unsigned long *post_ids, size_t page, size_t pages);
+short board_post_read(struct session *s, struct board *board,
+  unsigned long id, short idx);
+size_t board_find_post_ids(struct board *board, size_t *npost_ids,
+  unsigned long **post_ids, size_t offset, size_t limit);
+short board_post_create(struct board *board, struct board_thread *thread,
+  struct board_post *post);
+
+void
+board_show(struct session *s, short id)
+{
+	static struct session_menu_option opts[] = {
+		{ '#', "#0123456789", "Read post [#]" },
+		{ '<', "<",			"Previous page of posts" },
+		{ 'l', "Ll",		"List posts" },
+		{ '>', ">",			"Next page of posts" },
+		{ 'p', "Pp",		"Post new thread" },
+		{ 'q', "QqXx",		"Return to main menu" },
+		{ '?', "?",			"List menu options" },
+	};
+	struct board *board = NULL;
+	size_t n, nall_post_ids, page, pages, npost_ids;
+	unsigned long *post_ids = NULL;
+	short ret;
+	char c;
+	bool done, find_post_ids, show_list, show_help;
+	
+	for (n = 0; n < db->nboards; n++) {
+		if (db->boards[n].id == id) {
+			board = &db->boards[n];
+			break;
+		}
+	}
+	
+	if (!board) {
+		session_printf(s, "Invalid board\r\n");
+		session_flush(s);
+		return;
+	}
+	
+	page = 0;
+	find_post_ids = true;
+	show_list = true;
+	show_help = false;
+	done = false;
+	
+	while (!done) {
+		if (find_post_ids) {
+			nall_post_ids = board_find_post_ids(board, &npost_ids,
+			  &post_ids, page * POSTS_PER_PAGE, POSTS_PER_PAGE);
+			/* ceil(npost_ids / POSTS_PER_PAGE) */
+			pages = (nall_post_ids + POSTS_PER_PAGE - 1) / POSTS_PER_PAGE;
+			
+			if (page >= pages)
+				page = pages - 1;
+				
+			find_post_ids = false;
+		}
+		
+		if (show_list) {
+			board_list_posts(s, board, npost_ids, post_ids, page + 1,
+			  pages);
+			show_list = false;
+		}
+		
+		c = session_menu(s, board->description, board->name, opts,
+		  nitems(opts), show_help);
+		show_help = false;
+		
+handle_opt:
+		switch (c) {
+		case 'l':
+			show_list = true;
+			break;
+		case 'p':
+			board_compose(s, board, NULL, NULL, NULL, NULL);
+			break;
+		case '>':
+		case '<':
+			if (c == '>' && page == pages - 1) {
+				session_printf(s, "You are at the last page of posts\r\n");
+				session_flush(s);
+				break;
+			}
+			if (c == '<' && page == 0) {
+				session_printf(s, "You are already at the first page\r\n");
+				session_flush(s);
+				break;
+			}
+			if (c == '>')
+				page++;
+			else
+				page--;
+			find_post_ids = true;
+			show_list = true;
+			break;
+		case 0:
+		case 1:
+		case 2:
+		case 3:
+		case 4:
+		case 5:
+		case 6:
+		case 7:
+		case 8:
+		case 9:
+			if (c >= npost_ids) {
+				session_printf(s, "Invalid post\r\n");
+				session_flush(s);
+				break;
+			}
+			ret = board_post_read(s, board, post_ids[c], c);
+			switch (ret) {
+			case POST_READ_RETURN_DONE:
+				break;
+			case POST_READ_RETURN_LIST:
+				show_list = true;
+				break;
+			case POST_READ_RETURN_FIND:
+				find_post_ids = true;
+				show_list = true;
+				break;
+			default:
+				c = ret;
+				goto handle_opt;
+			}
+			break;
+		case '?':
+			show_help = true;
+			break;
+		default:
+			done = true;
+			break;
+		}
+	}
+}
+
+void
+board_list_posts(struct session *s, struct board *board, size_t npost_ids,
+  unsigned long *post_ids, unsigned long page, unsigned long pages)
+{
+	char time[24];
+	unsigned long indent_parent_ids[10] = { 0 };
+	char indent_s[22];
+	size_t n, size, idx;
+	struct username_cache *user;
+	struct bile_object *obj;
+	struct board_thread thread = { 0 };
+	struct board_post post;
+	short indent, j, k;
+	char *data;
+
+	session_printf(s, "{{B}}%s: %s (Page %ld of %ld){{/B}}\r\n",
+	  board->name, board->description, page, pages);
+	session_printf(s, "%s# N Date   From       Subject%s\r\n",
+	  ansi(s, ANSI_BOLD, ANSI_END), ansi(s, ANSI_RESET, ANSI_END));
+	session_flush(s);
+
+	if (npost_ids == 0) {
+		session_printf(s, "No posts here yet.\r\n");
+		session_flush(s);
+		return;
+	}
+	
+	for (n = 0; n < npost_ids; n++) {
+		size = bile_read_alloc(board->bile, BOARD_POST_RTYPE, post_ids[n],
+		  &data);
+		bile_unmarshall_object(board->bile, board_post_object_fields,
+		  nboard_post_object_fields, data, size, &post, false);
+		free(data);
+	
+		if (post.thread_id != thread.thread_id) {
+			if (thread.thread_id) {
+				free(thread.subject);
+				free(thread.post_ids);
+				free(thread.parent_post_ids);
+			}
+			size = bile_read_alloc(board->bile, BOARD_THREAD_RTYPE,
+			  post.thread_id, &data);
+			bile_unmarshall_object(board->bile, board_thread_object_fields,
+			  nboard_thread_object_fields, data, size, &thread, true);
+			free(data);
+			
+			for (j = 0; j < nitems(indent_parent_ids); j++)
+				indent_parent_ids[j] = 0;
+		}
+		
+		user = user_find_username(post.sender_user_id);
+		strftime(time, sizeof(time), "%b %d", localtime(&post.time));
+		
+		if (post.parent_post_id == 0) {
+			indent_s[0] = '\0';
+		} else {
+			indent = -1;
+			for (j = 0; j < nitems(indent_parent_ids); j++) {
+				if (indent_parent_ids[j] == post.parent_post_id ||
+				  indent_parent_ids[j] == 0) {
+					indent = j;
+					indent_parent_ids[j] = post.parent_post_id;
+					for (k = j + 1; k < nitems(indent_parent_ids); k++)
+						indent_parent_ids[k] = 0;
+					break;
+				}
+			}
+			if (indent == -1)
+				indent = nitems(indent_parent_ids) - 1;
+			
+			for (j = 0; j < indent - 1; j++) {
+				indent_s[j] = ' ';
+			}
+			indent_s[j] = '`';
+			indent_s[j + 1] = '-';
+			indent_s[j + 2] = '>';
+			indent_s[j + 3] = '\0';
+		}
+		session_printf(s, "%s%ld %c %s %- 10s %s{{#}}%- 40s%s\r\n",
+		  true ? "" : ansi(s, ANSI_BOLD, ANSI_END),
+		  n,
+		  true ? ' ' : 'N',
+		  time,
+		  user ? user->username : "(unknown)",
+		  post.parent_post_id != 0 && n == 0 ? "Re: " : "",
+		  post.parent_post_id == 0 || n == 0 ? thread.subject : indent_s,
+		  true ? "" : ansi(s, ANSI_RESET, ANSI_END));
+		session_flush(s);
+	}
+	
+	if (thread.subject != NULL)
+		free(thread.subject);
+	if (thread.post_ids != NULL)
+		free(thread.post_ids);
+	if (thread.parent_post_ids != NULL)
+		free(thread.parent_post_ids);
+}
+
+unsigned long
+board_compose(struct session *s, struct board *board,
+  struct board_thread *thread, struct board_post *parent_post,
+  char *initial_subject, char *initial_body)
+{
+	struct board_post post = { 0 };
+	char *data = NULL, *tmp = NULL;
+	size_t size;
+	short c, ret;
+
+	if (!s->user) {
+		session_output_string(s, "Posting is not available to guests.\r\n"
+		  "Please create an account first.\r\n");
+		session_flush(s);
+		return 0;
+	}
+	
+	if (initial_body)
+		post.body = xstrdup(initial_body);
+	if (thread) {
+		post.thread_id = thread->thread_id;
+		post.parent_post_id = parent_post->id;
+	} else
+		thread = xmalloczero(sizeof(struct board_thread));
+		
+	post.sender_user_id = s->user->id;
+	
+	session_printf(s, "{{B}}Compose %s{{/B}}\r\n",
+	  parent_post ? "Reply" : "New Post");
+	session_printf(s, "{{B}}From:{{/B}}    %s\r\n", s->user->username);
+	session_printf(s, "{{B}}To:{{/B}}      %s\r\n", board->name);
+	
+	if (parent_post) {
+		session_printf(s, "{{B}}Subject:{{/B}}{{#}} Re: %s\r\n",
+		  thread->subject);
+		session_flush(s);
+	} else {
+		if (initial_subject)
+			thread->subject = xstrdup(initial_subject);
+
+post_compose_start:
+		for (;;) {
+			session_output_template(s, "{{B}}Subject:{{/B}} ");
+			session_flush(s);
+	
+			tmp = session_field_input(s, 100, 50, thread->subject,
+			  false, 0);
+			if (thread->subject != NULL)
+				free(thread->subject);
+			thread->subject = tmp;
+			session_output(s, "\r\n", 2);
+			session_flush(s);
+			if (thread->subject == NULL)
+				goto post_compose_done;
+			if (thread->subject[0] == '\0') {
+				session_output_template(s, "{{B}}Error:{{/B}} Subject "
+				  "cannot be blank (^C to cancel)\r\n");
+				session_flush(s);
+				free(thread->subject);
+				continue;
+			}
+			thread->subject_size = strlen(thread->subject) + 1;
+			break;
+		}
+	}
+	
+	for (;;) {
+		session_output_template(s,
+		  "{{B}}Message (^D when finished):{{/B}}\r\n");
+		session_flush(s);
+	
+		tmp = session_field_input(s, 2048, s->terminal_columns - 1,
+		  post.body, true, 0);
+		if (post.body != NULL)
+			free(post.body);
+		post.body = tmp;
+		session_output(s, "\r\n", 2);
+		session_flush(s);
+		if (post.body == NULL)
+			goto post_compose_done;
+		if (post.body[0] == '\0') {
+			free(post.body);
+			goto post_compose_done;
+		}
+		post.body_size = strlen(post.body) + 1;
+		break;
+	}
+	
+	for (;;) {
+		session_output_template(s, "\r\n{{B}}(P){{/B}}ost message, "
+		  "{{B}}(E){{/B}}dit again, or {{B}}(C){{/B}}ancel? ");
+		session_flush(s);
+
+		c = session_input_char(s);
+		if (c == 0 || s->ending)
+			goto post_compose_done;
+			
+		switch (c) {
+		case 'p':
+		case 'P':
+		case 'y':
+			session_printf(s, "%c\r\n", c);
+			session_flush(s);
+			/* FALLTHROUGH */
+		case '\n':
+		case '\r':
+			/* send */
+			session_output_string(s, "Posting message... ");
+			session_flush(s);
+			
+			if (board_post_create(board, thread, &post) == 0) {
+				session_log(s, "Posted message %ld to %s", post.id,
+				  board->name);
+				session_output_string(s, "done\r\n");
+			} else
+				session_output_string(s, "failed!\r\n");
+				
+			session_flush(s);
+
+			goto post_compose_done;
+		case 'e':
+		case 'E':
+			session_printf(s, "%c\r\n", c);
+			session_flush(s);
+			goto post_compose_start;
+		case 'c':
+		case 'C':
+			session_printf(s, "%c\r\n", c);
+			session_flush(s);
+			/* FALLTHROUGH */
+		case CONTROL_C:
+			goto post_compose_done;
+		}
+	}
+	
+post_compose_error:
+	session_output_string(s, "Failed saving message!\r\n");
+	session_flush(s);
+	
+post_compose_done:
+	if (parent_post == NULL) {
+		if (thread->subject != NULL)
+			free(thread->subject);
+		free(thread);
+	}
+	if (post.body)
+		free(post.body);
+	
+	return post.id;
+}
+
+short
+board_post_read(struct session *s, struct board *board, unsigned long id,
+  short idx)
+{
+	static struct session_menu_option opts[] = {
+		{ '#', "#0123456789", "Read post [#]" },
+		{ 'r', "Rr",	"Post reply to this message" },
+		{ 'q', "QqXx",	"Return to message list" },
+		{ '?', "?",		"List these options" },
+	};
+	char time[32];
+	struct board_thread thread;
+	struct board_post post;
+	struct username_cache *sender, *recipient;
+	unsigned long new_id;
+	char prompt[DB_BOARD_NAME_LENGTH + 8];
+	size_t n, size;
+	short ret = POST_READ_RETURN_DONE;
+	char c;
+	char *data;
+	bool done = false, show_help = false;
+	
+	size = bile_read_alloc(board->bile, BOARD_POST_RTYPE, id, &data);
+	if (size == 0)
+		panic("failed fetching message %ld: %d", id,
+		  bile_error(board->bile));
+	bile_unmarshall_object(board->bile, board_post_object_fields,
+	  nboard_post_object_fields, data, size, &post, true);
+	free(data);
+
+	size = bile_read_alloc(board->bile, BOARD_THREAD_RTYPE,
+	  post.thread_id, &data);
+	if (size == 0)
+		panic("failed fetching thread %ld: %d", post.thread_id,
+		  bile_error(board->bile));
+
+	bile_unmarshall_object(board->bile, board_thread_object_fields,
+	  nboard_thread_object_fields, data, size, &thread, true);
+	free(data);
+
+	sender = user_find_username(post.sender_user_id);
+
+	strftime(time, sizeof(time), "%Y-%m-%d %H:%M:%S",
+	  localtime(&post.time));
+	
+	session_printf(s, "{{B}}From:{{/B}}    %s\r\n",
+	  sender ? sender->username : "(unknown)");
+	session_printf(s, "{{B}}To:{{/B}}      %s\r\n", board->name);
+	session_printf(s, "{{B}}Date:{{/B}}    %s %s\r\n", time,
+	  db->config.timezone);
+	session_printf(s, "{{B}}Subject:{{/B}}{{#}} %s%s\r\n",
+	  (post.parent_post_id ? "Re: " : ""), thread.subject);
+	session_flush(s);
+	session_output_string(s, "\r\n");
+	session_output(s, post.body, post.body_size);
+	session_output_string(s, "\r\n");
+
+	snprintf(prompt, sizeof(prompt), "%s:%ld", board->name, post.id);
+	
+	while (!done) {
+		c = session_menu(s, thread.subject, prompt, opts,
+		  nitems(opts), show_help);
+		show_help = false;
+		
+		switch (c) {
+		case 'r':
+			new_id = board_compose(s, board, &thread, &post, NULL, NULL);
+			if (new_id) {
+				; //ret = POST_READ_RETURN_FIND;
+				done = true;
+			}
+			break;
+		case 0:
+		case 1:
+		case 2:
+		case 3:
+		case 4:
+		case 5:
+		case 6:
+		case 7:
+		case 8:
+		case 9:
+			ret = c;
+			done = true;
+			break;
+		case 'q':
+			done = true;
+			break;
+		case '?':
+			show_help = true;
+			break;
+		}
+	}
+	
+	return ret;
+}
+
+size_t
+board_find_post_ids(struct board *board, size_t *npost_ids,
+  unsigned long **post_ids, size_t offset, size_t limit)
+{
+	struct board_thread_map {
+		unsigned long id;
+		time_t time;
+		size_t nposts;
+	};
+	struct bile_object *o;
+	struct board_thread_map *thread_map = NULL, tmp_map;
+	struct board_thread thread;
+	size_t nthreads, nall_post_ids, n, post_ids_size, size, seen_posts;
+	short i, j;
+	char *data;
+
+	post_ids_size = sizeof(long) * 16;
+	*post_ids = xmalloc(post_ids_size);
+	*npost_ids = 0;
+	nall_post_ids = 0;
+
+	nthreads = bile_count_by_type(board->bile, BOARD_THREAD_RTYPE);
+	if (nthreads == 0)
+		return 0;
+
+	thread_map = xcalloc(sizeof(struct board_thread_map), nthreads);
+
+	for (n = 0; o = bile_get_nth_of_type(board->bile, n,
+	  BOARD_THREAD_RTYPE); n++) {
+		bile_read_alloc(board->bile, BOARD_THREAD_RTYPE, o->id, &data);
+		bile_unmarshall_object(board->bile, board_thread_object_fields,
+		  nboard_thread_object_fields, data, o->size, &thread, false);
+		free(data);
+		free(o);
+	
+		thread_map[n].id = thread.thread_id;
+		thread_map[n].time = thread.last_post_at;
+		thread_map[n].nposts = thread.nposts;
+		nall_post_ids += thread.nposts;
+	}
+	
+	if (offset >= nall_post_ids)
+		goto done;
+	
+	/* sort by last post date descending */
+	for (i = 0; i < nthreads; i++) {
+		for (j = 0; j < nthreads - i - 1; j++) {
+			if (thread_map[j].time < thread_map[j + 1].time) {
+				tmp_map = thread_map[j];
+				thread_map[j] = thread_map[j + 1];
+				thread_map[j + 1] = tmp_map;
+			}
+		}
+	}
+	
+	/* gather threads until we run out of space for posts */
+	seen_posts = 0;
+	for (j = 0; j < nthreads; j++) {
+		size = bile_read_alloc(board->bile, BOARD_THREAD_RTYPE,
+		  thread_map[j].id, &data);
+		bile_unmarshall_object(board->bile, board_thread_object_fields,
+		  nboard_thread_object_fields, data, size, &thread, true);
+		free(data);
+		
+		for (i = 0; i < thread.nposts; i++) {
+			if (offset > 0 && seen_posts++ < offset)
+				continue;
+				
+			EXPAND_TO_FIT(*post_ids, post_ids_size,
+			  ((*npost_ids) + 1) * sizeof(long), sizeof(long),
+			  sizeof(long) * 16);
+			(*post_ids)[*npost_ids] = thread.post_ids[i];
+			(*npost_ids)++;
+
+			if (*npost_ids >= limit)
+				break;
+		}
+		
+		free(thread.post_ids);
+		free(thread.subject);
+		
+		if (*npost_ids >= limit)
+			break;
+	}
+	
+done:
+	if (thread_map != NULL)
+		free(thread_map);
+	
+	return nall_post_ids;
+}
+
+short
+board_post_create(struct board *board, struct board_thread *thread,
+  struct board_post *post)
+{
+	short ret;
+	char *data;
+	size_t size;
+	ssize_t n, j;
+	size_t insert;
+	
+	if (post->parent_post_id == 0) {
+		thread->thread_id = bile_next_id(board->bile, BOARD_THREAD_RTYPE);
+		post->thread_id = thread->thread_id;
+	}
+	
+	post->id = bile_next_id(board->bile, BOARD_POST_RTYPE);
+	post->time = Time;
+
+	ret = bile_marshall_object(board->bile, board_post_object_fields,
+	  nboard_post_object_fields, post, &data, &size);
+	if (ret != 0 || size == 0) {
+		warn("failed to marshall new post object");
+		post->id = 0;
+		goto done;
+	}
+	if (bile_write(board->bile, BOARD_POST_RTYPE, post->id, data,
+	  size) != size) {
+		warn("bile_write of new post failed! %d", bile_error(board->bile));
+		post->id = 0;
+		free(data);
+		goto done;
+	}
+
+	thread->last_post_at = post->time;
+	thread->nposts++;
+	thread->post_ids = xreallocarray(thread->post_ids, thread->nposts,
+	  sizeof(long));
+	thread->parent_post_ids = xreallocarray(thread->parent_post_ids,
+	  thread->nposts, sizeof(long));
+
+	/*
+	 * Add new post id to thread post_ids, but put it in the right place
+	 * so that reading post_ids will present the tree in order.  Walk
+	 * parent_post_ids and find the first match of our parent, then insert
+	 * our new post there.  This puts newest replies at the top.
+	 */
+
+	insert = thread->nposts - 1;
+	for (n = 0; n < thread->nposts - 1; n++) {
+		if (thread->parent_post_ids[n] != post->parent_post_id)
+			continue;
+		
+		for (j = thread->nposts - 2; j >= n; j--) {
+			thread->post_ids[j + 1] = thread->post_ids[j];
+			thread->parent_post_ids[j + 1] = thread->parent_post_ids[j];
+		}
+		insert = n;
+		break;
+	}
+	thread->post_ids[insert] = post->id;
+	thread->parent_post_ids[insert] = post->parent_post_id;
+	
+	ret = bile_marshall_object(board->bile, board_thread_object_fields,
+	  nboard_thread_object_fields, thread, &data, &size);
+	if (ret != 0 || size == 0) {
+		warn("failed to marshall thread object");
+		post->id = 0;
+		goto done;
+	}
+	if (bile_write(board->bile, BOARD_THREAD_RTYPE, thread->thread_id,
+	  data, size) != size) {
+		warn("bile_write of thread failed! %d", bile_error(board->bile));
+		post->id = 0;
+		goto done;
+	}
+	free(data);
+
+	bile_flush(board->bile, true);
+
+done:
+	return (post->id == 0 ? -1 : 0);
+}
--- board.h	Thu May 26 15:23:00 2022
+++ board.h	Thu May 26 15:23:00 2022
@@ -0,0 +1,67 @@
+/*
+ * Copyright (c) 2022 joshua stein <jcs@jcs.org>
+ *
+ * Permission to use, copy, modify, and distribute this software for any
+ * purpose with or without fee is hereby granted, provided that the above
+ * copyright notice and this permission notice appear in all copies.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
+ * WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
+ * MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
+ * ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
+ * WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
+ * ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
+ * OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+ */
+
+#ifndef __BOARD_H__
+#define __BOARD_H__
+
+#include <stddef.h>
+#include "session.h"
+
+struct board {
+	unsigned long id;
+	char name[DB_BOARD_NAME_LENGTH];
+	char description[DB_BOARD_DESCR_LENGTH];
+	bool restricted_posting;
+	bool restricted_viewing;
+	unsigned short retention_days;
+	unsigned long last_post_at;
+	unsigned long post_count;
+	
+	struct bile *bile;
+};
+
+extern struct struct_field board_fields[];
+extern size_t nboard_fields;
+extern struct bile_object_field board_object_fields[];
+extern size_t nboard_object_fields;
+
+struct board_post {
+	unsigned long id;
+	unsigned long thread_id;
+	time_t time;
+	unsigned long sender_user_id;
+	size_t body_size;
+	char *body;
+	unsigned long parent_post_id;
+};
+extern struct bile_object_field board_post_object_fields[];
+extern size_t nboard_post_object_fields;
+
+struct board_thread {
+	unsigned long thread_id;
+	time_t last_post_at;
+	size_t subject_size;
+	char *subject;
+	unsigned long nposts;
+	unsigned long *post_ids;
+	unsigned long *parent_post_ids;
+};
+extern struct bile_object_field board_thread_object_fields[];
+extern size_t nboard_thread_object_fields;
+
+void board_show(struct session *s, short id);
+
+#endif