| | @@ -47,10 +47,11 @@ |
| 47 | 47 | ForumPost *pNext; /* Next in chronological order */ |
| 48 | 48 | ForumPost *pPrev; /* Previous in chronological order */ |
| 49 | 49 | ForumPost *pDisplay; /* Next in display order */ |
| 50 | 50 | int nEdit; /* Number of edits to this post */ |
| 51 | 51 | int nIndent; /* Number of levels of indentation for this post */ |
| 52 | + int iClosed; /* See forum_rid_is_closed() */ |
| 52 | 53 | }; |
| 53 | 54 | |
| 54 | 55 | /* |
| 55 | 56 | ** A single instance of the following tracks all entries for a thread. |
| 56 | 57 | */ |
| | @@ -77,10 +78,285 @@ |
| 77 | 78 | db_bind_int(&q, "$rid", rid); |
| 78 | 79 | res = db_step(&q)==SQLITE_ROW; |
| 79 | 80 | db_reset(&q); |
| 80 | 81 | return res; |
| 81 | 82 | } |
| 83 | + |
| 84 | +/* |
| 85 | +** Given a valid forumpost.fpid value, this function returns the first |
| 86 | +** fpid in the chain of edits for that forum post, or rid if no prior |
| 87 | +** versions are found. |
| 88 | +*/ |
| 89 | +static int forumpost_head_rid(int rid){ |
| 90 | + Stmt q; |
| 91 | + int rcRid = rid; |
| 92 | + |
| 93 | + db_prepare(&q, "SELECT fprev FROM forumpost" |
| 94 | + " WHERE fpid=:rid AND fprev IS NOT NULL"); |
| 95 | + db_bind_int(&q, ":rid", rid); |
| 96 | + while( SQLITE_ROW==db_step(&q) ){ |
| 97 | + rcRid = db_column_int(&q, 0); |
| 98 | + db_reset(&q); |
| 99 | + db_bind_int(&q, ":rid", rcRid); |
| 100 | + } |
| 101 | + db_finalize(&q); |
| 102 | + return rcRid; |
| 103 | +} |
| 104 | + |
| 105 | +/* |
| 106 | +** Returns true if p, or any parent of p, has a non-zero iClosed |
| 107 | +** value. Returns 0 if !p. For an edited chain of post, the tag is |
| 108 | +** checked on the pEditHead entry, to simplify subsequent unlocking of |
| 109 | +** the post. |
| 110 | +** |
| 111 | +** If bCheckIrt is true then p's thread in-response-to parents are |
| 112 | +** checked (recursively) for closure, else only p is checked. |
| 113 | +*/ |
| 114 | +static int forumpost_is_closed(ForumPost *p, int bCheckIrt){ |
| 115 | + while(p){ |
| 116 | + if( p->pEditHead ) p = p->pEditHead; |
| 117 | + if( p->iClosed || !bCheckIrt ) return p->iClosed; |
| 118 | + p = p->pIrt; |
| 119 | + } |
| 120 | + return 0; |
| 121 | +} |
| 122 | + |
| 123 | +/* |
| 124 | +** Given a forum post RID, this function returns true if that post has |
| 125 | +** (or inherits) an active "closed" tag. If bCheckIrt is true then |
| 126 | +** the post to which the given post responds is also checked |
| 127 | +** (recursively), else they are not. When checking in-response-to |
| 128 | +** posts, the first one which is closed ends the search. |
| 129 | +** |
| 130 | +** Note that this function checks _exactly_ the given rid, whereas |
| 131 | +** forum post closure/re-opening is always applied to the head of an |
| 132 | +** edit chain so that we get consistent implied locking beheavior for |
| 133 | +** later versions and responses to arbitrary versions in the |
| 134 | +** chain. Even so, the "closed" tag is applied as a propagating tag |
| 135 | +** so will apply to all edits in a given chain. |
| 136 | +** |
| 137 | +** The return value is one of: |
| 138 | +** |
| 139 | +** - 0 if no "closed" tag is found. |
| 140 | +** |
| 141 | +** - The tagxref.rowid of the tagxref entry for the closure if rid is |
| 142 | +** the forum post to which the closure applies. |
| 143 | +** |
| 144 | +** - (-tagxref.rowid) if the given rid inherits a "closed" tag from an |
| 145 | +** IRT forum post. |
| 146 | +*/ |
| 147 | +static int forum_rid_is_closed(int rid, int bCheckIrt){ |
| 148 | + static Stmt qIrt = empty_Stmt_m; |
| 149 | + int rc = 0, i = 0; |
| 150 | + /* TODO: this can probably be turned into a CTE by someone with |
| 151 | + ** superior SQL-fu. */ |
| 152 | + for( ; rid; i++ ){ |
| 153 | + rc = rid_has_active_tag_name(rid, "closed"); |
| 154 | + if( rc || !bCheckIrt ) break; |
| 155 | + else if( !qIrt.pStmt ) { |
| 156 | + db_static_prepare(&qIrt, |
| 157 | + "SELECT firt FROM forumpost " |
| 158 | + "WHERE fpid=$fpid ORDER BY fmtime DESC" |
| 159 | + ); |
| 160 | + } |
| 161 | + db_bind_int(&qIrt, "$fpid", rid); |
| 162 | + rid = SQLITE_ROW==db_step(&qIrt) ? db_column_int(&qIrt, 0) : 0; |
| 163 | + db_reset(&qIrt); |
| 164 | + } |
| 165 | + return i ? -rc : rc; |
| 166 | +} |
| 167 | + |
| 168 | +/* |
| 169 | +** Closes or re-opens the given forum RID via addition of a new |
| 170 | +** control artifact into the repository. In order to provide |
| 171 | +** consistent behavior for implied closing of responses and later |
| 172 | +** versions, it always acts on the first version of the given forum |
| 173 | +** post, walking the forumpost.fprev values to find the head of the |
| 174 | +** chain. |
| 175 | +** |
| 176 | +** If doClose is true then a propagating "closed" tag is added, except |
| 177 | +** as noted below, with the given optional zReason string as the tag's |
| 178 | +** value. If doClose is false then any active "closed" tag on frid is |
| 179 | +** cancelled, except as noted below. zReason is ignored if doClose is |
| 180 | +** false or if zReason is NULL or starts with a NUL byte. |
| 181 | +** |
| 182 | +** This function only adds a "closed" tag if forum_rid_is_closed() |
| 183 | +** indicates that frid's head is not closed. If a parent post is |
| 184 | +** already closed, no tag is added. Similarly, it will only remove a |
| 185 | +** "closed" tag from a post which has its own "closed" tag, and will |
| 186 | +** not remove an inherited one from a parent post. |
| 187 | +** |
| 188 | +** If doClose is true and frid is closed (directly or inherited), this |
| 189 | +** is a no-op. Likewise, if doClose is false and frid itself is not |
| 190 | +** closed (not accounting for an inherited closed tag), this is a |
| 191 | +** no-op. |
| 192 | +** |
| 193 | +** Returns true if it actually creates a new tag, else false. Fails |
| 194 | +** fatally on error. If it returns true then any ForumPost::iClosed |
| 195 | +** values from previously loaded posts are invalidated if they refer |
| 196 | +** to the amended post or a response to it. |
| 197 | +** |
| 198 | +** Sidebars: |
| 199 | +** |
| 200 | +** - Unless the caller has a transaction open, via |
| 201 | +** db_begin_transaction(), there is a very tiny race condition |
| 202 | +** window during which the caller's idea of whether or not the forum |
| 203 | +** post is closed may differ from the current repository state. |
| 204 | +** |
| 205 | +** - This routine assumes that frid really does refer to a forum post. |
| 206 | +** |
| 207 | +** - This routine assumes that frid is not private or pending |
| 208 | +** moderation. |
| 209 | +** |
| 210 | +** - Closure of a forum post requires a propagating "closed" tag to |
| 211 | +** account for how edits of posts are handled. This differs from |
| 212 | +** closure of a branch, where a non-propagating tag is used. |
| 213 | +*/ |
| 214 | +static int forumpost_close(int frid, int doClose, const char *zReason){ |
| 215 | + Blob artifact = BLOB_INITIALIZER; /* Output artifact */ |
| 216 | + Blob cksum = BLOB_INITIALIZER; /* Z-card */ |
| 217 | + int iClosed; /* true if frid is closed */ |
| 218 | + int trid; /* RID of new control artifact */ |
| 219 | + char *zUuid; /* UUID of head version of post */ |
| 220 | + |
| 221 | + db_begin_transaction(); |
| 222 | + frid = forumpost_head_rid(frid); |
| 223 | + iClosed = forum_rid_is_closed(frid, 1); |
| 224 | + if( (iClosed && doClose |
| 225 | + /* Already closed, noting that in the case of (iClosed<0), it's |
| 226 | + ** actually a parent which is closed. */) |
| 227 | + || (iClosed<=0 && !doClose |
| 228 | + /* This entry is not closed, but a parent post may be. */) ){ |
| 229 | + db_end_transaction(0); |
| 230 | + return 0; |
| 231 | + } |
| 232 | + if( doClose==0 || (zReason && !zReason[0]) ){ |
| 233 | + zReason = 0; |
| 234 | + } |
| 235 | + zUuid = rid_to_uuid(frid); |
| 236 | + blob_appendf(&artifact, "D %z\n", date_in_standard_format( "now" )); |
| 237 | + blob_appendf(&artifact, |
| 238 | + "T %cclosed %s%s%F\n", |
| 239 | + doClose ? '*' : '-', zUuid, |
| 240 | + zReason ? " " : "", zReason ? zReason : ""); |
| 241 | + blob_appendf(&artifact, "U %F\n", login_name()); |
| 242 | + md5sum_blob(&artifact, &cksum); |
| 243 | + blob_appendf(&artifact, "Z %b\n", &cksum); |
| 244 | + blob_reset(&cksum); |
| 245 | + trid = content_put_ex(&artifact, 0, 0, 0, 0); |
| 246 | + if( trid==0 ){ |
| 247 | + fossil_fatal("Error saving tag artifact: %s", g.zErrMsg); |
| 248 | + } |
| 249 | + if( manifest_crosslink(trid, &artifact, |
| 250 | + MC_NONE /*MC_PERMIT_HOOKS?*/)==0 ){ |
| 251 | + fossil_fatal("%s", g.zErrMsg); |
| 252 | + } |
| 253 | + assert( blob_is_reset(&artifact) ); |
| 254 | + db_add_unsent(trid); |
| 255 | + admin_log("%s forum post %S", doClose ? "Close" : "Re-open", zUuid); |
| 256 | + fossil_free(zUuid); |
| 257 | + /* Potential TODO: if (iClosed>0) then we could find the initial tag |
| 258 | + ** artifact and content_deltify(thatRid,&trid,1,0). Given the tiny |
| 259 | + ** size of these artifacts, however, that would save little space, |
| 260 | + ** if any. */ |
| 261 | + db_end_transaction(0); |
| 262 | + return 1; |
| 263 | +} |
| 264 | + |
| 265 | +/* |
| 266 | +** Returns true if the forum-close-policy setting is true, else false, |
| 267 | +** caching the result for subsequent calls. |
| 268 | +*/ |
| 269 | +static int forumpost_close_policy(void){ |
| 270 | + static int closePolicy = -99; |
| 271 | + |
| 272 | + if( closePolicy==-99 ){ |
| 273 | + closePolicy = db_get_boolean("forum-close-policy",0)>0; |
| 274 | + } |
| 275 | + return closePolicy; |
| 276 | +} |
| 277 | + |
| 278 | +/* |
| 279 | +** Returns 1 if the current user is an admin, -1 if the current user |
| 280 | +** is a forum moderator and the forum-close-policy setting is true, |
| 281 | +** else returns 0. The value is cached for subsequent calls. |
| 282 | +*/ |
| 283 | +static int forumpost_may_close(void){ |
| 284 | + static int permClose = -99; |
| 285 | + if( permClose!=-99 ){ |
| 286 | + return permClose; |
| 287 | + }else if( g.perm.Admin ){ |
| 288 | + return permClose = 1; |
| 289 | + }else if( g.perm.ModForum ){ |
| 290 | + return permClose = forumpost_close_policy()>0 ? -1 : 0; |
| 291 | + }else{ |
| 292 | + return permClose = 0; |
| 293 | + } |
| 294 | +} |
| 295 | + |
| 296 | +/* |
| 297 | +** If iClosed is true and the current user forumpost-close privileges, |
| 298 | +** this renders either a checkbox to unlock forum post fpid (if |
| 299 | +** iClosed>0) or a SPAN.warning element that the given post inherits |
| 300 | +** the CLOSED status from a parent post (if iClosed<0). If neither of |
| 301 | +** the initial conditions is true, this is a no-op. |
| 302 | +*/ |
| 303 | +static void forumpost_emit_closed_state(int fpid, int iClosed){ |
| 304 | + const char *zCommon; |
| 305 | + int iHead = forumpost_head_rid(fpid); |
| 306 | + const int permClose = forumpost_may_close(); |
| 307 | + |
| 308 | + zCommon = forumpost_close_policy()==0 |
| 309 | + ? "Admins may close or re-open posts, or respond to closed posts." |
| 310 | + : "Admins or moderators " |
| 311 | + "may close or re-open posts, or respond to closed posts."; |
| 312 | + /*@ forumpost_emit_closed_state(%d(fpid), %d(iClosed))<br/>*/ |
| 313 | + if( iHead != fpid ){ |
| 314 | + iClosed = forum_rid_is_closed(iHead, 1); |
| 315 | + /*@ forumpost_emit_closed_state() %d(iHead), %d(iClosed)*/ |
| 316 | + } |
| 317 | + if( iClosed<0 ){ |
| 318 | + @ <div class="warning forumpost-closure-warning">\ |
| 319 | + @ This post is CLOSED via a parent post. %s(zCommon)\ |
| 320 | + @ </div> |
| 321 | + return; |
| 322 | + } |
| 323 | + else if( iClosed==0 ){ |
| 324 | + if( permClose==0 ) return; |
| 325 | + @ <div class="warning forumpost-closure-warning"> |
| 326 | + @ <form method="post" action="%R/forumpost_close"> |
| 327 | + @ <input type="hidden" name="fpid" value="%z(rid_to_uuid(iHead))" /> |
| 328 | + @ <input type="submit" value="CLOSE this post and its responses" /> |
| 329 | + @ <span>%s(zCommon)</span> |
| 330 | + @ <span>This does NOT save any pending changes in |
| 331 | + @ the editor!</span> |
| 332 | + @ </form></div> |
| 333 | + return; |
| 334 | + } |
| 335 | + assert( iClosed>0 ); |
| 336 | + /* Only show the "unlock" option on a post which is actually |
| 337 | + ** closed, not on a post which inherits that state. */ |
| 338 | + @ <div class="warning forumpost-closure-warning">\ |
| 339 | + @ This post is CLOSED. %s(zCommon) |
| 340 | + if( permClose ){ |
| 341 | + @ <form method="post" action="%R/forumpost_reopen"> |
| 342 | + @ <input type="hidden" name="fpid" value="%z(rid_to_uuid(iHead))" /> |
| 343 | + @ <input type="submit" value="Re-open this post and its responses" /> |
| 344 | + @ <span>This does NOT save any pending changes in |
| 345 | + @ the editor!</span> |
| 346 | + @ </form> |
| 347 | + } |
| 348 | + @ </div> |
| 349 | +} |
| 350 | + |
| 351 | +/* |
| 352 | +** Emits a warning that the current forum post is CLOSED and can only |
| 353 | +** be edited or responded to by an administrator. */ |
| 354 | +static void forumpost_error_closed(void){ |
| 355 | + @ <div class='error'>This (sub)thread is CLOSED and can only be |
| 356 | + @ edited or replied to by an admin user.</div> |
| 357 | +} |
| 82 | 358 | |
| 83 | 359 | /* |
| 84 | 360 | ** Delete a complete ForumThread and all its entries. |
| 85 | 361 | */ |
| 86 | 362 | static void forumthread_delete(ForumThread *pThread){ |
| | @@ -215,10 +491,13 @@ |
| 215 | 491 | for(; p; p=p->pEditPrev ){ |
| 216 | 492 | p->nEdit = pPost->nEdit; |
| 217 | 493 | p->pEditTail = pPost; |
| 218 | 494 | } |
| 219 | 495 | } |
| 496 | + pPost->iClosed = forum_rid_is_closed(pPost->pEditHead |
| 497 | + ? pPost->pEditHead->fpid |
| 498 | + : pPost->fpid, 1); |
| 220 | 499 | } |
| 221 | 500 | db_finalize(&q); |
| 222 | 501 | |
| 223 | 502 | if( computeHierarchy ){ |
| 224 | 503 | /* Compute the hierarchical display order */ |
| | @@ -300,25 +579,31 @@ |
| 300 | 579 | pThread = forumthread_create(froot, 1); |
| 301 | 580 | fossil_print("Chronological:\n"); |
| 302 | 581 | fossil_print( |
| 303 | 582 | /* 0 1 2 3 4 5 6 7 */ |
| 304 | 583 | /* 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123 */ |
| 305 | | - " sid rev fpid pIrt pEditPrev pEditTail hash\n"); |
| 584 | + " sid rev closed fpid pIrt pEditPrev pEditTail hash\n"); |
| 306 | 585 | for(p=pThread->pFirst; p; p=p->pNext){ |
| 307 | | - fossil_print("%4d %4d %9d %9d %9d %9d %8.8s\n", p->sid, p->rev, |
| 586 | + fossil_print("%4d %4d %7d %9d %9d %9d %9d %8.8s\n", |
| 587 | + p->sid, p->rev, |
| 588 | + p->iClosed, |
| 308 | 589 | p->fpid, p->pIrt ? p->pIrt->fpid : 0, |
| 309 | 590 | p->pEditPrev ? p->pEditPrev->fpid : 0, |
| 310 | 591 | p->pEditTail ? p->pEditTail->fpid : 0, p->zUuid); |
| 311 | 592 | } |
| 312 | 593 | fossil_print("\nDisplay\n"); |
| 313 | 594 | for(p=pThread->pDisplay; p; p=p->pDisplay){ |
| 314 | 595 | fossil_print("%*s", (p->nIndent-1)*3, ""); |
| 315 | 596 | if( p->pEditTail ){ |
| 316 | | - fossil_print("%d->%d\n", p->fpid, p->pEditTail->fpid); |
| 597 | + fossil_print("%d->%d", p->fpid, p->pEditTail->fpid); |
| 317 | 598 | }else{ |
| 318 | | - fossil_print("%d\n", p->fpid); |
| 599 | + fossil_print("%d", p->fpid); |
| 600 | + } |
| 601 | + if( p->iClosed ){ |
| 602 | + fossil_print(" [closed%s]", p->iClosed<0 ? " via parent" : ""); |
| 319 | 603 | } |
| 604 | + fossil_print("\n"); |
| 320 | 605 | } |
| 321 | 606 | forumthread_delete(pThread); |
| 322 | 607 | } |
| 323 | 608 | |
| 324 | 609 | /* |
| | @@ -511,23 +796,25 @@ |
| 511 | 796 | char *zHist; /* History query string */ |
| 512 | 797 | Manifest *pManifest; /* Manifest comprising the current post */ |
| 513 | 798 | int bPrivate; /* True for posts awaiting moderation */ |
| 514 | 799 | int bSameUser; /* True if author is also the reader */ |
| 515 | 800 | int iIndent; /* Indent level */ |
| 801 | + int iClosed; /* True if (sub)thread is closed */ |
| 516 | 802 | const char *zMimetype;/* Formatting MIME type */ |
| 517 | 803 | |
| 518 | 804 | /* Get the manifest for the post. Abort if not found (e.g. shunned). */ |
| 519 | 805 | pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0); |
| 520 | 806 | if( !pManifest ) return; |
| 521 | | - |
| 807 | + iClosed = forumpost_is_closed(p, 1); |
| 522 | 808 | /* When not in raw mode, create the border around the post. */ |
| 523 | 809 | if( !bRaw ){ |
| 524 | 810 | /* Open the <div> enclosing the post. Set the class string to mark the post |
| 525 | 811 | ** as selected and/or obsolete. */ |
| 526 | 812 | iIndent = (p->pEditHead ? p->pEditHead->nIndent : p->nIndent)-1; |
| 527 | 813 | @ <div id='forum%d(p->fpid)' class='forumTime\ |
| 528 | 814 | @ %s(bSelect ? " forumSel" : "")\ |
| 815 | + @ %s(iClosed ? " forumClosed" : "")\ |
| 529 | 816 | @ %s(p->pEditTail ? " forumObs" : "")' \ |
| 530 | 817 | if( iIndent && iIndentScale ){ |
| 531 | 818 | @ style='margin-left:%d(iIndent*iIndentScale)ex;'> |
| 532 | 819 | }else{ |
| 533 | 820 | @ > |
| | @@ -543,11 +830,11 @@ |
| 543 | 830 | ** * The post is unedited |
| 544 | 831 | ** * The post was last edited by the original author |
| 545 | 832 | ** * The post was last edited by a different person |
| 546 | 833 | */ |
| 547 | 834 | if( p->pEditHead ){ |
| 548 | | - zDate = db_text(0, "SELECT datetime(%.17g,toLocal())", |
| 835 | + zDate = db_text(0, "SELECT datetime(%.17g,toLocal())", |
| 549 | 836 | p->pEditHead->rDate); |
| 550 | 837 | }else{ |
| 551 | 838 | zPosterName = forum_post_display_name(p, pManifest); |
| 552 | 839 | zEditorName = zPosterName; |
| 553 | 840 | } |
| | @@ -555,11 +842,11 @@ |
| 555 | 842 | if( p->pEditPrev ){ |
| 556 | 843 | zPosterName = forum_post_display_name(p->pEditHead, 0); |
| 557 | 844 | zEditorName = forum_post_display_name(p, pManifest); |
| 558 | 845 | zHist = bHist ? "" : zQuery[0]==0 ? "?hist" : "&hist"; |
| 559 | 846 | @ <h3 class='forumPostHdr'>(%d(p->sid)\ |
| 560 | | - @ .%0*d(fossil_num_digits(p->nEdit))(p->rev)) \ |
| 847 | + @ .%0*d(fossil_num_digits(p->nEdit))(p->rev)) |
| 561 | 848 | if( fossil_strcmp(zPosterName, zEditorName)==0 ){ |
| 562 | 849 | @ By %s(zPosterName) on %h(zDate) edited from \ |
| 563 | 850 | @ %z(href("%R/forumpost/%S%s%s",p->pEditPrev->zUuid,zQuery,zHist))\ |
| 564 | 851 | @ %d(p->sid).%0*d(fossil_num_digits(p->nEdit))(p->pEditPrev->rev)</a> |
| 565 | 852 | }else{ |
| | @@ -568,11 +855,11 @@ |
| 568 | 855 | @ %z(href("%R/forumpost/%S%s%s",p->pEditPrev->zUuid,zQuery,zHist))\ |
| 569 | 856 | @ %d(p->sid).%0*d(fossil_num_digits(p->nEdit))(p->pEditPrev->rev)</a> |
| 570 | 857 | } |
| 571 | 858 | }else{ |
| 572 | 859 | zPosterName = forum_post_display_name(p, pManifest); |
| 573 | | - @ <h3 class='forumPostHdr'>(%d(p->sid)) \ |
| 860 | + @ <h3 class='forumPostHdr'>(%d(p->sid)) |
| 574 | 861 | @ By %s(zPosterName) on %h(zDate) |
| 575 | 862 | } |
| 576 | 863 | fossil_free(zDate); |
| 577 | 864 | |
| 578 | 865 | |
| | @@ -631,18 +918,26 @@ |
| 631 | 918 | /* When not in raw mode, finish creating the border around the post. */ |
| 632 | 919 | if( !bRaw ){ |
| 633 | 920 | /* If the user is able to write to the forum and if this post has not been |
| 634 | 921 | ** edited, create a form with various interaction buttons. */ |
| 635 | 922 | if( g.perm.WrForum && !p->pEditTail ){ |
| 636 | | - @ <div><form action="%R/forumedit" method="POST"> |
| 923 | + @ <div class="forumpost-single-controls">\ |
| 924 | + @ <form action="%R/forumedit" method="POST"> |
| 637 | 925 | @ <input type="hidden" name="fpid" value="%s(p->zUuid)"> |
| 638 | 926 | if( !bPrivate ){ |
| 639 | | - /* Reply and Edit are only available if the post has been approved. */ |
| 640 | | - @ <input type="submit" name="reply" value="Reply"> |
| 641 | | - if( g.perm.Admin || bSameUser ){ |
| 642 | | - @ <input type="submit" name="edit" value="Edit"> |
| 643 | | - @ <input type="submit" name="nullout" value="Delete"> |
| 927 | + /* Reply and Edit are only available if the post has been |
| 928 | + ** approved. Closed threads can only be edited or replied to |
| 929 | + ** if forumpost_may_close() is true but a user may delete |
| 930 | + ** their own posts even if they are closed. */ |
| 931 | + if( forumpost_may_close() || !iClosed ){ |
| 932 | + @ <input type="submit" name="reply" value="Reply"> |
| 933 | + if( g.perm.Admin || (bSameUser && !iClosed) ){ |
| 934 | + @ <input type="submit" name="edit" value="Edit"> |
| 935 | + } |
| 936 | + if( g.perm.Admin || bSameUser ){ |
| 937 | + @ <input type="submit" name="nullout" value="Delete"> |
| 938 | + } |
| 644 | 939 | } |
| 645 | 940 | }else if( g.perm.ModForum ){ |
| 646 | 941 | /* Allow moderators to approve or reject pending posts. Also allow |
| 647 | 942 | ** forum supervisors to mark non-special users as trusted and therefore |
| 648 | 943 | ** able to post unmoderated. */ |
| | @@ -657,11 +952,20 @@ |
| 657 | 952 | } |
| 658 | 953 | }else if( bSameUser ){ |
| 659 | 954 | /* Allow users to delete (reject) their own pending posts. */ |
| 660 | 955 | @ <input type="submit" name="reject" value="Delete"> |
| 661 | 956 | } |
| 662 | | - @ </form></div> |
| 957 | + @ </form> |
| 958 | + if( bSelect && forumpost_may_close() && iClosed>=0 ){ |
| 959 | + int iHead = forumpost_head_rid(p->fpid); |
| 960 | + @ <form method="post" \ |
| 961 | + @ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'> |
| 962 | + @ <input type="hidden" name="fpid" value="%z(rid_to_uuid(iHead))" /> |
| 963 | + @ <input type="submit" value='%s(iClosed ? "Re-open" : "Close")' /> |
| 964 | + @ </form> |
| 965 | + } |
| 966 | + @ </div> |
| 663 | 967 | } |
| 664 | 968 | @ </div> |
| 665 | 969 | } |
| 666 | 970 | |
| 667 | 971 | /* Clean up. */ |
| | @@ -1040,10 +1344,15 @@ |
| 1040 | 1344 | Blob x, cksum, formatCheck, errMsg; |
| 1041 | 1345 | Manifest *pPost; |
| 1042 | 1346 | int nContent = zContent ? (int)strlen(zContent) : 0; |
| 1043 | 1347 | |
| 1044 | 1348 | schema_forum(); |
| 1349 | + if( !g.perm.Admin && (iEdit || iInReplyTo) |
| 1350 | + && forum_rid_is_closed(iEdit ? iEdit : iInReplyTo, 1) ){ |
| 1351 | + forumpost_error_closed(); |
| 1352 | + return 0; |
| 1353 | + } |
| 1045 | 1354 | if( iEdit==0 && whitespace_only(zContent) ){ |
| 1046 | 1355 | return 0; |
| 1047 | 1356 | } |
| 1048 | 1357 | if( iInReplyTo==0 && iEdit>0 ){ |
| 1049 | 1358 | iBasis = iEdit; |
| | @@ -1141,10 +1450,44 @@ |
| 1141 | 1450 | @ %z(href("%R/markup_help"))Markup style</a>: |
| 1142 | 1451 | mimetype_option_menu(zMimetype, "mimetype"); |
| 1143 | 1452 | @ <br><textarea aria-label="Content:" name="content" class="wikiedit" \ |
| 1144 | 1453 | @ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea><br> |
| 1145 | 1454 | } |
| 1455 | + |
| 1456 | +/* |
| 1457 | +** WEBPAGE: forumpost_close hidden |
| 1458 | +** WEBPAGE: forumpost_reopen hidden |
| 1459 | +** |
| 1460 | +** fpid=X Hash of the post to be edited. REQUIRED |
| 1461 | +** reason=X Optional reason for closure. |
| 1462 | +** |
| 1463 | +** Closes or re-opens the given forum post, within the bounds of the |
| 1464 | +** API for forumpost_close(). After (perhaps) modifying the "closed" |
| 1465 | +** status of the given thread, it redirects to that post's thread |
| 1466 | +** view. Requires admin privileges. |
| 1467 | +*/ |
| 1468 | +void forum_page_close(void){ |
| 1469 | + const char *zFpid = PD("fpid",""); |
| 1470 | + const char *zReason = 0; |
| 1471 | + int fClose; |
| 1472 | + int fpid; |
| 1473 | + |
| 1474 | + login_check_credentials(); |
| 1475 | + if( forumpost_may_close()==0 ){ |
| 1476 | + login_needed(g.anon.Admin); |
| 1477 | + return; |
| 1478 | + } |
| 1479 | + fpid = symbolic_name_to_rid(zFpid, "f"); |
| 1480 | + if( fpid<=0 ){ |
| 1481 | + webpage_error("Missing or invalid fpid query parameter"); |
| 1482 | + } |
| 1483 | + fClose = sqlite3_strglob("*_close*", g.zPath)==0; |
| 1484 | + if( fClose ) zReason = PD("reason",0); |
| 1485 | + forumpost_close(fpid, fClose, zReason); |
| 1486 | + cgi_redirectf("%R/forumpost/%S",zFpid); |
| 1487 | + return; |
| 1488 | +} |
| 1146 | 1489 | |
| 1147 | 1490 | /* |
| 1148 | 1491 | ** WEBPAGE: forumnew |
| 1149 | 1492 | ** WEBPAGE: forumedit |
| 1150 | 1493 | ** |
| | @@ -1152,10 +1495,11 @@ |
| 1152 | 1495 | ** But first prompt to see if the user would like to log in. |
| 1153 | 1496 | */ |
| 1154 | 1497 | void forum_page_init(void){ |
| 1155 | 1498 | int isEdit; |
| 1156 | 1499 | char *zGoto; |
| 1500 | + |
| 1157 | 1501 | login_check_credentials(); |
| 1158 | 1502 | if( !g.perm.WrForum ){ |
| 1159 | 1503 | login_needed(g.anon.WrForum); |
| 1160 | 1504 | return; |
| 1161 | 1505 | } |
| | @@ -1291,11 +1635,13 @@ |
| 1291 | 1635 | const char *zTitle = 0; |
| 1292 | 1636 | char *zDate = 0; |
| 1293 | 1637 | const char *zFpid = PD("fpid",""); |
| 1294 | 1638 | int isCsrfSafe; |
| 1295 | 1639 | int isDelete = 0; |
| 1640 | + int iClosed = 0; |
| 1296 | 1641 | int bSameUser; /* True if author is also the reader */ |
| 1642 | + int bPreview; /* True in preview mode. */ |
| 1297 | 1643 | int bPrivate; /* True if post is private (not yet moderated) */ |
| 1298 | 1644 | |
| 1299 | 1645 | login_check_credentials(); |
| 1300 | 1646 | if( !g.perm.WrForum ){ |
| 1301 | 1647 | login_needed(g.anon.WrForum); |
| | @@ -1308,13 +1654,15 @@ |
| 1308 | 1654 | froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid); |
| 1309 | 1655 | if( froot==0 || (pRootPost = manifest_get(froot, CFTYPE_FORUM, 0))==0 ){ |
| 1310 | 1656 | webpage_error("fpid does not appear to be a forum post: \"%d\"", fpid); |
| 1311 | 1657 | } |
| 1312 | 1658 | if( P("cancel") ){ |
| 1313 | | - cgi_redirectf("%R/forumpost/%S",P("fpid")); |
| 1659 | + cgi_redirectf("%R/forumpost/%S",zFpid); |
| 1314 | 1660 | return; |
| 1315 | 1661 | } |
| 1662 | + bPreview = P("preview")!=0; |
| 1663 | + iClosed = forum_rid_is_closed(fpid, 1); |
| 1316 | 1664 | isCsrfSafe = cgi_csrf_safe(1); |
| 1317 | 1665 | bPrivate = content_is_private(fpid); |
| 1318 | 1666 | bSameUser = login_is_individual() |
| 1319 | 1667 | && fossil_strcmp(pPost->zUser, g.zLogin)==0; |
| 1320 | 1668 | if( isCsrfSafe && (g.perm.ModForum || (bPrivate && bSameUser)) ){ |
| | @@ -1376,10 +1724,11 @@ |
| 1376 | 1724 | if( pPost->zThreadTitle ) zTitle = ""; |
| 1377 | 1725 | style_header("Delete %s", zTitle ? "Post" : "Reply"); |
| 1378 | 1726 | @ <h1>Original Post:</h1> |
| 1379 | 1727 | forum_render(pPost->zThreadTitle, pPost->zMimetype, pPost->zWiki, |
| 1380 | 1728 | "forumEdit", 1); |
| 1729 | + forumpost_emit_closed_state(fpid, iClosed); |
| 1381 | 1730 | @ <h1>Change Into:</h1> |
| 1382 | 1731 | forum_render(zTitle, zMimetype, zContent,"forumEdit", 1); |
| 1383 | 1732 | @ <form action="%R/forume2" method="POST"> |
| 1384 | 1733 | @ <input type="hidden" name="fpid" value="%h(P("fpid"))"> |
| 1385 | 1734 | @ <input type="hidden" name="nullout" value="1"> |
| | @@ -1400,11 +1749,11 @@ |
| 1400 | 1749 | } |
| 1401 | 1750 | style_header("Edit %s", zTitle ? "Post" : "Reply"); |
| 1402 | 1751 | @ <h2>Original Post:</h2> |
| 1403 | 1752 | forum_render(pPost->zThreadTitle, pPost->zMimetype, pPost->zWiki, |
| 1404 | 1753 | "forumEdit", 1); |
| 1405 | | - if( P("preview") ){ |
| 1754 | + if( bPreview ){ |
| 1406 | 1755 | @ <h2>Preview of Edited Post:</h2> |
| 1407 | 1756 | forum_render(zTitle, zMimetype, zContent,"forumEdit", 1); |
| 1408 | 1757 | } |
| 1409 | 1758 | @ <h2>Revised Message:</h2> |
| 1410 | 1759 | @ <form action="%R/forume2" method="POST"> |
| | @@ -1429,11 +1778,11 @@ |
| 1429 | 1778 | zDisplayName = display_name_from_login(pPost->zUser); |
| 1430 | 1779 | @ <h3 class='forumPostHdr'>By %s(zDisplayName) on %h(zDate)</h3> |
| 1431 | 1780 | fossil_free(zDisplayName); |
| 1432 | 1781 | fossil_free(zDate); |
| 1433 | 1782 | forum_render(0, pPost->zMimetype, pPost->zWiki, "forumEdit", 1); |
| 1434 | | - if( P("preview") && !whitespace_only(zContent) ){ |
| 1783 | + if( bPreview && !whitespace_only(zContent) ){ |
| 1435 | 1784 | @ <h2>Preview:</h2> |
| 1436 | 1785 | forum_render(0, zMimetype,zContent, "forumEdit", 1); |
| 1437 | 1786 | } |
| 1438 | 1787 | @ <h2>Enter Reply:</h2> |
| 1439 | 1788 | @ <form action="%R/forume2" method="POST"> |
| | @@ -1444,16 +1793,140 @@ |
| 1444 | 1793 | } |
| 1445 | 1794 | if( !isDelete ){ |
| 1446 | 1795 | @ <input type="submit" name="preview" value="Preview"> |
| 1447 | 1796 | } |
| 1448 | 1797 | @ <input type="submit" name="cancel" value="Cancel"> |
| 1449 | | - if( (P("preview") && !whitespace_only(zContent)) || isDelete ){ |
| 1450 | | - @ <input type="submit" name="submit" value="Submit"> |
| 1798 | + if( (bPreview && !whitespace_only(zContent)) || isDelete ){ |
| 1799 | + if( !iClosed || g.perm.Admin ) { |
| 1800 | + @ <input type="submit" name="submit" value="Submit"> |
| 1801 | + } |
| 1451 | 1802 | } |
| 1452 | 1803 | forum_render_debug_options(); |
| 1453 | 1804 | @ </form> |
| 1454 | 1805 | forum_emit_js(); |
| 1806 | + forumpost_emit_closed_state(fpid, iClosed); |
| 1807 | + style_finish_page(); |
| 1808 | +} |
| 1809 | + |
| 1810 | +/* |
| 1811 | +** WEBPAGE: setup_forum |
| 1812 | +** |
| 1813 | +** Forum configuration and metrics. |
| 1814 | +*/ |
| 1815 | +void forum_setup(void){ |
| 1816 | + /* boolean config settings specific to the forum. */ |
| 1817 | + const char * zSettingsBool[] = { |
| 1818 | + "forum-close-policy", |
| 1819 | + NULL /* sentinel entry */ |
| 1820 | + }; |
| 1821 | + |
| 1822 | + login_check_credentials(); |
| 1823 | + if( !g.perm.Setup ){ |
| 1824 | + login_needed(g.anon.Setup); |
| 1825 | + return; |
| 1826 | + } |
| 1827 | + style_set_current_feature("forum"); |
| 1828 | + style_header("Forum Setup"); |
| 1829 | + |
| 1830 | + @ <h2>Metrics</h2> |
| 1831 | + { |
| 1832 | + int nPosts = db_int(0, "SELECT COUNT(*) FROM event WHERE type='f'"); |
| 1833 | + @ <p><a href='%R/forum'>Forum posts</a>: |
| 1834 | + @ <a href='%R/timeline?y=f'>%d(nPosts)</a></p> |
| 1835 | + } |
| 1836 | + |
| 1837 | + @ <h2>Supervisors</h2> |
| 1838 | + @ <p>Users with capabilities 's', 'a', or '6'.</p> |
| 1839 | + { |
| 1840 | + Stmt q = empty_Stmt; |
| 1841 | + int nRows = 0; |
| 1842 | + db_prepare(&q, "SELECT uid, login, cap FROM user " |
| 1843 | + "WHERE cap GLOB '*[as6]*' ORDER BY login"); |
| 1844 | + @ <table class='bordered'> |
| 1845 | + @ <thead><tr><th>User</th><th>Capabilities</th></tr></thead> |
| 1846 | + @ <tbody> |
| 1847 | + while( SQLITE_ROW==db_step(&q) ){ |
| 1848 | + const int iUid = db_column_int(&q, 0); |
| 1849 | + const char *zUser = db_column_text(&q, 1); |
| 1850 | + const char *zCap = db_column_text(&q, 2); |
| 1851 | + ++nRows; |
| 1852 | + @ <tr> |
| 1853 | + @ <td><a href='%R/setup_uedit?id=%d(iUid)'>%h(zUser)</a></td> |
| 1854 | + @ <td>(%h(zCap))</td> |
| 1855 | + @ </tr> |
| 1856 | + } |
| 1857 | + db_finalize(&q); |
| 1858 | + @</tbody></table> |
| 1859 | + if( 0==nRows ){ |
| 1860 | + @ No supervisors |
| 1861 | + }else{ |
| 1862 | + @ %d(nRows) supervisor(s) |
| 1863 | + } |
| 1864 | + } |
| 1865 | + |
| 1866 | + @ <h2>Moderators</h2> |
| 1867 | + @ <p>Users with capability '5'.</p> |
| 1868 | + { |
| 1869 | + Stmt q = empty_Stmt; |
| 1870 | + int nRows = 0; |
| 1871 | + db_prepare(&q, "SELECT uid, login, cap FROM user " |
| 1872 | + "WHERE cap GLOB '*5*' ORDER BY login"); |
| 1873 | + @ <table class='bordered'> |
| 1874 | + @ <thead><tr><th>User</th><th>Capabilities</th></tr></thead> |
| 1875 | + @ <tbody> |
| 1876 | + while( SQLITE_ROW==db_step(&q) ){ |
| 1877 | + const int iUid = db_column_int(&q, 0); |
| 1878 | + const char *zUser = db_column_text(&q, 1); |
| 1879 | + const char *zCap = db_column_text(&q, 2); |
| 1880 | + ++nRows; |
| 1881 | + @ <tr> |
| 1882 | + @ <td><a href='%R/setup_uedit?id=%d(iUid)'>%h(zUser)</a></td> |
| 1883 | + @ <td>(%h(zCap))</td> |
| 1884 | + @ </tr> |
| 1885 | + } |
| 1886 | + db_finalize(&q); |
| 1887 | + @ </tbody></table> |
| 1888 | + if( 0==nRows ){ |
| 1889 | + @ No non-supervisor moderators |
| 1890 | + }else{ |
| 1891 | + @ %d(nRows) moderator(s) |
| 1892 | + } |
| 1893 | + } |
| 1894 | + |
| 1895 | + @ <h2>Settings</h2> |
| 1896 | + @ <p>Configuration settings specific to the forum.</p> |
| 1897 | + if( P("submit") && cgi_csrf_safe(1) ){ |
| 1898 | + int i = 0; |
| 1899 | + const char *zSetting; |
| 1900 | + login_verify_csrf_secret(); |
| 1901 | + db_begin_transaction(); |
| 1902 | + while( (zSetting = zSettingsBool[i++]) ){ |
| 1903 | + const char *z = P(zSetting); |
| 1904 | + if( !z || !z[0] ) z = "off"; |
| 1905 | + db_set(zSetting/*works-like:"x"*/, z, 0); |
| 1906 | + } |
| 1907 | + db_end_transaction(0); |
| 1908 | + @ <p><em>Settings saved.</em></p> |
| 1909 | + } |
| 1910 | + { |
| 1911 | + int i = 0; |
| 1912 | + const char *zSetting; |
| 1913 | + @ <form action="%R/setup_forum" method="post"> |
| 1914 | + login_insert_csrf_secret(); |
| 1915 | + @ <table class='forum-settings-list'><tbody> |
| 1916 | + while( (zSetting = zSettingsBool[i++]) ){ |
| 1917 | + @ <tr><td> |
| 1918 | + onoff_attribute("", zSetting, zSetting/*works-like:"x"*/, 0, 0); |
| 1919 | + @ </td><td> |
| 1920 | + @ <a href='%R/help?cmd=%h(zSetting)'>%h(zSetting)</a> |
| 1921 | + @ </td></tr> |
| 1922 | + } |
| 1923 | + @ </tbody></table> |
| 1924 | + @ <input type='submit' name='submit' value='Apply changes'> |
| 1925 | + @ </form> |
| 1926 | + } |
| 1927 | + |
| 1455 | 1928 | style_finish_page(); |
| 1456 | 1929 | } |
| 1457 | 1930 | |
| 1458 | 1931 | /* |
| 1459 | 1932 | ** WEBPAGE: forummain |
| 1460 | 1933 | |