/*
** Copyright (c) 2018 D. Richard Hipp
**
** This program is free software; you can redistribute it and/or
** modify it under the terms of the Simplified BSD License (also
** known as the "2-Clause License" or "FreeBSD License".)
**
** This program is distributed in the hope that it will be useful,
** but without any warranty; without even the implied warranty of
** merchantability or fitness for a particular purpose.
**
** Author contact information:
** drh@hwaci.com
** http://www.hwaci.com/drh/
**
*******************************************************************************
**
** This file contains code used to generate the user forum.
*/
#include "config.h"
#include <assert.h>
#include "forum.h"
#if INTERFACE
/*
** Each instance of the following object represents a single message -
** either the initial post, an edit to a post, a reply, or an edit to
** a reply.
*/
struct ForumEntry {
int fpid; /* rid for this entry */
int fprev; /* zero if initial entry. non-zero if an edit */
int firt; /* This entry replies to firt */
int mfirt; /* Root in-reply-to */
ForumEntry *pLeaf; /* Most recent edit for this entry */
ForumEntry *pEdit; /* This entry is an edit of pEditee */
ForumEntry *pNext; /* Next in chronological order */
ForumEntry *pPrev; /* Previous in chronological order */
ForumEntry *pDisplay; /* Next in display order */
int nIndent; /* Number of levels of indentation for this entry */
};
/*
** A single instance of the following tracks all entries for a thread.
*/
struct ForumThread {
ForumEntry *pFirst; /* First entry in chronological order */
ForumEntry *pLast; /* Last entry in chronological order */
ForumEntry *pDisplay; /* Entries in display order */
ForumEntry *pTail; /* Last on the display list */
};
#endif /* INTERFACE */
/*
** Delete a complete ForumThread and all its entries.
*/
static void forumthread_delete(ForumThread *pThread){
ForumEntry *pEntry, *pNext;
for(pEntry=pThread->pFirst; pEntry; pEntry = pNext){
pNext = pEntry->pNext;
fossil_free(pEntry);
}
fossil_free(pThread);
}
/*
** Search a ForumEntry list forwards looking for the entry with fpid
*/
static ForumEntry *forumentry_forward(ForumEntry *p, int fpid){
while( p && p->fpid!=fpid ) p = p->pNext;
return p;
}
/*
** Search backwards for a ForumEntry
*/
static ForumEntry *forumentry_backward(ForumEntry *p, int fpid){
while( p && p->fpid!=fpid ) p = p->pPrev;
return p;
}
/*
** Add an entry to the display list
*/
static void forumentry_add_to_display(ForumThread *pThread, ForumEntry *p){
if( pThread->pDisplay==0 ){
pThread->pDisplay = p;
}else{
pThread->pTail->pDisplay = p;
}
pThread->pTail = p;
}
/*
** Extend the display list for pThread by adding all entries that
** reference fpid. The first such entry will be no earlier then
** entry "p".
*/
static void forumthread_display_order(
ForumThread *pThread,
ForumEntry *p,
int fpid,
int nIndent
){
while( p ){
if( p->pEdit==0 && p->mfirt==fpid ){
p->nIndent = nIndent;
forumentry_add_to_display(pThread, p);
forumthread_display_order(pThread, p->pNext, p->fpid, nIndent+1);
}
p = p->pNext;
}
}
/*
** Construct a ForumThread object given the root record id.
*/
static ForumThread *forumthread_create(int froot){
ForumThread *pThread;
ForumEntry *pEntry;
Stmt q;
pThread = fossil_malloc( sizeof(*pThread) );
memset(pThread, 0, sizeof(*pThread));
db_prepare(&q, "SELECT fpid, firt, fprev FROM forumpost"
" WHERE froot=%d ORDER BY fmtime", froot);
while( db_step(&q)==SQLITE_ROW ){
pEntry = fossil_malloc( sizeof(*pEntry) );
memset(pEntry, 0, sizeof(*pEntry));
pEntry->fpid = db_column_int(&q, 0);
pEntry->firt = db_column_int(&q, 1);
pEntry->fprev = db_column_int(&q, 2);
pEntry->mfirt = pEntry->firt;
pEntry->pPrev = pThread->pLast;
pEntry->pNext = 0;
if( pThread->pLast==0 ){
pThread->pFirst = pEntry;
}else{
pThread->pLast->pNext = pEntry;
}
pThread->pLast = pEntry;
}
db_finalize(&q);
/* Establish which entries are the latest edit. After this loop
** completes, entries that have non-NULL pLeaf should not be
** displayed.
*/
for(pEntry=pThread->pFirst; pEntry; pEntry=pEntry->pNext){
if( pEntry->fprev ){
ForumEntry *pBase, *p;
pBase = p = forumentry_backward(pEntry->pPrev, pEntry->fprev);
pEntry->pEdit = p;
while( p ){
pBase = p;
p->pLeaf = pEntry;
p = pBase->pEdit;
}
for(p=pEntry->pNext; p; p=p->pNext){
if( p->mfirt==pEntry->fpid ) p->mfirt = pBase->mfirt;
}
}
}
/* Compute the display order */
pEntry = pThread->pFirst;
pEntry->nIndent = 1;
forumentry_add_to_display(pThread, pEntry);
forumthread_display_order(pThread, pEntry, pEntry->fpid, 2);
/* Return the result */
return pThread;
}
/*
** COMMAND: test-forumthread
**
** Usage: %fossil test-forumthread THREADID
**
** Display a summary of all messages on a thread.
*/
void forumthread_cmd(void){
int fpid;
int froot;
const char *zName;
ForumThread *pThread;
ForumEntry *p;
db_find_and_open_repository(0,0);
verify_all_options();
if( g.argc!=3 ) usage("THREADID");
zName = g.argv[2];
fpid = symbolic_name_to_rid(zName, "f");
if( fpid<=0 ){
fossil_fatal("Unknown or ambiguous forum id: \"%s\"", zName);
}
froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid);
if( froot==0 ){
fossil_fatal("Not a forum post: \"%s\"", zName);
}
fossil_print("fpid = %d\n", fpid);
fossil_print("froot = %d\n", froot);
pThread = forumthread_create(froot);
fossil_print("Chronological:\n");
/* 123456789 123456789 123456789 123456789 123456789 */
fossil_print(" fpid firt fprev mfirt pLeaf\n");
for(p=pThread->pFirst; p; p=p->pNext){
fossil_print("%9d %9d %9d %9d %9d\n",
p->fpid, p->firt, p->fprev, p->mfirt, p->pLeaf ? p->pLeaf->fpid : 0);
}
fossil_print("\nDisplay\n");
for(p=pThread->pDisplay; p; p=p->pDisplay){
fossil_print("%*s", (p->nIndent-1)*3, "");
if( p->pLeaf ){
fossil_print("%d->%d\n", p->fpid, p->pLeaf->fpid);
}else{
fossil_print("%d\n", p->fpid);
}
}
forumthread_delete(pThread);
}
/*
** Render a forum post for display
*/
void forum_render(
const char *zTitle,
const char *zMimetype,
const char *zContent
){
@ <div style='border: 1px solid black;padding: 1ex;'>
if( zTitle ){
if( zTitle[0] ){
@ <h1>%h(zTitle)</h1>
}else{
@ <h1><i>Deleted</i></h1>
}
}
if( zContent && zContent[0] ){
Blob x;
blob_init(&x, 0, 0);
blob_append(&x, zContent, -1);
wiki_render_by_mimetype(&x, zMimetype);
blob_reset(&x);
}else{
@ <i>Deleted</i>
}
@ </div>
}
/*
** Display all posts in a forum thread in chronological order
*/
static void forum_display_chronological(int froot, int target){
Stmt q;
int i = 0;
db_prepare(&q,
"SELECT fpid, fprev, firt, uuid, datetime(fmtime,'unixepoch')\n"
" FROM forumpost, blob\n"
" WHERE froot=%d AND rid=fpid\n"
" ORDER BY fmtime", froot);
while( db_step(&q)==SQLITE_ROW ){
int fpid = db_column_int(&q, 0);
int fprev = db_column_int(&q, 1);
int firt = db_column_int(&q, 2);
const char *zUuid = db_column_text(&q, 3);
const char *zDate = db_column_text(&q, 4);
Manifest *pPost = manifest_get(fpid, CFTYPE_FORUM, 0);
if( pPost==0 ) continue;
if( i>0 ){
@ <hr>
}
i++;
if( pPost->zThreadTitle ){
@ <h1>%h(pPost->zThreadTitle)</h1>
}
@ <p>By %h(pPost->zUser) on %h(zDate) (%d(fpid))
if( fprev ){
@ edit of %d(fprev)
}
if( firt ){
@ reply to %d(firt)
}
if( g.perm.Debug ){
@ <span class="debug">\
@ <a href="%R/artifact/%h(zUuid)">artifact</a></span>
}
forum_render(0, pPost->zMimetype, pPost->zWiki);
if( g.perm.WrForum ){
int sameUser = login_is_individual()
&& fossil_strcmp(pPost->zUser, g.zLogin)==0;
int isPrivate = content_is_private(fpid);
@ <p><form action="%R/forumedit" method="POST">
@ <input type="hidden" name="fpid" value="%s(zUuid)">
if( !isPrivate ){
/* Reply and Edit are only available if the post has already
** been approved */
@ <input type="submit" name="reply" value="Reply">
if( g.perm.Admin || sameUser ){
@ <input type="submit" name="edit" value="Edit">
@ <input type="submit" name="nullout" value="Delete">
}
}else if( g.perm.ModForum ){
/* Provide moderators with moderation buttons for posts that
** are pending moderation */
@ <input type="submit" name="approve" value="Approve">
@ <input type="submit" name="reject" value="Reject">
}else if( sameUser ){
/* A post that is pending moderation can be deleted by the
** person who originally submitted the post */
@ <input type="submit" name="reject" value="Delete">
}
@ </form></p>
}
manifest_destroy(pPost);
}
db_finalize(&q);
}
/*
** Display all messages in a forumthread with indentation.
*/
static void forum_display(int froot, int target){
ForumThread *pThread;
ForumEntry *p;
Manifest *pPost;
int fpid;
char *zDate;
char *zUuid;
pThread = forumthread_create(froot);
for(p=pThread->pDisplay; p; p=p->pDisplay){
@ <div style='margin-left: %d((p->nIndent-1)*3)ex;'>
fpid = p->pLeaf ? p->pLeaf->fpid : p->fpid;
pPost = manifest_get(fpid, CFTYPE_FORUM, 0);
if( pPost==0 ) continue;
if( pPost->zThreadTitle ){
@ <h1>%h(pPost->zThreadTitle)</h1>
}
zDate = db_text(0, "SELECT datetime(%.17g)", pPost->rDate);
@ <p>By %h(pPost->zUser) on %h(zDate) (%d(fpid))
fossil_free(zDate);
zUuid = rid_to_uuid(fpid);
if( g.perm.Debug ){
@ <span class="debug">\
@ <a href="%R/artifact/%h(zUuid)">artifact</a></span>
}
forum_render(0, pPost->zMimetype, pPost->zWiki);
if( g.perm.WrForum ){
int sameUser = login_is_individual()
&& fossil_strcmp(pPost->zUser, g.zLogin)==0;
int isPrivate = content_is_private(fpid);
@ <p><form action="%R/forumedit" method="POST">
@ <input type="hidden" name="fpid" value="%s(zUuid)">
if( !isPrivate ){
/* Reply and Edit are only available if the post has already
** been approved */
@ <input type="submit" name="reply" value="Reply">
if( g.perm.Admin || sameUser ){
@ <input type="submit" name="edit" value="Edit">
@ <input type="submit" name="nullout" value="Delete">
}
}else if( g.perm.ModForum ){
/* Provide moderators with moderation buttons for posts that
** are pending moderation */
@ <input type="submit" name="approve" value="Approve">
@ <input type="submit" name="reject" value="Reject">
}else if( sameUser ){
/* A post that is pending moderation can be deleted by the
** person who originally submitted the post */
@ <input type="submit" name="reject" value="Delete">
}
@ </form></p>
}
manifest_destroy(pPost);
fossil_free(zUuid);
@ </div>
}
forumthread_delete(pThread);
}
/*
** WEBPAGE: forumthread
**
** Show all forum messages associated with a particular message thread.
**
** Query parameters:
**
** name=X The hash of the first post of the thread. REQUIRED
*/
void forumthread_page(void){
int fpid;
int froot;
const char *zName = P("name");
login_check_credentials();
if( !g.perm.RdForum ){
login_needed(g.anon.RdForum);
return;
}
if( zName==0 ){
webpage_error("Missing \"name=\" query parameter");
}
fpid = symbolic_name_to_rid(zName, "f");
if( fpid<=0 ){
webpage_error("Unknown or ambiguous forum id: \"%s\"", zName);
}
style_header("Forum");
froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid);
if( froot==0 ){
webpage_error("Not a forum post: \"%s\"", zName);
}
if( P("t") ){
forum_display_chronological(froot, fpid);
}else{
forum_display(froot, fpid);
}
style_footer();
}
/*
** Return true if a forum post should be moderated.
*/
static int forum_need_moderation(void){
if( P("domod") ) return 1;
if( g.perm.WrTForum ) return 0;
if( g.perm.ModForum ) return 0;
return 1;
}
/*
** Add a new Forum Post artifact to the repository.
**
** Return true if a redirect occurs.
*/
static int forum_post(
const char *zTitle, /* Title. NULL for replies */
int iInReplyTo, /* Post replying to. 0 for new threads */
int iEdit, /* Post being edited, or zero for a new post */
const char *zUser, /* Username. NULL means use login name */
const char *zMimetype, /* Mimetype of content. */
const char *zContent /* Content */
){
char *zDate;
char *zI;
char *zG;
int iBasis;
Blob x, cksum, formatCheck, errMsg;
Manifest *pPost;
schema_forum();
if( iInReplyTo==0 && iEdit>0 ){
iBasis = iEdit;
iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d", iEdit);
}else{
iBasis = iInReplyTo;
}
webpage_assert( (zTitle==0)+(iInReplyTo==0)==1 );
blob_init(&x, 0, 0);
zDate = date_in_standard_format("now");
blob_appendf(&x, "D %s\n", zDate);
fossil_free(zDate);
zG = db_text(0,
"SELECT uuid FROM blob, forumpost"
" WHERE blob.rid==forumpost.froot"
" AND forumpost.fpid=%d", iBasis);
if( zG ){
blob_appendf(&x, "G %s\n", zG);
fossil_free(zG);
}
if( zTitle ){
blob_appendf(&x, "H %F\n", zTitle);
}
zI = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iInReplyTo);
if( zI ){
blob_appendf(&x, "I %s\n", zI);
fossil_free(zI);
}
if( fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){
blob_appendf(&x, "N %s\n", zMimetype);
}
if( iEdit>0 ){
char *zP = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iEdit);
if( zP==0 ) webpage_error("missing edit artifact %d", iEdit);
blob_appendf(&x, "P %s\n", zP);
fossil_free(zP);
}
if( zUser==0 ){
if( login_is_nobody() ){
zUser = "anonymous";
}else{
zUser = login_name();
}
}
blob_appendf(&x, "U %F\n", zUser);
blob_appendf(&x, "W %d\n%s\n", strlen(zContent), zContent);
md5sum_blob(&x, &cksum);
blob_appendf(&x, "Z %b\n", &cksum);
blob_reset(&cksum);
/* Verify that the artifact we are creating is well-formed */
blob_init(&formatCheck, 0, 0);
blob_init(&errMsg, 0, 0);
blob_copy(&formatCheck, &x);
pPost = manifest_parse(&formatCheck, 0, &errMsg);
if( pPost==0 ){
webpage_error("malformed forum post artifact - %s", blob_str(&errMsg));
}
webpage_assert( pPost->type==CFTYPE_FORUM );
manifest_destroy(pPost);
if( P("dryrun") ){
@ <div class='debug'>
@ This is the artifact that would have been generated:
@ <pre>%h(blob_str(&x))</pre>
@ </div>
blob_reset(&x);
return 0;
}else{
int nrid = wiki_put(&x, 0, forum_need_moderation());
cgi_redirectf("%R/forumthread/%S", rid_to_uuid(nrid));
return 1;
}
}
/*
** Paint the form elements for entering a Forum post
*/
static void forum_entry_widget(
const char *zTitle,
const char *zMimetype,
const char *zContent
){
if( zTitle ){
@ Title: <input type="input" name="title" value="%h(zTitle)" size="50"><br>
}
@ Markup style:
mimetype_option_menu(zMimetype);
@ <br><textarea name="content" class="wikiedit" cols="80" \
@ rows="25" wrap="virtual">%h(zContent)</textarea><br>
}
/*
** WEBPAGE: forumnew
**
** Start a new forum thread.
*/
void forumnew_page(void){
const char *zTitle = PDT("title","");
const char *zMimetype = PD("mimetype","text/x-fossil-wiki");
const char *zContent = PDT("content","");
login_check_credentials();
if( !g.perm.WrForum ){
login_needed(g.anon.WrForum);
return;
}
if( P("submit") ){
if( forum_post(zTitle, 0, 0, 0, zMimetype, zContent) ) return;
}
if( P("preview") ){
@ <h1>Preview:</h1>
forum_render(zTitle, zMimetype, zContent);
}
style_header("New Forum Thread");
@ <form action="%R/%s(g.zPath)" method="POST">
forum_entry_widget(zTitle, zMimetype, zContent);
@ <input type="submit" name="preview" value="Preview">
if( P("preview") ){
@ <input type="submit" name="submit" value="Submit">
}else{
@ <input type="submit" name="submit" value="Submit" disabled>
}
if( g.perm.Debug ){
/* For the test-forumnew page add these extra debugging controls */
@ <div class="debug">
@ <label><input type="checkbox" name="dryrun" %s(PCK("dryrun"))> \
@ Dry run</label>
@ <br><label><input type="checkbox" name="domod" %s(PCK("domod"))> \
@ Require moderator approval</label>
@ <br><label><input type="checkbox" name="showqp" %s(PCK("showqp"))> \
@ Show query parameters</label>
@ </div>
}
@ </form>
style_footer();
}
/*
** WEBPAGE: forumedit
**
** Edit an existing forum message.
** Query parameters:
**
** name=X Hash of the post to be editted. REQUIRED
*/
void forumedit_page(void){
int fpid;
Manifest *pPost;
const char *zMimetype = 0;
const char *zContent = 0;
const char *zTitle = 0;
int isCsrfSafe;
int isDelete = 0;
login_check_credentials();
if( !g.perm.WrForum ){
login_needed(g.anon.WrForum);
return;
}
fpid = symbolic_name_to_rid(PD("fpid",""), "f");
if( fpid<=0 || (pPost = manifest_get(fpid, CFTYPE_FORUM, 0))==0 ){
webpage_error("Missing or invalid fpid query parameter");
}
if( P("cancel") ){
cgi_redirectf("%R/forumthread/%S",P("fpid"));
return;
}
isCsrfSafe = cgi_csrf_safe(1);
if( g.perm.ModForum && isCsrfSafe ){
if( P("approve") ){
moderation_approve(fpid);
cgi_redirectf("%R/forumthread/%S",P("fpid"));
return;
}
if( P("reject") ){
moderation_disapprove(fpid);
cgi_redirectf("%R/forumthread/%S",P("fpid"));
return;
}
}
isDelete = P("nullout")!=0;
if( P("submit") && isCsrfSafe ){
int done = 1;
const char *zMimetype = PD("mimetype","text/x-fossil-wiki");
const char *zContent = PDT("content","");
if( P("reply") ){
done = forum_post(0, fpid, 0, 0, zMimetype, zContent);
}else if( P("edit") || isDelete ){
done = forum_post(P("title"), 0, fpid, 0, zMimetype, zContent);
}else{
webpage_error("Missing 'reply' query parameter");
}
if( done ) return;
}
if( isDelete ){
zMimetype = "text/x-fossil-wiki";
zContent = "";
if( pPost->zThreadTitle ) zTitle = "";
style_header("Delete %s", zTitle ? "Post" : "Reply");
@ <h1>Original Post:</h1>
forum_render(pPost->zThreadTitle, pPost->zMimetype, pPost->zWiki);
@ <h1>Change Into:</h1>
forum_render(zTitle, zMimetype, zContent);
@ <form action="%R/forumedit" method="POST">
@ <input type="hidden" name="fpid" value="%h(P("fpid"))">
@ <input type="hidden" name="nullout" value="1">
@ <input type="hidden" name="mimetype" value="%h(zMimetype)">
@ <input type="hidden" name="content" value="%h(zContent)">
if( zTitle ){
@ <input type="hidden" name="title" value="%h(zTitle)">
}
}else if( P("edit") ){
/* Provide an edit to the fpid post */
zMimetype = P("mimetype");
zContent = PT("content");
zTitle = P("title");
if( zContent==0 ) zContent = fossil_strdup(pPost->zWiki);
if( zMimetype==0 ) zMimetype = fossil_strdup(pPost->zMimetype);
if( zTitle==0 && pPost->zThreadTitle!=0 ){
zTitle = fossil_strdup(pPost->zThreadTitle);
}
style_header("Edit %s", zTitle ? "Post" : "Reply");
@ <h1>Original Post:</h1>
forum_render(pPost->zThreadTitle, pPost->zMimetype, pPost->zWiki);
if( P("preview") ){
@ <h1>Preview Of Editted Post:</h1>
forum_render(zTitle, zMimetype, zContent);
}
@ <h1>Enter A Reply:</h1>
@ <form action="%R/forumedit" method="POST">
@ <input type="hidden" name="fpid" value="%h(P("fpid"))">
@ <input type="hidden" name="edit" value="1">
forum_entry_widget(zTitle, zMimetype, zContent);
}else{
/* Reply */
zMimetype = PD("mimetype","text/x-fossil-wiki");
zContent = PDT("content","");
style_header("Reply");
@ <h1>Replying To:</h1>
forum_render(0, pPost->zMimetype, pPost->zWiki);
if( P("preview") ){
@ <h1>Preview:</h1>
forum_render(0, zMimetype,zContent);
}
@ <h1>Enter A Reply:</h1>
@ <form action="%R/forumedit" method="POST">
@ <input type="hidden" name="fpid" value="%h(P("fpid"))">
@ <input type="hidden" name="reply" value="1">
forum_entry_widget(0, zMimetype, zContent);
}
if( !isDelete ){
@ <input type="submit" name="preview" value="Preview">
}
@ <input type="submit" name="cancel" value="Cancel">
if( P("preview") || isDelete ){
@ <input type="submit" name="submit" value="Submit">
}
if( g.perm.Debug ){
/* For the test-forumnew page add these extra debugging controls */
@ <div class="debug">
@ <label><input type="checkbox" name="dryrun" %s(PCK("dryrun"))> \
@ Dry run</label>
@ <br><label><input type="checkbox" name="domod" %s(PCK("domod"))> \
@ Require moderator approval</label>
@ <br><label><input type="checkbox" name="showqp" %s(PCK("showqp"))> \
@ Show query parameters</label>
@ </div>
}
@ </form>
style_footer();
}