|
1
|
/* |
|
2
|
** Copyright (c) 2018 D. Richard Hipp |
|
3
|
** |
|
4
|
** This program is free software; you can redistribute it and/or |
|
5
|
** modify it under the terms of the Simplified BSD License (also |
|
6
|
** known as the "2-Clause License" or "FreeBSD License".) |
|
7
|
** |
|
8
|
** This program is distributed in the hope that it will be useful, |
|
9
|
** but without any warranty; without even the implied warranty of |
|
10
|
** merchantability or fitness for a particular purpose. |
|
11
|
** |
|
12
|
** Author contact information: |
|
13
|
** [email protected] |
|
14
|
** http://www.hwaci.com/drh/ |
|
15
|
** |
|
16
|
******************************************************************************* |
|
17
|
** |
|
18
|
** This file contains code used to generate the user forum. |
|
19
|
*/ |
|
20
|
#include "config.h" |
|
21
|
#include <assert.h> |
|
22
|
#include "forum.h" |
|
23
|
|
|
24
|
/* |
|
25
|
** Default to using Markdown markup |
|
26
|
*/ |
|
27
|
#define DEFAULT_FORUM_MIMETYPE "text/x-markdown" |
|
28
|
|
|
29
|
#if INTERFACE |
|
30
|
/* |
|
31
|
** Each instance of the following object represents a single message - |
|
32
|
** either the initial post, an edit to a post, a reply, or an edit to |
|
33
|
** a reply. |
|
34
|
*/ |
|
35
|
struct ForumPost { |
|
36
|
int fpid; /* rid for this post */ |
|
37
|
int sid; /* Serial ID number */ |
|
38
|
int rev; /* Revision number */ |
|
39
|
char *zUuid; /* Artifact hash */ |
|
40
|
char *zDisplayName; /* Name of user who wrote this post */ |
|
41
|
double rDate; /* Date for this post */ |
|
42
|
ForumPost *pIrt; /* This post replies to pIrt */ |
|
43
|
ForumPost *pEditHead; /* Original, unedited post */ |
|
44
|
ForumPost *pEditTail; /* Most recent edit for this post */ |
|
45
|
ForumPost *pEditNext; /* This post is edited by pEditNext */ |
|
46
|
ForumPost *pEditPrev; /* This post is an edit of pEditPrev */ |
|
47
|
ForumPost *pNext; /* Next in chronological order */ |
|
48
|
ForumPost *pPrev; /* Previous in chronological order */ |
|
49
|
ForumPost *pDisplay; /* Next in display order */ |
|
50
|
int nEdit; /* Number of edits to this post */ |
|
51
|
int nIndent; /* Number of levels of indentation for this post */ |
|
52
|
int iClosed; /* True if forum_rid_is_tagged("closed") */ |
|
53
|
}; |
|
54
|
|
|
55
|
/* |
|
56
|
** A single instance of the following tracks all entries for a thread. |
|
57
|
*/ |
|
58
|
struct ForumThread { |
|
59
|
ForumPost *pFirst; /* First post in chronological order */ |
|
60
|
ForumPost *pLast; /* Last post in chronological order */ |
|
61
|
ForumPost *pDisplay; /* Entries in display order */ |
|
62
|
ForumPost *pTail; /* Last on the display list */ |
|
63
|
int mxIndent; /* Maximum indentation level */ |
|
64
|
int nArtifact; /* Number of forum artifacts in this thread */ |
|
65
|
}; |
|
66
|
|
|
67
|
/* |
|
68
|
** A single entry from the forum-statuses setting. |
|
69
|
*/ |
|
70
|
struct ForumStatus { |
|
71
|
char *zLabel; /* Label for the UI */ |
|
72
|
char *zValue; /* status=X tag value */ |
|
73
|
}; |
|
74
|
|
|
75
|
/* |
|
76
|
** A list of ForumStatus objects. |
|
77
|
*/ |
|
78
|
struct ForumStatusList { |
|
79
|
struct ForumStatus *aStatus; /* List of statuses */ |
|
80
|
unsigned int n; /* Number of entries */ |
|
81
|
}; |
|
82
|
|
|
83
|
/* |
|
84
|
** Information passed into the status_match() SQL function |
|
85
|
** via the sqlite3_user_data() mechanism, and used by status_match() |
|
86
|
** to determine whether or not a particular forum thread should |
|
87
|
** be displayed. |
|
88
|
*/ |
|
89
|
struct ForumStatusMatch { |
|
90
|
const ForumStatusList *pFses; /* Parsed forum-statuses setting */ |
|
91
|
int eStatusTag; /* tagid for the "status" property */ |
|
92
|
unsigned int iMatch; /* Match this status value */ |
|
93
|
}; |
|
94
|
#endif /* INTERFACE */ |
|
95
|
|
|
96
|
|
|
97
|
/* |
|
98
|
** Returns a high-level representation of the forum-statuses setting. |
|
99
|
** This is a singleton, cached across calls. |
|
100
|
*/ |
|
101
|
const ForumStatusList * forum_statuses(void){ |
|
102
|
static ForumStatusList fses = {0,0}; |
|
103
|
static int once = 0; |
|
104
|
while( !once ){ |
|
105
|
++once; |
|
106
|
/* Read `forum-statuses` setting and transform it into the |
|
107
|
** fses object. |
|
108
|
** |
|
109
|
** Maybe: if it's empty, synthesize a length-1 list from |
|
110
|
** {value:"default",label:"Default",...}. It's expected that |
|
111
|
** usage may be slightly simplified if we always have a non-empty |
|
112
|
** list. A length-1 list is, for purposes of the UI, identical to |
|
113
|
** an empty one - status selection/filtering makes no sense if |
|
114
|
** there's only one choice. */ |
|
115
|
db_multi_exec( |
|
116
|
"CREATE TEMP TABLE IF NOT EXISTS forumstatus(" |
|
117
|
" ord INTEGER PRIMARY KEY, label, value" |
|
118
|
");" |
|
119
|
"DELETE FROM forumstatus;" |
|
120
|
"INSERT INTO forumstatus(label,value)" |
|
121
|
" WITH setting(v) AS (" |
|
122
|
" SELECT value v FROM config WHERE name='forum-statuses'" |
|
123
|
" )," |
|
124
|
" room(r) AS (" |
|
125
|
" SELECT e.value FROM setting s, jsonb_each(s.v) e" |
|
126
|
" WHERE json_valid(s.v, 0x02)" |
|
127
|
" )" |
|
128
|
" SELECT r->>'label', r->>'value'" |
|
129
|
" FROM room;" |
|
130
|
); |
|
131
|
fses.n = (unsigned)db_int(0, "SELECT count(*) FROM forumstatus"); |
|
132
|
if( fses.n ){ |
|
133
|
int i = 0; |
|
134
|
Stmt q; |
|
135
|
db_prepare(&q,"SELECT label, value FROM forumstatus" |
|
136
|
" ORDER BY ord"); |
|
137
|
fses.aStatus = fossil_malloc(sizeof(fses.aStatus[0]) * fses.n); |
|
138
|
while( SQLITE_ROW==db_step(&q) ){ |
|
139
|
ForumStatus * fs = &fses.aStatus[i++]; |
|
140
|
fs->zLabel = fossil_strdup(db_column_text(&q, 0)); |
|
141
|
fs->zValue = fossil_strdup(db_column_text(&q, 1)); |
|
142
|
} |
|
143
|
db_finalize(&q); |
|
144
|
} |
|
145
|
} |
|
146
|
return &fses; |
|
147
|
} |
|
148
|
|
|
149
|
/* |
|
150
|
** Search for a ForumStatus object by its tag value. If a match is |
|
151
|
** found, the corresponding object is returned. If no match is found |
|
152
|
** then (A) if bFirst is false then 0 is returned, else (B) the first |
|
153
|
** entry in the list is returned, noting that the list may be empty, |
|
154
|
** in which case 0 is returned. |
|
155
|
*/ |
|
156
|
static const ForumStatus * forum_status_by_value( |
|
157
|
const char *z, int bFirst |
|
158
|
){ |
|
159
|
const ForumStatusList * const fses = forum_statuses(); |
|
160
|
const ForumStatus * fs0 = 0; |
|
161
|
unsigned int i; |
|
162
|
if( !fses->n ) return 0; |
|
163
|
for( i = 0; i < fses->n; ++i ){ |
|
164
|
const ForumStatus * fs = &fses->aStatus[i]; |
|
165
|
if( 0==fossil_strcmp(z, fs->zValue) ){ |
|
166
|
return fs; |
|
167
|
}else if( !fs0 ){ |
|
168
|
fs0 = fs; |
|
169
|
} |
|
170
|
} |
|
171
|
return bFirst ? fs0 : 0; |
|
172
|
} |
|
173
|
|
|
174
|
/* |
|
175
|
** COMMAND: test-forum-statuses |
|
176
|
*/ |
|
177
|
void test_forum_statuses_cmd(void){ |
|
178
|
const ForumStatusList * fses; |
|
179
|
unsigned i; |
|
180
|
db_find_and_open_repository(0,0); |
|
181
|
fses = forum_statuses(); |
|
182
|
for(i = 0; i < fses->n; ++i ){ |
|
183
|
const ForumStatus * fs = &fses->aStatus[i]; |
|
184
|
fossil_print("Status: %!j %!j\n", fs->zValue, fs->zLabel); |
|
185
|
assert( fs==forum_status_by_value(fs->zValue, 0) ); |
|
186
|
} |
|
187
|
fossil_print("Total statuses: %u\n", i); |
|
188
|
} |
|
189
|
|
|
190
|
/* |
|
191
|
** Return true if the forum post with the given rid has been |
|
192
|
** subsequently edited. |
|
193
|
*/ |
|
194
|
int forum_rid_has_been_edited(int rid){ |
|
195
|
static Stmt q; |
|
196
|
int res; |
|
197
|
db_static_prepare(&q, |
|
198
|
"SELECT 1 FROM forumpost A, forumpost B" |
|
199
|
" WHERE A.fpid=$rid AND B.froot=A.froot AND B.fprev=$rid" |
|
200
|
); |
|
201
|
db_bind_int(&q, "$rid", rid); |
|
202
|
res = db_step(&q)==SQLITE_ROW; |
|
203
|
db_reset(&q); |
|
204
|
return res; |
|
205
|
} |
|
206
|
|
|
207
|
/* |
|
208
|
** Given a valid forumpost.fpid value, this function returns the |
|
209
|
** initial forumpost.fpid in the chain of edits for that forum post, |
|
210
|
** or rid if no prior versions are found. |
|
211
|
*/ |
|
212
|
int forumpost_head_rid(int rid){ |
|
213
|
static Stmt q = empty_Stmt_m; |
|
214
|
int rcRid = rid; |
|
215
|
if( !q.pStmt ){ |
|
216
|
db_static_prepare(&q, |
|
217
|
"SELECT fprev FROM forumpost" |
|
218
|
" WHERE fpid=:rid AND fprev IS NOT NULL" |
|
219
|
); |
|
220
|
} |
|
221
|
db_bind_int(&q, ":rid", rid); |
|
222
|
while( SQLITE_ROW==db_step(&q) ){ |
|
223
|
rcRid = db_column_int(&q, 0); |
|
224
|
db_reset(&q); |
|
225
|
db_bind_int(&q, ":rid", rcRid); |
|
226
|
} |
|
227
|
db_reset(&q); |
|
228
|
return rcRid; |
|
229
|
} |
|
230
|
|
|
231
|
/* |
|
232
|
** Works like forumpost_head_rid() but expects zUuid to be an |
|
233
|
** unambiguous forum post name. It may be a hash prefix, so long as |
|
234
|
** it's unambiguous. Returns the rid of the head post, -1 if the name |
|
235
|
** is ambiguous, and 0 if the name cannot be resolved as a forum post. |
|
236
|
*/ |
|
237
|
int forumpost_head_rid2(const char *zUuid){ |
|
238
|
const int fpid = symbolic_name_to_rid(zUuid, "f"); |
|
239
|
return fpid>0 |
|
240
|
? forumpost_head_rid(fpid) |
|
241
|
: fpid; |
|
242
|
} |
|
243
|
|
|
244
|
/* |
|
245
|
** Given a forum post RID and user name, returns true if zUserName |
|
246
|
** matches the event.(euser,user) field for a formpost entry with the |
|
247
|
** matching RID. Returns false if no match is found. If zUserName is |
|
248
|
** 0 then login_name() is used. |
|
249
|
*/ |
|
250
|
int forumpost_is_owner(int rid, const char *zUserName){ |
|
251
|
static Stmt q; |
|
252
|
int rc; |
|
253
|
if( !q.pStmt ){ |
|
254
|
db_static_prepare( |
|
255
|
&q, "SELECT 1 FROM event" |
|
256
|
" WHERE type='f' AND objid=$rid" |
|
257
|
" AND coalesce(euser,user)=$user" |
|
258
|
); |
|
259
|
} |
|
260
|
db_bind_int(&q, "$rid", rid); |
|
261
|
db_bind_text(&q, "$user", zUserName ? zUserName : login_name()); |
|
262
|
rc = SQLITE_ROW==db_step(&q); |
|
263
|
db_reset(&q); |
|
264
|
return rc; |
|
265
|
} |
|
266
|
|
|
267
|
/* |
|
268
|
** Returns true if p, or any parent of p, has a non-zero iClosed |
|
269
|
** value. Returns 0 if !p. For an edited chain of post, the tag is |
|
270
|
** checked on the pEditHead entry, to simplify subsequent unlocking of |
|
271
|
** the post. |
|
272
|
** |
|
273
|
** If bCheckIrt is true then p's thread in-response-to parents are |
|
274
|
** checked (recursively) for closure, else only p is checked. |
|
275
|
*/ |
|
276
|
static int forumpost_is_closed( |
|
277
|
ForumThread *pThread, /* Thread that the post is a member of */ |
|
278
|
ForumPost *p, /* the forum post */ |
|
279
|
int bCheckIrt /* True to check In-Reply-To posts */ |
|
280
|
){ |
|
281
|
int mx = pThread->nArtifact+1; |
|
282
|
while( p && (mx--)>0 ){ |
|
283
|
if( p->pEditHead ) p = p->pEditHead; |
|
284
|
if( p->iClosed || !bCheckIrt ) return p->iClosed; |
|
285
|
p = p->pIrt; |
|
286
|
} |
|
287
|
return 0; |
|
288
|
} |
|
289
|
|
|
290
|
/* |
|
291
|
** Given a forum post RID, this function returns true if that post has |
|
292
|
** (or inherits) an active tag named zTagName. If bCheckIrt is true |
|
293
|
** then the post to which the given post responds is also checked |
|
294
|
** (recursively), else they are not. When checking in-response-to |
|
295
|
** posts, the first one which is closed ends the search. |
|
296
|
** |
|
297
|
** This function checks _exactly_ the given rid, whereas forum post |
|
298
|
** closure/re-opening is always applied to the head of an edit chain |
|
299
|
** so that we get consistent implied locking behavior for later |
|
300
|
** versions and responses to arbitrary versions in the chain. Even so, |
|
301
|
** the "closed" tag is applied as a propagating tag so will apply to |
|
302
|
** all edits in a given chain. |
|
303
|
** |
|
304
|
** The return value is one of: |
|
305
|
** |
|
306
|
** - 0 if no matching tag is found. |
|
307
|
** |
|
308
|
** - The tagxref.rowid of the tagxref entry for the closure if rid is |
|
309
|
** the forum post to which the closure applies. |
|
310
|
** |
|
311
|
** - (-tagxref.rowid) if the given rid inherits the tag from an IRT |
|
312
|
** forum post. |
|
313
|
*/ |
|
314
|
static int forum_rid_is_tagged(int rid, const char *zTagName, int bCheckIrt){ |
|
315
|
static Stmt qIrt = empty_Stmt_m; |
|
316
|
int rc = 0, i = 0; |
|
317
|
/* TODO: this can probably be turned into a CTE by someone with |
|
318
|
** superior SQL-fu. */ |
|
319
|
for( ; rid; i++ ){ |
|
320
|
rc = rid_has_active_tag_name(rid, zTagName); |
|
321
|
if( rc || !bCheckIrt ) break; |
|
322
|
else if( !qIrt.pStmt ) { |
|
323
|
db_static_prepare(&qIrt, |
|
324
|
"SELECT firt FROM forumpost " |
|
325
|
"WHERE fpid=$fpid ORDER BY fmtime DESC" |
|
326
|
); |
|
327
|
} |
|
328
|
db_bind_int(&qIrt, "$fpid", rid); |
|
329
|
rid = SQLITE_ROW==db_step(&qIrt) ? db_column_int(&qIrt, 0) : 0; |
|
330
|
db_reset(&qIrt); |
|
331
|
} |
|
332
|
return i ? -rc : rc; |
|
333
|
} |
|
334
|
|
|
335
|
/* True if moderation of forum posts performs the same operation |
|
336
|
** on its attachments. */ |
|
337
|
#define FORUMPOST_MOD_ATTACHMENTS 1 |
|
338
|
#if FORUMPOST_MOD_ATTACHMENTS |
|
339
|
/* |
|
340
|
** Internal helper for moderation_forumpost_...(). |
|
341
|
*/ |
|
342
|
static void forumpost_prep_pending_attachids(Stmt *q, int fpid){ |
|
343
|
db_prepare( |
|
344
|
q, |
|
345
|
"SELECT attachid FROM attachment " |
|
346
|
"WHERE target=(" |
|
347
|
" SELECT uuid FROM blob WHERE rid=%d" |
|
348
|
") and attachid in (" |
|
349
|
" SELECT objid FROM modreq" |
|
350
|
")", |
|
351
|
forumpost_head_rid(fpid) |
|
352
|
); |
|
353
|
} |
|
354
|
#endif |
|
355
|
|
|
356
|
/* |
|
357
|
** Approve the given forum post RID and any pending-approval |
|
358
|
** attachments associated with its initial version. |
|
359
|
*/ |
|
360
|
static void moderation_forumpost_approve(int fpid){ |
|
361
|
#if !FORUMPOST_MOD_ATTACHMENTS |
|
362
|
moderation_approve('f', fpid); |
|
363
|
#else |
|
364
|
/* Also approve any pending attachments */ |
|
365
|
Stmt q; |
|
366
|
moderation_approve('f', fpid); |
|
367
|
forumpost_prep_pending_attachids(&q, fpid); |
|
368
|
while( SQLITE_ROW==db_step(&q) ){ |
|
369
|
moderation_approve('a', db_column_int(&q, 0)); |
|
370
|
} |
|
371
|
db_finalize(&q); |
|
372
|
#endif |
|
373
|
} |
|
374
|
|
|
375
|
/* |
|
376
|
** Disapprove the given forum post and any pending-moderation |
|
377
|
** attachments on its initial version. |
|
378
|
*/ |
|
379
|
static void moderation_forumpost_disapprove(int fpid){ |
|
380
|
#if !FORUMPOST_MOD_ATTACHMENTS |
|
381
|
moderation_disapprove(fpid); |
|
382
|
#else |
|
383
|
/* Also disapprove any pending attachments */ |
|
384
|
Stmt q; |
|
385
|
moderation_disapprove(fpid); |
|
386
|
forumpost_prep_pending_attachids(&q, fpid); |
|
387
|
while( SQLITE_ROW==db_step(&q) ){ |
|
388
|
moderation_disapprove(db_column_int(&q, 0)); |
|
389
|
} |
|
390
|
db_finalize(&q); |
|
391
|
#endif |
|
392
|
} |
|
393
|
#undef FORUMPOST_MOD_ATTACHMENTS |
|
394
|
|
|
395
|
/* |
|
396
|
** Applies or cancels a tag named zTagName on the given forum RID via |
|
397
|
** addition of a new control artifact into the repository. In order to |
|
398
|
** provide consistent behavior, it always acts on the first version of |
|
399
|
** the given forum post, walking the forumpost.fprev values to find |
|
400
|
** the head of the chain. |
|
401
|
** |
|
402
|
** If addTag is true then a propagating tag is added, except as noted |
|
403
|
** below, with the given optional zValue string as the tag's |
|
404
|
** value. If addTag is false then any matching active tag on frid is |
|
405
|
** cancelled, except as noted below. zValue is ignored if it is NULL |
|
406
|
** or starts with a NUL byte, or if addTag is false. |
|
407
|
** |
|
408
|
** This function only adds a tag if forum_rid_is_tagged() indicates |
|
409
|
** that frid's head is not tagged. If a parent post is already tagged, |
|
410
|
** no tag is added. Similarly, it will only remove a tag from a post |
|
411
|
** which has its own tag, and will not remove an inherited one from a |
|
412
|
** parent post. |
|
413
|
** |
|
414
|
** If addTag is true and frid is already tagged, this is a |
|
415
|
** no-op. Likewise, if addTag is false and frid is not tagged (not |
|
416
|
** accounting for a tag inherited via an in-response-to post), this is |
|
417
|
** a no-op. |
|
418
|
** |
|
419
|
** Returns a positive value (a new tag.tagid value) if it actually |
|
420
|
** creates a new tag, else 0. On error it returns a negative alue |
|
421
|
** and g.zErrMsg "should" contain details. |
|
422
|
** |
|
423
|
** If it returns true then state from previously-loaded posts may be |
|
424
|
** invalidated if they refer to the amended post or a response to it. |
|
425
|
** e.g. if zTagName is "closed" then ForumPost::iClosed values may be |
|
426
|
** stale. |
|
427
|
** |
|
428
|
** Sidebars: |
|
429
|
** |
|
430
|
** - Unless the caller has a transaction open, via |
|
431
|
** db_begin_transaction(), there is a very tiny race condition |
|
432
|
** window during which the caller's idea of whether or not the forum |
|
433
|
** post is tagged may differ from the current repository state. |
|
434
|
** |
|
435
|
** - This routine assumes that frid really does refer to a forum post. |
|
436
|
** |
|
437
|
** - This routine assumes that frid is not private or pending |
|
438
|
** moderation. |
|
439
|
** |
|
440
|
** - The applied tag is propagating so so that "closed" tags can |
|
441
|
** account for how edits of posts are handled. This differs from |
|
442
|
** closure of a branch, where a non-propagating tag is used. |
|
443
|
*/ |
|
444
|
static int forumpost_tag(int frid, int addTag, |
|
445
|
const char *zTagName, |
|
446
|
const char *zValue){ |
|
447
|
Blob artifact = BLOB_INITIALIZER; /* Output artifact */ |
|
448
|
Blob cksum = BLOB_INITIALIZER; /* Z-card */ |
|
449
|
int iTagged; /* true if frid is already tagged */ |
|
450
|
int trid; /* RID of new control artifact */ |
|
451
|
char *zUuid; /* UUID of head version of post */ |
|
452
|
|
|
453
|
db_begin_transaction(); |
|
454
|
frid = forumpost_head_rid(frid); |
|
455
|
iTagged = forum_rid_is_tagged(frid, zTagName, 0); |
|
456
|
if( !addTag && !iTagged ){ |
|
457
|
/* Nothing to do. We never tag an IRT-inherited post via this |
|
458
|
** function. */ |
|
459
|
db_end_transaction(0); |
|
460
|
return 0; |
|
461
|
} |
|
462
|
if( !addTag || (zValue && !zValue[0]) ){ |
|
463
|
zValue = 0; |
|
464
|
} |
|
465
|
if( addTag && iTagged ){ |
|
466
|
char *zOld = 0; |
|
467
|
int cmp; |
|
468
|
rid_has_tag2(frid, zTagName, &zOld); |
|
469
|
cmp = fossil_strcmp(zOld, zValue); |
|
470
|
fossil_free(zOld); |
|
471
|
if( 0==cmp ){ |
|
472
|
/* Same value - leave it as is. */ |
|
473
|
db_end_transaction(0); |
|
474
|
return 0; |
|
475
|
} |
|
476
|
} |
|
477
|
zUuid = rid_to_uuid(frid); |
|
478
|
blob_appendf(&artifact, "D %z\n", date_in_standard_format( "now" )); |
|
479
|
blob_appendf(&artifact, "T %c%s %s%s%F\n", |
|
480
|
addTag ? '*' : '-', zTagName, |
|
481
|
zUuid, zValue ? " " : "", zValue ? zValue : ""); |
|
482
|
blob_appendf(&artifact, "U %F\n", login_name()); |
|
483
|
md5sum_blob(&artifact, &cksum); |
|
484
|
blob_appendf(&artifact, "Z %b\n", &cksum); |
|
485
|
blob_reset(&cksum); |
|
486
|
trid = content_put_ex(&artifact, 0, 0, 0, 0); |
|
487
|
if( trid==0 ){ |
|
488
|
return -1; |
|
489
|
} |
|
490
|
if( manifest_crosslink(trid, &artifact, MC_NONE)==0 ){ |
|
491
|
return -2; |
|
492
|
} |
|
493
|
assert( blob_is_reset(&artifact) ); |
|
494
|
db_add_unsent(trid); |
|
495
|
admin_log("Tag forum post %S with %c%s", |
|
496
|
zUuid, addTag ? '*' : '-', zTagName); |
|
497
|
fossil_free(zUuid); |
|
498
|
db_end_transaction(0); |
|
499
|
return trid; |
|
500
|
} |
|
501
|
|
|
502
|
/* |
|
503
|
** COMMAND: test-forumpost-tag |
|
504
|
** |
|
505
|
** Usage: %fossil test-forumpost-tag ?-cancel? THREADID TAGNAME TAGVAL |
|
506
|
** |
|
507
|
** A tester for forumpost_tag(). It always rolls back changes. |
|
508
|
*/ |
|
509
|
void test_forumpost_tag_command(void){ |
|
510
|
int fpid; |
|
511
|
int rc; |
|
512
|
const char *zPost; |
|
513
|
const char *zTag; |
|
514
|
const char *zVal; |
|
515
|
const int bAdd = find_option("cancel","",0)==0; |
|
516
|
|
|
517
|
db_find_and_open_repository(0,0); |
|
518
|
verify_all_options(); |
|
519
|
if( g.argc<5 ){ |
|
520
|
usage("forum-post-id tag-name value"); |
|
521
|
} |
|
522
|
zPost = g.argv[2]; |
|
523
|
zTag = g.argv[3]; |
|
524
|
zVal = g.argv[4]; |
|
525
|
|
|
526
|
db_begin_transaction(); |
|
527
|
fpid = forumpost_head_rid2(zPost); |
|
528
|
if( fpid<=0 ){ |
|
529
|
fossil_fatal("Cannot resolve post ID %s", zPost); |
|
530
|
} |
|
531
|
fossil_print("%s => %d => %z\n", zTag, fpid, |
|
532
|
rid_to_uuid(fpid)); |
|
533
|
rc = forumpost_tag(fpid, bAdd, zTag, zVal); |
|
534
|
fossil_print("tag fpid=%d taxgxref.tagid=%d\n", fpid, rc); |
|
535
|
db_end_transaction(1); |
|
536
|
} |
|
537
|
|
|
538
|
/* |
|
539
|
** Returns true if the forum-close-policy setting is true, else false, |
|
540
|
** caching the result for subsequent calls. |
|
541
|
*/ |
|
542
|
static int forumpost_close_policy(void){ |
|
543
|
static int closePolicy = -99; |
|
544
|
|
|
545
|
if( closePolicy==-99 ){ |
|
546
|
closePolicy = db_get_boolean("forum-close-policy",0)>0; |
|
547
|
} |
|
548
|
return closePolicy; |
|
549
|
} |
|
550
|
|
|
551
|
/* |
|
552
|
** Returns 1 if the current user is an admin, -1 if the current user |
|
553
|
** is a forum moderator and the forum-close-policy setting is true, |
|
554
|
** else returns 0. The value is cached for subsequent calls. |
|
555
|
** |
|
556
|
** This policy also determines whether non-admin forum moderators |
|
557
|
** may delete forum attachments. |
|
558
|
*/ |
|
559
|
int forumpost_may_close(void){ |
|
560
|
static int permClose = -99; |
|
561
|
if( permClose!=-99 ){ |
|
562
|
return permClose; |
|
563
|
}else if( g.perm.Admin ){ |
|
564
|
return permClose = 1; |
|
565
|
}else if( g.perm.ModForum ){ |
|
566
|
return permClose = forumpost_close_policy()>0 ? -1 : 0; |
|
567
|
}else{ |
|
568
|
return permClose = 0; |
|
569
|
} |
|
570
|
} |
|
571
|
|
|
572
|
/* |
|
573
|
** Emits a warning that the current forum post is CLOSED and can only |
|
574
|
** be edited or responded to by an administrator. */ |
|
575
|
static void forumpost_error_closed(void){ |
|
576
|
@ <div class='error'>This (sub)thread is CLOSED and can only be |
|
577
|
@ edited or replied to by an admin user.</div> |
|
578
|
} |
|
579
|
|
|
580
|
/* |
|
581
|
** Delete a complete ForumThread and all its entries. |
|
582
|
*/ |
|
583
|
static void forumthread_delete(ForumThread *pThread){ |
|
584
|
ForumPost *pPost, *pNext; |
|
585
|
for(pPost=pThread->pFirst; pPost; pPost = pNext){ |
|
586
|
pNext = pPost->pNext; |
|
587
|
fossil_free(pPost->zUuid); |
|
588
|
fossil_free(pPost->zDisplayName); |
|
589
|
fossil_free(pPost); |
|
590
|
} |
|
591
|
fossil_free(pThread); |
|
592
|
} |
|
593
|
|
|
594
|
/* |
|
595
|
** Search a ForumPost list forwards looking for the post with fpid |
|
596
|
*/ |
|
597
|
static ForumPost *forumpost_forward(ForumPost *p, int fpid){ |
|
598
|
while( p && p->fpid!=fpid ) p = p->pNext; |
|
599
|
return p; |
|
600
|
} |
|
601
|
|
|
602
|
/* |
|
603
|
** Search backwards for a ForumPost |
|
604
|
*/ |
|
605
|
static ForumPost *forumpost_backward(ForumPost *p, int fpid){ |
|
606
|
while( p && p->fpid!=fpid ) p = p->pPrev; |
|
607
|
return p; |
|
608
|
} |
|
609
|
|
|
610
|
/* |
|
611
|
** Add a post to the display list |
|
612
|
*/ |
|
613
|
static void forumpost_add_to_display(ForumThread *pThread, ForumPost *p){ |
|
614
|
if( pThread->pDisplay==0 ){ |
|
615
|
pThread->pDisplay = p; |
|
616
|
}else{ |
|
617
|
pThread->pTail->pDisplay = p; |
|
618
|
} |
|
619
|
pThread->pTail = p; |
|
620
|
} |
|
621
|
|
|
622
|
/* |
|
623
|
** Extend the display list for pThread by adding all entries that |
|
624
|
** reference fpid. The first such post will be no earlier then |
|
625
|
** post "p". |
|
626
|
*/ |
|
627
|
static void forumthread_display_order( |
|
628
|
ForumThread *pThread, /* The complete thread */ |
|
629
|
ForumPost *pBase /* Add replies to this post */ |
|
630
|
){ |
|
631
|
ForumPost *p; |
|
632
|
ForumPost *pPrev = 0; |
|
633
|
ForumPost *pBaseIrt; |
|
634
|
for(p=pBase->pNext; p; p=p->pNext){ |
|
635
|
if( !p->pEditPrev && p->pIrt ){ |
|
636
|
pBaseIrt = p->pIrt->pEditHead ? p->pIrt->pEditHead : p->pIrt; |
|
637
|
if( pBaseIrt==pBase ){ |
|
638
|
if( pPrev ){ |
|
639
|
pPrev->nIndent = pBase->nIndent + 1; |
|
640
|
forumpost_add_to_display(pThread, pPrev); |
|
641
|
forumthread_display_order(pThread, pPrev); |
|
642
|
} |
|
643
|
pPrev = p; |
|
644
|
} |
|
645
|
} |
|
646
|
} |
|
647
|
if( pPrev ){ |
|
648
|
pPrev->nIndent = pBase->nIndent + 1; |
|
649
|
if( pPrev->nIndent>pThread->mxIndent ) pThread->mxIndent = pPrev->nIndent; |
|
650
|
forumpost_add_to_display(pThread, pPrev); |
|
651
|
forumthread_display_order(pThread, pPrev); |
|
652
|
} |
|
653
|
} |
|
654
|
|
|
655
|
/* |
|
656
|
** Construct a ForumThread object given the root record id. |
|
657
|
*/ |
|
658
|
static ForumThread *forumthread_create(int froot, int computeHierarchy){ |
|
659
|
ForumThread *pThread; |
|
660
|
ForumPost *pPost; |
|
661
|
ForumPost *p; |
|
662
|
Stmt q; |
|
663
|
int sid = 1; |
|
664
|
int firt, fprev; |
|
665
|
pThread = fossil_malloc( sizeof(*pThread) ); |
|
666
|
memset(pThread, 0, sizeof(*pThread)); |
|
667
|
db_prepare(&q, |
|
668
|
"SELECT fpid, firt, fprev, (SELECT uuid FROM blob WHERE rid=fpid), fmtime" |
|
669
|
" FROM forumpost" |
|
670
|
" WHERE froot=%d ORDER BY fmtime", |
|
671
|
froot |
|
672
|
); |
|
673
|
while( db_step(&q)==SQLITE_ROW ){ |
|
674
|
pPost = fossil_malloc( sizeof(*pPost) ); |
|
675
|
memset(pPost, 0, sizeof(*pPost)); |
|
676
|
pPost->fpid = db_column_int(&q, 0); |
|
677
|
firt = db_column_int(&q, 1); |
|
678
|
fprev = db_column_int(&q, 2); |
|
679
|
pPost->zUuid = fossil_strdup(db_column_text(&q,3)); |
|
680
|
pPost->rDate = db_column_double(&q,4); |
|
681
|
if( !fprev ) pPost->sid = sid++; |
|
682
|
pPost->pPrev = pThread->pLast; |
|
683
|
pPost->pNext = 0; |
|
684
|
if( pThread->pLast==0 ){ |
|
685
|
pThread->pFirst = pPost; |
|
686
|
}else{ |
|
687
|
pThread->pLast->pNext = pPost; |
|
688
|
} |
|
689
|
pThread->pLast = pPost; |
|
690
|
pThread->nArtifact++; |
|
691
|
|
|
692
|
/* Find the in-reply-to post. Default to the topic post if the replied-to |
|
693
|
** post cannot be found. */ |
|
694
|
if( firt ){ |
|
695
|
pPost->pIrt = pThread->pFirst; |
|
696
|
for(p=pThread->pFirst; p; p=p->pNext){ |
|
697
|
if( p->fpid==firt ){ |
|
698
|
pPost->pIrt = p; |
|
699
|
break; |
|
700
|
} |
|
701
|
} |
|
702
|
} |
|
703
|
|
|
704
|
/* Maintain the linked list of post edits. */ |
|
705
|
if( fprev ){ |
|
706
|
p = forumpost_backward(pPost->pPrev, fprev); |
|
707
|
p->pEditNext = pPost; |
|
708
|
pPost->sid = p->sid; |
|
709
|
pPost->rev = p->rev+1; |
|
710
|
pPost->nEdit = p->nEdit+1; |
|
711
|
pPost->pEditPrev = p; |
|
712
|
pPost->pEditHead = p->pEditHead ? p->pEditHead : p; |
|
713
|
for(; p; p=p->pEditPrev ){ |
|
714
|
p->nEdit = pPost->nEdit; |
|
715
|
p->pEditTail = pPost; |
|
716
|
} |
|
717
|
} |
|
718
|
pPost->iClosed = forum_rid_is_tagged(pPost->pEditHead |
|
719
|
? pPost->pEditHead->fpid |
|
720
|
: pPost->fpid, "closed", 1); |
|
721
|
} |
|
722
|
db_finalize(&q); |
|
723
|
|
|
724
|
if( computeHierarchy ){ |
|
725
|
/* Compute the hierarchical display order */ |
|
726
|
pPost = pThread->pFirst; |
|
727
|
pPost->nIndent = 1; |
|
728
|
pThread->mxIndent = 1; |
|
729
|
forumpost_add_to_display(pThread, pPost); |
|
730
|
forumthread_display_order(pThread, pPost); |
|
731
|
} |
|
732
|
|
|
733
|
/* Return the result */ |
|
734
|
return pThread; |
|
735
|
} |
|
736
|
|
|
737
|
/* |
|
738
|
** List all forum threads to standard output. |
|
739
|
*/ |
|
740
|
static void forum_thread_list(void){ |
|
741
|
Stmt q; |
|
742
|
db_prepare(&q, |
|
743
|
" SELECT" |
|
744
|
" datetime(max(fmtime))," |
|
745
|
" sum(fprev IS NULL)," |
|
746
|
" froot" |
|
747
|
" FROM forumpost" |
|
748
|
" GROUP BY froot" |
|
749
|
" ORDER BY 1;" |
|
750
|
); |
|
751
|
fossil_print(" id cnt most recent post\n"); |
|
752
|
fossil_print("------ ---- -------------------\n"); |
|
753
|
while( db_step(&q)==SQLITE_ROW ){ |
|
754
|
fossil_print("%6d %4d %s\n", |
|
755
|
db_column_int(&q, 2), |
|
756
|
db_column_int(&q, 1), |
|
757
|
db_column_text(&q, 0) |
|
758
|
); |
|
759
|
} |
|
760
|
db_finalize(&q); |
|
761
|
} |
|
762
|
|
|
763
|
/* |
|
764
|
** COMMAND: test-forumthread |
|
765
|
** |
|
766
|
** Usage: %fossil test-forumthread [THREADID] |
|
767
|
** |
|
768
|
** Display a summary of all messages on a thread THREADID. If the |
|
769
|
** THREADID argument is omitted, then show a list of all threads. |
|
770
|
** |
|
771
|
** This command is intended for testing an analysis only. |
|
772
|
*/ |
|
773
|
void forumthread_cmd(void){ |
|
774
|
int fpid; |
|
775
|
int froot; |
|
776
|
const char *zName; |
|
777
|
ForumThread *pThread; |
|
778
|
ForumPost *p; |
|
779
|
|
|
780
|
db_find_and_open_repository(0,0); |
|
781
|
verify_all_options(); |
|
782
|
if( g.argc==2 ){ |
|
783
|
forum_thread_list(); |
|
784
|
return; |
|
785
|
} |
|
786
|
if( g.argc!=3 ) usage("THREADID"); |
|
787
|
zName = g.argv[2]; |
|
788
|
fpid = symbolic_name_to_rid(zName, "f"); |
|
789
|
if( fpid<=0 ){ |
|
790
|
fpid = db_int(0, "SELECT rid FROM blob WHERE rid=%d", atoi(zName)); |
|
791
|
} |
|
792
|
if( fpid<=0 ){ |
|
793
|
fossil_fatal("unknown or ambiguous forum id: \"%s\"", zName); |
|
794
|
} |
|
795
|
froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid); |
|
796
|
if( froot==0 ){ |
|
797
|
fossil_fatal("Not a forum post: \"%s\"", zName); |
|
798
|
} |
|
799
|
fossil_print("fpid = %d\n", fpid); |
|
800
|
fossil_print("froot = %d\n", froot); |
|
801
|
pThread = forumthread_create(froot, 1); |
|
802
|
fossil_print("count = %d\n", pThread->nArtifact); |
|
803
|
fossil_print("Chronological:\n"); |
|
804
|
fossil_print( |
|
805
|
/* 0 1 2 3 4 5 6 7 */ |
|
806
|
/* 123456789 123456789 123456789 123456789 123456789 123456789 123456789 123 */ |
|
807
|
" sid rev closed fpid pIrt pEditPrev pEditTail hash\n"); |
|
808
|
for(p=pThread->pFirst; p; p=p->pNext){ |
|
809
|
fossil_print("%4d %4d %7d %9d %9d %9d %9d %8.8s\n", |
|
810
|
p->sid, p->rev, |
|
811
|
p->iClosed, |
|
812
|
p->fpid, p->pIrt ? p->pIrt->fpid : 0, |
|
813
|
p->pEditPrev ? p->pEditPrev->fpid : 0, |
|
814
|
p->pEditTail ? p->pEditTail->fpid : 0, p->zUuid); |
|
815
|
} |
|
816
|
fossil_print("\nDisplay\n"); |
|
817
|
for(p=pThread->pDisplay; p; p=p->pDisplay){ |
|
818
|
fossil_print("%*s", (p->nIndent-1)*3, ""); |
|
819
|
if( p->pEditTail ){ |
|
820
|
fossil_print("%d->%d", p->fpid, p->pEditTail->fpid); |
|
821
|
}else{ |
|
822
|
fossil_print("%d", p->fpid); |
|
823
|
} |
|
824
|
if( p->iClosed ){ |
|
825
|
fossil_print(" [closed%s]", p->iClosed<0 ? " via parent" : ""); |
|
826
|
} |
|
827
|
fossil_print("\n"); |
|
828
|
} |
|
829
|
forumthread_delete(pThread); |
|
830
|
} |
|
831
|
|
|
832
|
/* |
|
833
|
** WEBPAGE: forumthreadhashlist |
|
834
|
** |
|
835
|
** Usage: /forumthreadhashlist/HASH-OF-ROOT |
|
836
|
** |
|
837
|
** This page (accessibly only to admins) shows a list of all artifacts |
|
838
|
** associated with a single forum thread. An admin might copy/paste this |
|
839
|
** list into the /shun page in order to shun an entire thread. |
|
840
|
*/ |
|
841
|
void forumthreadhashlist(void){ |
|
842
|
int fpid; |
|
843
|
int froot; |
|
844
|
const char *zName = P("name"); |
|
845
|
ForumThread *pThread; |
|
846
|
ForumPost *p; |
|
847
|
char *fuuid; |
|
848
|
Stmt q; |
|
849
|
|
|
850
|
login_check_credentials(); |
|
851
|
if( !g.perm.Admin ){ |
|
852
|
return; |
|
853
|
} |
|
854
|
if( zName==0 ){ |
|
855
|
webpage_error("Missing \"name=\" query parameter"); |
|
856
|
} |
|
857
|
fpid = symbolic_name_to_rid(zName, "f"); |
|
858
|
if( fpid<=0 ){ |
|
859
|
if( fpid==0 ){ |
|
860
|
webpage_notfound_error("Unknown forum id: \"%s\"", zName); |
|
861
|
}else{ |
|
862
|
ambiguous_page(); |
|
863
|
} |
|
864
|
return; |
|
865
|
} |
|
866
|
froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid); |
|
867
|
if( froot==0 ){ |
|
868
|
webpage_notfound_error("Not a forum post: \"%s\"", zName); |
|
869
|
} |
|
870
|
fuuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", froot); |
|
871
|
style_set_current_feature("forum"); |
|
872
|
style_header("Artifacts Of Forum Thread"); |
|
873
|
@ <h2> |
|
874
|
@ Artifacts associated with the forum thread |
|
875
|
@ <a href="%R/forumthread/%S(fuuid)">%S(fuuid)</a>:</h2> |
|
876
|
@ <pre> |
|
877
|
pThread = forumthread_create(froot, 1); |
|
878
|
for(p=pThread->pFirst; p; p=p->pNext){ |
|
879
|
@ %h(p->zUuid) |
|
880
|
} |
|
881
|
forumthread_delete(pThread); |
|
882
|
@ </pre> |
|
883
|
@ <hr> |
|
884
|
@ <h2>Related FORUMPOST Table Content</h2> |
|
885
|
@ <table border="1" cellpadding="4" cellspacing="0"> |
|
886
|
@ <tr><th>fpid<th>froot<th>fprev<th>firt<th>fmtime |
|
887
|
db_prepare(&q, "SELECT fpid, froot, fprev, firt, datetime(fmtime)" |
|
888
|
" FROM forumpost" |
|
889
|
" WHERE froot=%d" |
|
890
|
" ORDER BY fmtime", froot); |
|
891
|
while( db_step(&q)==SQLITE_ROW ){ |
|
892
|
@ <tr><td>%d(db_column_int(&q,0))\ |
|
893
|
@ <td>%d(db_column_int(&q,1))\ |
|
894
|
@ <td>%d(db_column_int(&q,2))\ |
|
895
|
@ <td>%d(db_column_int(&q,3))\ |
|
896
|
@ <td>%h(db_column_text(&q,4))</tr> |
|
897
|
} |
|
898
|
@ </table> |
|
899
|
db_finalize(&q); |
|
900
|
style_finish_page(); |
|
901
|
} |
|
902
|
|
|
903
|
/* |
|
904
|
** Returns true if the current user is authorized to set forum post |
|
905
|
** fpid's status. |
|
906
|
*/ |
|
907
|
static int forum_may_set_status(int fpid){ |
|
908
|
if( moderation_pending(fpid) ) return 0; |
|
909
|
return g.perm.Admin |
|
910
|
|| g.perm.ModForum |
|
911
|
|| (login_is_individual() |
|
912
|
&& forumpost_is_owner(fpid, 0)); |
|
913
|
} |
|
914
|
|
|
915
|
/* |
|
916
|
** If the current user is authorized to set fp's status then this |
|
917
|
** renders a mini-form for setting the status then redirecting back to |
|
918
|
** the post. Else it may emit a status label or no output. |
|
919
|
*/ |
|
920
|
static void forum_render_status_selection( const ForumPost *fp ){ |
|
921
|
const ForumStatusList * const fss = forum_statuses(); |
|
922
|
if( fss->n>1 ){ |
|
923
|
const ForumPost * pHead = fp->pEditHead ? fp->pEditHead : fp; |
|
924
|
int i; |
|
925
|
char * zCurrent = 0; |
|
926
|
const ForumStatus * sCurrent = 0; |
|
927
|
rid_has_tag2(pHead->fpid, "status", &zCurrent); |
|
928
|
for( i = 0; i < fss->n; ++i ){ |
|
929
|
const ForumStatus * const fs = &fss->aStatus[i]; |
|
930
|
if( 0==fossil_strcmp(zCurrent, fs->zValue) ){ |
|
931
|
sCurrent = fs; |
|
932
|
break; |
|
933
|
} |
|
934
|
} |
|
935
|
if( !sCurrent ) sCurrent = &fss->aStatus[0]; |
|
936
|
assert( sCurrent ); |
|
937
|
@ <fieldset class='forum-status-selection'>\ |
|
938
|
@ <legend>Status \ |
|
939
|
@ <span class='help-buttonlet initially-hidden'>\ |
|
940
|
@ Moderators and the post's owner may change \ |
|
941
|
@ the status of this thread unless it is still. \ |
|
942
|
@ pending moderation. See \ |
|
943
|
@ <a href='%R/help/forum-statuses' target='_new'>\ |
|
944
|
@ /help/forum-statuses</a></span>\ |
|
945
|
@ </legend>\ |
|
946
|
if( forum_may_set_status(fp->fpid) ){ |
|
947
|
@ <form method="post" action='%R/forumpost_status'> |
|
948
|
login_insert_csrf_secret(); |
|
949
|
@ <input type='hidden' name='fpid' value='%s(fp->zUuid)' /> |
|
950
|
@ <select name='status' data-fpid='%s(fp->zUuid)'\ |
|
951
|
@ data-initial-value='%h(zCurrent ? zCurrent : "")'> |
|
952
|
for( i = 0; i < fss->n; ++i ){ |
|
953
|
const ForumStatus * const fs = &fss->aStatus[i]; |
|
954
|
@ <option value='%h(fs->zValue)'\ |
|
955
|
@ %s(sCurrent==fs ? " selected" : "")>\ |
|
956
|
@ %h(fs->zLabel)</option> |
|
957
|
} |
|
958
|
@ </select> |
|
959
|
@ <input type='button' class='submit action-status' disabled |
|
960
|
@ value='Change' /> |
|
961
|
/* ^^^ This must be <input>, not <button>, or else tapping it |
|
962
|
** will unconditionally submit. */ |
|
963
|
@ </form> |
|
964
|
/* Form is activated in fossil.page.forumpost.js */ |
|
965
|
}else{ |
|
966
|
@ <button disabled>Status: %h(sCurrent->zLabel)</button> |
|
967
|
} |
|
968
|
@ </fieldset> |
|
969
|
fossil_free(zCurrent); |
|
970
|
} |
|
971
|
} |
|
972
|
|
|
973
|
/* |
|
974
|
** Render a forum post for display |
|
975
|
*/ |
|
976
|
void forum_render( |
|
977
|
const char *zTitle, /* The title. Might be NULL for no title */ |
|
978
|
const char *zMimetype, /* Mimetype of the message */ |
|
979
|
const char *zContent, /* Content of the message */ |
|
980
|
const char *zClass, /* Put in a <div> if not NULL */ |
|
981
|
int bScroll /* Large message content scrolls if true */ |
|
982
|
){ |
|
983
|
if( zClass ){ |
|
984
|
@ <div class='%s(zClass)'> |
|
985
|
} |
|
986
|
if( zTitle ){ |
|
987
|
if( zTitle[0] ){ |
|
988
|
@ <h1>%h(zTitle)</h1> |
|
989
|
}else{ |
|
990
|
@ <h1><i>Deleted</i></h1> |
|
991
|
} |
|
992
|
} |
|
993
|
if( zContent && zContent[0] ){ |
|
994
|
Blob x; |
|
995
|
const int isFossilWiki = zMimetype==0 |
|
996
|
|| fossil_strcmp(zMimetype, "text/x-fossil-wiki")==0; |
|
997
|
if( bScroll ){ |
|
998
|
@ <div class='forumPostBody'> |
|
999
|
}else{ |
|
1000
|
@ <div class='forumPostFullBody'> |
|
1001
|
} |
|
1002
|
blob_init(&x, 0, 0); |
|
1003
|
blob_append(&x, zContent, -1); |
|
1004
|
safe_html_context(DOCSRC_FORUM); |
|
1005
|
if( isFossilWiki ){ |
|
1006
|
/* Markdown and plain-text rendering add a wrapper DIV resp. PRE |
|
1007
|
** element around the post, and some CSS relies on its existence |
|
1008
|
** in order to handle expansion/collapse of the post. Fossil |
|
1009
|
** Wiki rendering does not do so, so we must wrap those manually |
|
1010
|
** here. */ |
|
1011
|
@ <div class='fossilWiki'> |
|
1012
|
} |
|
1013
|
wiki_render_by_mimetype(&x, zMimetype); |
|
1014
|
if( isFossilWiki ){ |
|
1015
|
@ </div> |
|
1016
|
} |
|
1017
|
blob_reset(&x); |
|
1018
|
@ </div> |
|
1019
|
}else{ |
|
1020
|
@ <i>Deleted</i> |
|
1021
|
} |
|
1022
|
if( zClass ){ |
|
1023
|
@ </div> |
|
1024
|
} |
|
1025
|
} |
|
1026
|
|
|
1027
|
/* |
|
1028
|
** Compute a display name from a login name. |
|
1029
|
** |
|
1030
|
** If the input login is found in the USER table, then check the USER.INFO |
|
1031
|
** field to see if it has display-name followed by an email address. |
|
1032
|
** If it does, that becomes the new display name. If not, let the display |
|
1033
|
** name just be the login. |
|
1034
|
** |
|
1035
|
** Space to hold the returned name is obtained from fossil_strdup() or |
|
1036
|
** mprintf() and should be freed by the caller. |
|
1037
|
** |
|
1038
|
** HTML markup within the reply has been property escaped. Hyperlinks |
|
1039
|
** may have been added. The result is safe for use with %s. |
|
1040
|
*/ |
|
1041
|
static char *display_name_from_login(const char *zLogin){ |
|
1042
|
static Stmt q; |
|
1043
|
char *zResult; |
|
1044
|
db_static_prepare(&q, |
|
1045
|
"SELECT display_name(info) FROM user WHERE login=$login" |
|
1046
|
); |
|
1047
|
db_bind_text(&q, "$login", zLogin); |
|
1048
|
if( db_step(&q)==SQLITE_ROW && db_column_type(&q,0)==SQLITE_TEXT ){ |
|
1049
|
const char *zDisplay = db_column_text(&q,0); |
|
1050
|
if( fossil_strcmp(zDisplay,zLogin)==0 ){ |
|
1051
|
zResult = mprintf("%z%h</a>", |
|
1052
|
href("%R/timeline?ss=v&y=f&vfx&u=%t",zLogin),zLogin); |
|
1053
|
}else{ |
|
1054
|
zResult = mprintf("%s (%z%h</a>)", zDisplay, |
|
1055
|
href("%R/timeline?ss=v&y=f&vfx&u=%t",zLogin),zLogin); |
|
1056
|
} |
|
1057
|
}else{ |
|
1058
|
zResult = mprintf("%z%h</a>", |
|
1059
|
href("%R/timeline?ss=v&y=f&vfx&u=%t",zLogin),zLogin); |
|
1060
|
} |
|
1061
|
db_reset(&q); |
|
1062
|
return zResult; |
|
1063
|
} |
|
1064
|
|
|
1065
|
/* |
|
1066
|
** Compute and return the display name for a ForumPost. If |
|
1067
|
** pManifest is not NULL, then it is a Manifest object for the post. |
|
1068
|
** if pManifest is NULL, this routine has to fetch and parse the |
|
1069
|
** Manifest object for itself. |
|
1070
|
** |
|
1071
|
** Memory to hold the display name is attached to p->zDisplayName |
|
1072
|
** and will be freed together with the ForumPost object p when it |
|
1073
|
** is freed. |
|
1074
|
** |
|
1075
|
** The returned text has had all HTML markup escaped and is safe for |
|
1076
|
** use within %s. |
|
1077
|
*/ |
|
1078
|
static char *forum_post_display_name(ForumPost *p, Manifest *pManifest){ |
|
1079
|
Manifest *pToFree = 0; |
|
1080
|
if( p->zDisplayName ) return p->zDisplayName; |
|
1081
|
if( pManifest==0 ){ |
|
1082
|
pManifest = pToFree = manifest_get(p->fpid, CFTYPE_FORUM, 0); |
|
1083
|
if( pManifest==0 ) return "(unknown)"; |
|
1084
|
} |
|
1085
|
p->zDisplayName = display_name_from_login(pManifest->zUser); |
|
1086
|
if( pToFree ) manifest_destroy(pToFree); |
|
1087
|
if( p->zDisplayName==0 ) return "(unknown)"; |
|
1088
|
return p->zDisplayName; |
|
1089
|
} |
|
1090
|
|
|
1091
|
/* |
|
1092
|
** Renders the attachment list for the given forum post. |
|
1093
|
** Emits no output if there are no attachments. |
|
1094
|
*/ |
|
1095
|
static void forum_render_attachment_list(const char *zUuid){ |
|
1096
|
#if 1 |
|
1097
|
attachment_list(zUuid, "📎 Attachments", 0 |
|
1098
|
| ATTACHLIST_SIZE |
|
1099
|
| ATTACHLIST_HIDE_UNAPPROVED |
|
1100
|
| ATTACHLIST_DETAILS_CLOSED |
|
1101
|
| ATTACHLIST_HIDE_EMPTY); |
|
1102
|
#else |
|
1103
|
char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%!S'>" |
|
1104
|
"Attachments</a>:", zUuid); |
|
1105
|
attachment_list(zUuid, zLbl, |
|
1106
|
ATTACHLIST_HRULE_ABOVE |
|
1107
|
| ATTACHLIST_SIZE |
|
1108
|
| ATTACHLIST_HIDE_UNAPPROVED |
|
1109
|
| ATTACHLIST_HIDE_EMPTY); |
|
1110
|
fossil_free(zLbl); |
|
1111
|
#endif |
|
1112
|
} |
|
1113
|
|
|
1114
|
/* |
|
1115
|
** Renders the attachment list for p or (if not NULL) pEditHead. |
|
1116
|
*/ |
|
1117
|
static void forum_render_attachment_list2(ForumPost *p){ |
|
1118
|
if( p->pEditHead ) p = p->pEditHead; |
|
1119
|
forum_render_attachment_list(p->zUuid); |
|
1120
|
} |
|
1121
|
|
|
1122
|
/* Flags for use with forum_display_post() */ |
|
1123
|
#define FDISPLAY_RAW 0x01 /* omit the border */ |
|
1124
|
#define FDISPLAY_UNFORMATTED 0x02 /* leave the post unformatted */ |
|
1125
|
#define FDISPLAY_HISTORY 0x04 /* Showing edit history */ |
|
1126
|
#define FDISPLAY_SELECTED 0x08 /* This is the selected post */ |
|
1127
|
|
|
1128
|
/* |
|
1129
|
** Display a single post in a forum thread. |
|
1130
|
*/ |
|
1131
|
static void forum_display_post( |
|
1132
|
ForumThread *pThread, /* The thread that this post is a member of */ |
|
1133
|
ForumPost *p, /* Forum post to display */ |
|
1134
|
int iIndentScale, /* Indent scale factor */ |
|
1135
|
int flags, /* From the FDISPLAY_... enum */ |
|
1136
|
char *zQuery /* Common query string */ |
|
1137
|
){ |
|
1138
|
char *zPosterName; /* Name of user who originally made this post */ |
|
1139
|
char *zEditorName; /* Name of user who provided the current edit */ |
|
1140
|
char *zDate; /* The time/date string */ |
|
1141
|
char *zDateZulu; /* The date/time string in Zulul time */ |
|
1142
|
char *zHist; /* History query string */ |
|
1143
|
Manifest *pManifest; /* Manifest comprising the current post */ |
|
1144
|
int bPrivate; /* True for posts awaiting moderation */ |
|
1145
|
int bSameUser; /* True if author is also the reader */ |
|
1146
|
int iIndent; /* Indent level */ |
|
1147
|
int iClosed; /* True if (sub)thread is closed */ |
|
1148
|
const int bRaw = flags & FDISPLAY_RAW; |
|
1149
|
const int bUnf = flags & FDISPLAY_UNFORMATTED; |
|
1150
|
const int bHist = flags & FDISPLAY_HISTORY; |
|
1151
|
const int bSelect = flags & FDISPLAY_SELECTED; |
|
1152
|
const char *zMimetype;/* Formatting MIME type */ |
|
1153
|
|
|
1154
|
/* Get the manifest for the post. Abort if not found (e.g. shunned). */ |
|
1155
|
pManifest = manifest_get(p->fpid, CFTYPE_FORUM, 0); |
|
1156
|
if( !pManifest ) return; |
|
1157
|
iClosed = forumpost_is_closed(pThread, p, 1); |
|
1158
|
bPrivate = content_is_private(p->fpid); |
|
1159
|
bSameUser = login_is_individual() |
|
1160
|
&& fossil_strcmp(pManifest->zUser, g.zLogin)==0; |
|
1161
|
/* When not in raw mode, create the border around the post. */ |
|
1162
|
if( !bRaw ){ |
|
1163
|
/* Open the <div> enclosing the post. Set the class string to mark the post |
|
1164
|
** as selected and/or obsolete. */ |
|
1165
|
iIndent = (p->pEditHead ? p->pEditHead->nIndent : p->nIndent)-1; |
|
1166
|
@ <div id='forum%d(p->fpid)' class='forumpost forumTime\ |
|
1167
|
@ %s(bSelect ? " forumSel" : "")\ |
|
1168
|
@ %s(iClosed ? " forumClosed" : "")\ |
|
1169
|
@ %s(p->pEditTail ? " forumObs" : "")' \ |
|
1170
|
if( iIndent && iIndentScale ){ |
|
1171
|
@ style='margin-left:%d(iIndent*iIndentScale)ex;' \ |
|
1172
|
} |
|
1173
|
/* These data-X fields are used by the JS editor. */ |
|
1174
|
if( p->pIrt ){ |
|
1175
|
@ data-firt="%s(p->pIrt->zUuid)" \ |
|
1176
|
} |
|
1177
|
if( p->pEditHead ){ |
|
1178
|
@ data-fedithead="%s(p->pEditHead->zUuid)" \ |
|
1179
|
} |
|
1180
|
@ data-fpid="%s(p->zUuid)">\ |
|
1181
|
|
|
1182
|
/* If this is the first post (or an edit thereof), emit the thread title. */ |
|
1183
|
if( pManifest->zThreadTitle ){ |
|
1184
|
@ <h1>%h(pManifest->zThreadTitle)</h1> |
|
1185
|
} |
|
1186
|
|
|
1187
|
/* Begin emitting the header line. The forum of the title |
|
1188
|
** varies depending on whether: |
|
1189
|
** * The post is unedited |
|
1190
|
** * The post was last edited by the original author |
|
1191
|
** * The post was last edited by a different person |
|
1192
|
*/ |
|
1193
|
if( !p->pEditHead ){ |
|
1194
|
zPosterName = forum_post_display_name(p, pManifest); |
|
1195
|
zEditorName = zPosterName; |
|
1196
|
} |
|
1197
|
zDate = db_text(0, "SELECT datetime(%.17g,toLocal())", p->rDate); |
|
1198
|
zDateZulu = db_text(0, |
|
1199
|
"SELECT strftime('%%Y-%%m-%%dT%%H:%%M:%%SZ',%.17g)", |
|
1200
|
p->rDate); |
|
1201
|
if( p->pEditPrev ){ |
|
1202
|
zPosterName = forum_post_display_name(p->pEditHead, 0); |
|
1203
|
zEditorName = forum_post_display_name(p, pManifest); |
|
1204
|
zHist = bHist ? "" : zQuery[0]==0 ? "?hist" : "&hist"; |
|
1205
|
@ <h3 class='forumPostHdr'>(%d(p->sid)\ |
|
1206
|
@ .%0*d(fossil_num_digits(p->nEdit))(p->rev)) |
|
1207
|
if( fossil_strcmp(zPosterName, zEditorName)==0 ){ |
|
1208
|
@ By %s(zPosterName) on \ |
|
1209
|
style_copy_button(1, 0, 0, 0, zDateZulu, "%h", zDate); |
|
1210
|
@  edited from \ |
|
1211
|
@ %z(href("%R/forumpost/%S%s%s",p->pEditPrev->zUuid,zQuery,zHist))\ |
|
1212
|
@ %d(p->sid).%0*d(fossil_num_digits(p->nEdit))(p->pEditPrev->rev)</a> |
|
1213
|
}else{ |
|
1214
|
@ Originally by %s(zPosterName) \ |
|
1215
|
@ with edits by %s(zEditorName) on \ |
|
1216
|
style_copy_button(1, 0, 0, 0, zDateZulu, "%h", zDate); |
|
1217
|
@  from \ |
|
1218
|
@ %z(href("%R/forumpost/%S%s%s",p->pEditPrev->zUuid,zQuery,zHist))\ |
|
1219
|
@ %d(p->sid).%0*d(fossil_num_digits(p->nEdit))(p->pEditPrev->rev)</a> |
|
1220
|
} |
|
1221
|
}else{ |
|
1222
|
zPosterName = forum_post_display_name(p, pManifest); |
|
1223
|
@ <h3 class='forumPostHdr'>(%d(p->sid)) |
|
1224
|
@ By %s(zPosterName) on \ |
|
1225
|
style_copy_button(1, 0, 0, 0, zDateZulu, "%h", zDate); |
|
1226
|
cgi_append_content(" ", 1); |
|
1227
|
} |
|
1228
|
fossil_free(zDate); |
|
1229
|
fossil_free(zDateZulu); |
|
1230
|
|
|
1231
|
|
|
1232
|
/* If debugging is enabled, link to the artifact page. */ |
|
1233
|
if( g.perm.Debug ){ |
|
1234
|
@ <span class="debug">\ |
|
1235
|
@ <a href="%R/artifact/%h(p->zUuid)">(artifact-%d(p->fpid))</a></span> |
|
1236
|
} |
|
1237
|
|
|
1238
|
/* If this is a reply, refer back to the parent post. */ |
|
1239
|
if( p->pIrt ){ |
|
1240
|
@ in reply to %z(href("%R/forumpost/%S%s",p->pIrt->zUuid,zQuery))\ |
|
1241
|
@ %d(p->pIrt->sid)\ |
|
1242
|
if( p->pIrt->nEdit ){ |
|
1243
|
@ .%0*d(fossil_num_digits(p->pIrt->nEdit))(p->pIrt->rev)\ |
|
1244
|
} |
|
1245
|
@ </a> |
|
1246
|
} |
|
1247
|
|
|
1248
|
/* If this post was later edited, refer forward to the next edit. */ |
|
1249
|
if( p->pEditNext ){ |
|
1250
|
@ updated by %z(href("%R/forumpost/%S%s",p->pEditNext->zUuid,zQuery))\ |
|
1251
|
@ %d(p->pEditNext->sid)\ |
|
1252
|
@ .%0*d(fossil_num_digits(p->nEdit))(p->pEditNext->rev)</a> |
|
1253
|
} |
|
1254
|
|
|
1255
|
/* Provide a link to select the individual post. */ |
|
1256
|
if( !bSelect ){ |
|
1257
|
@ %z(href("%R/forumpost/%!S%s",p->zUuid,zQuery))[link]</a> |
|
1258
|
} |
|
1259
|
|
|
1260
|
/* Provide a link to the raw source code. */ |
|
1261
|
if( !bUnf ){ |
|
1262
|
@ %z(href("%R/forumpost/%!S?raw",p->zUuid))[source]</a> |
|
1263
|
} |
|
1264
|
@ </h3> |
|
1265
|
|
|
1266
|
if( bPrivate && (bSameUser || g.perm.Admin || g.perm.ModForum) ){ |
|
1267
|
moderation_pending_www(p->fpid); |
|
1268
|
} |
|
1269
|
}/*!bRaw*/ |
|
1270
|
|
|
1271
|
/* Check if this post is approved, also if it's by the current user. |
|
1272
|
Render the post if the user is able to see it. */ |
|
1273
|
if( bPrivate && !g.perm.ModForum && !bSameUser ){ |
|
1274
|
@ <p><span class="modpending">Awaiting Moderator Approval</span></p> |
|
1275
|
}else{ |
|
1276
|
if( bRaw || bUnf || p->pEditTail ){ |
|
1277
|
zMimetype = "text/plain"; |
|
1278
|
}else{ |
|
1279
|
zMimetype = pManifest->zMimetype; |
|
1280
|
} |
|
1281
|
forum_render(0, zMimetype, pManifest->zWiki, 0, !bRaw); |
|
1282
|
} |
|
1283
|
|
|
1284
|
/* When not in raw mode, finish creating the border around the post. */ |
|
1285
|
if( !bRaw ){ |
|
1286
|
int bBrBeforeAttach = 0; /* Layout kludge for Attach button */ |
|
1287
|
forum_render_attachment_list2(p); |
|
1288
|
/* If the user is able to write to the forum and if this post has not been |
|
1289
|
** edited, create a form with various interaction buttons. */ |
|
1290
|
if( g.perm.WrForum && !p->pEditTail ){ |
|
1291
|
@ <div class="forumpost-single-controls">\ |
|
1292
|
@ <form action="%R/forumedit" method="POST"> |
|
1293
|
@ <input type="hidden" name="fpid" value="%s(p->zUuid)"> |
|
1294
|
if( !bPrivate ){ |
|
1295
|
/* Reply and Edit are only available if the post has been |
|
1296
|
** approved. Closed threads can only be edited or replied to |
|
1297
|
** if forumpost_may_close() is true but a user may delete |
|
1298
|
** their own posts even if they are closed. */ |
|
1299
|
if( forumpost_may_close() || !iClosed ){ |
|
1300
|
@ <input type="submit" name="reply" value="Reply"> |
|
1301
|
if( g.perm.Admin || (bSameUser && !iClosed) ){ |
|
1302
|
@ <input type="submit" name="edit" value="Edit"> |
|
1303
|
} |
|
1304
|
if( g.perm.Admin || bSameUser ){ |
|
1305
|
@ <input type="submit" name="nullout" value="Delete"> |
|
1306
|
} |
|
1307
|
} |
|
1308
|
}else if( g.perm.ModForum ){ |
|
1309
|
/* Allow moderators to approve or reject pending posts. Also allow |
|
1310
|
** forum supervisors to mark non-special users as trusted and therefore |
|
1311
|
** able to post unmoderated. */ |
|
1312
|
@ <input type="submit" name="approve" value="Approve" \ |
|
1313
|
@ class="action-approve">\ |
|
1314
|
@ <input type="submit" name="reject" value="Reject" \ |
|
1315
|
@ class="action-reject">\ |
|
1316
|
if( g.perm.AdminForum && !login_is_special(pManifest->zUser) ){ |
|
1317
|
@ <br><label><input type="checkbox" name="trust"> |
|
1318
|
@ Trust user "%h(pManifest->zUser)" so that future posts by \ |
|
1319
|
@ "%h(pManifest->zUser)" do not require moderation. |
|
1320
|
@ </label> |
|
1321
|
@ <input type="hidden" name="trustuser" value="%h(pManifest->zUser)"> |
|
1322
|
bBrBeforeAttach = 1 /* slightly unmangle the layout */; |
|
1323
|
} |
|
1324
|
}else if( bSameUser ){ |
|
1325
|
/* Allow users to delete (reject) their own pending posts. */ |
|
1326
|
@ <input type="submit" name="reject" value="Delete"> |
|
1327
|
} |
|
1328
|
login_insert_csrf_secret(); |
|
1329
|
@ </form> |
|
1330
|
|
|
1331
|
if( bSelect ){ |
|
1332
|
const ForumPost *pHead = p->pEditHead ? p->pEditHead : p; |
|
1333
|
if( !bPrivate && forumpost_may_close() && iClosed>=0 ){ |
|
1334
|
@ <form method="post" \ |
|
1335
|
@ action='%R/forumpost_%s(iClosed > 0 ? "reopen" : "close")'> |
|
1336
|
login_insert_csrf_secret(); |
|
1337
|
@ <input type="hidden" name="fpid" value="%s(p->zUuid)" /> |
|
1338
|
if( moderation_pending(p->fpid)==0 ){ |
|
1339
|
@ <input type="button" value='%s(iClosed ? "Re-open" : "Close")' \ |
|
1340
|
@ class='submit hidden \ |
|
1341
|
@ %s(iClosed ? "action-reopen" : "action-close")'/> |
|
1342
|
/* ^^^ activated by fossil.page.forumpost.js */ |
|
1343
|
} |
|
1344
|
@ </form> |
|
1345
|
} |
|
1346
|
if( attach_user_may(p/*not pHead*/->fpid, CFTYPE_FORUM) ){ |
|
1347
|
/* When an admin edits someone else's post, the admin |
|
1348
|
** effectively takes over ownership of it (and we currently |
|
1349
|
** have no way of passing it back). Because of this, we |
|
1350
|
** check the ownership of `p` instead of `pHead`. */ |
|
1351
|
if( bBrBeforeAttach ){ |
|
1352
|
@ <br> |
|
1353
|
} |
|
1354
|
attach_render_attachadd_button(pHead->zUuid); |
|
1355
|
} |
|
1356
|
} |
|
1357
|
@ </div> |
|
1358
|
} |
|
1359
|
if( !p->pIrt && (flags & FDISPLAY_SELECTED)){ |
|
1360
|
forum_render_status_selection(p); |
|
1361
|
} |
|
1362
|
@ </div> |
|
1363
|
}/*!bRaw*/ |
|
1364
|
|
|
1365
|
/* Clean up. */ |
|
1366
|
manifest_destroy(pManifest); |
|
1367
|
} |
|
1368
|
|
|
1369
|
/* |
|
1370
|
** Possible display modes for forum_display_thread(). |
|
1371
|
*/ |
|
1372
|
enum { |
|
1373
|
FD_RAW, /* Like FD_SINGLE, but additionally omit the border, force |
|
1374
|
** unformatted mode, and inhibit history mode */ |
|
1375
|
FD_SINGLE, /* Render a single post and (optionally) its edit history */ |
|
1376
|
FD_CHRONO, /* Render all posts in chronological order */ |
|
1377
|
FD_HIER, /* Render all posts in an indented hierarchy */ |
|
1378
|
}; |
|
1379
|
|
|
1380
|
/* |
|
1381
|
** Display a forum thread. If mode is FD_RAW or FD_SINGLE, display only a |
|
1382
|
** single post from the thread and (optionally) its edit history. |
|
1383
|
*/ |
|
1384
|
static void forum_display_thread( |
|
1385
|
int froot, /* Forum thread root post ID */ |
|
1386
|
int fpid, /* Selected forum post ID, or 0 if none selected */ |
|
1387
|
int mode, /* Forum display mode, one of the FD_* enumerations */ |
|
1388
|
int autoMode, /* mode was selected automatically */ |
|
1389
|
int bUnf, /* True if rendering unformatted */ |
|
1390
|
int bHist /* True if showing edit history, ignored for FD_RAW */ |
|
1391
|
){ |
|
1392
|
ForumThread *pThread; /* Thread structure */ |
|
1393
|
ForumPost *pSelect; /* Currently selected post, or NULL if none */ |
|
1394
|
ForumPost *p; /* Post iterator pointer */ |
|
1395
|
char zQuery[30]; /* Common query string */ |
|
1396
|
int iIndentScale = 4; /* Indent scale factor, measured in "ex" units */ |
|
1397
|
int sid; /* Comparison serial ID */ |
|
1398
|
int i; |
|
1399
|
|
|
1400
|
/* In raw mode, force unformatted display and disable history. */ |
|
1401
|
if( mode == FD_RAW ){ |
|
1402
|
bUnf = 1; |
|
1403
|
bHist = 0; |
|
1404
|
} |
|
1405
|
|
|
1406
|
/* Thread together the posts and (optionally) compute the hierarchy. */ |
|
1407
|
pThread = forumthread_create(froot, mode==FD_HIER); |
|
1408
|
|
|
1409
|
/* Compute the appropriate indent scaling. */ |
|
1410
|
if( mode==FD_HIER ){ |
|
1411
|
iIndentScale = 4; |
|
1412
|
while( iIndentScale>1 && iIndentScale*pThread->mxIndent>25 ){ |
|
1413
|
iIndentScale--; |
|
1414
|
} |
|
1415
|
}else{ |
|
1416
|
iIndentScale = 0; |
|
1417
|
} |
|
1418
|
|
|
1419
|
/* Find the selected post, or (depending on parameters) its latest edit. */ |
|
1420
|
pSelect = fpid ? forumpost_forward(pThread->pFirst, fpid) : 0; |
|
1421
|
if( !bHist && mode!=FD_RAW && pSelect && pSelect->pEditTail ){ |
|
1422
|
pSelect = pSelect->pEditTail; |
|
1423
|
} |
|
1424
|
|
|
1425
|
/* When displaying only a single post, abort if no post was selected or the |
|
1426
|
** selected forum post does not exist in the thread. Otherwise proceed to |
|
1427
|
** display the entire thread without marking any posts as selected. */ |
|
1428
|
if( !pSelect && (mode==FD_RAW || mode==FD_SINGLE) ){ |
|
1429
|
return; |
|
1430
|
} |
|
1431
|
|
|
1432
|
/* Create the common query string to append to nearly all post links. */ |
|
1433
|
i = 0; |
|
1434
|
if( !autoMode ){ |
|
1435
|
char m = 'a'; |
|
1436
|
switch( mode ){ |
|
1437
|
case FD_RAW: m = 'r'; break; |
|
1438
|
case FD_CHRONO: m = 'c'; break; |
|
1439
|
case FD_HIER: m = 'h'; break; |
|
1440
|
case FD_SINGLE: m = 's'; break; |
|
1441
|
} |
|
1442
|
zQuery[i++] = '?'; |
|
1443
|
zQuery[i++] = 't'; |
|
1444
|
zQuery[i++] = '='; |
|
1445
|
zQuery[i++] = m; |
|
1446
|
} |
|
1447
|
if( bUnf ){ |
|
1448
|
zQuery[i] = i==0 ? '?' : '&'; i++; |
|
1449
|
zQuery[i++] = 'u'; |
|
1450
|
zQuery[i++] = 'n'; |
|
1451
|
zQuery[i++] = 'f'; |
|
1452
|
} |
|
1453
|
if( bHist ){ |
|
1454
|
zQuery[i] = i==0 ? '?' : '&'; i++; |
|
1455
|
zQuery[i++] = 'h'; |
|
1456
|
zQuery[i++] = 'i'; |
|
1457
|
zQuery[i++] = 's'; |
|
1458
|
zQuery[i++] = 't'; |
|
1459
|
} |
|
1460
|
assert( i<(int)sizeof(zQuery) ); |
|
1461
|
zQuery[i] = 0; |
|
1462
|
assert( zQuery[0]==0 || zQuery[0]=='?' ); |
|
1463
|
|
|
1464
|
/* Identify which post to display first. If history is shown, start with the |
|
1465
|
** original, unedited post. Otherwise advance to the post's latest edit. */ |
|
1466
|
if( mode==FD_RAW || mode==FD_SINGLE ){ |
|
1467
|
p = pSelect; |
|
1468
|
if( bHist && p->pEditHead ) p = p->pEditHead; |
|
1469
|
}else{ |
|
1470
|
p = mode==FD_CHRONO ? pThread->pFirst : pThread->pDisplay; |
|
1471
|
if( !bHist && p->pEditTail ) p = p->pEditTail; |
|
1472
|
} |
|
1473
|
|
|
1474
|
/* Display the appropriate subset of posts in sequence. */ |
|
1475
|
while( p ){ |
|
1476
|
/* Display the post. */ |
|
1477
|
forum_display_post( |
|
1478
|
pThread, p, iIndentScale, |
|
1479
|
(mode==FD_RAW ? FDISPLAY_RAW : 0) | |
|
1480
|
(bUnf ? FDISPLAY_UNFORMATTED : 0) | |
|
1481
|
(bHist ? FDISPLAY_HISTORY : 0) | |
|
1482
|
(p==pSelect ? FDISPLAY_SELECTED : 0), |
|
1483
|
zQuery |
|
1484
|
); |
|
1485
|
|
|
1486
|
/* Advance to the next post in the thread. */ |
|
1487
|
if( mode==FD_CHRONO ){ |
|
1488
|
/* Chronological mode: display posts (optionally including edits) in their |
|
1489
|
** original commit order. */ |
|
1490
|
if( bHist ){ |
|
1491
|
p = p->pNext; |
|
1492
|
}else{ |
|
1493
|
sid = p->sid; |
|
1494
|
if( p->pEditHead ) p = p->pEditHead; |
|
1495
|
do p = p->pNext; while( p && p->sid<=sid ); |
|
1496
|
if( p && p->pEditTail ) p = p->pEditTail; |
|
1497
|
} |
|
1498
|
}else if( bHist && p->pEditNext ){ |
|
1499
|
/* Hierarchical and single mode: display each post's edits in sequence. */ |
|
1500
|
p = p->pEditNext; |
|
1501
|
}else if( mode==FD_HIER ){ |
|
1502
|
/* Hierarchical mode: after displaying with each post (optionally |
|
1503
|
** including edits), go to the next post in computed display order. */ |
|
1504
|
p = p->pEditHead ? p->pEditHead->pDisplay : p->pDisplay; |
|
1505
|
if( !bHist && p && p->pEditTail ) p = p->pEditTail; |
|
1506
|
}else{ |
|
1507
|
/* Single and raw mode: terminate after displaying the selected post and |
|
1508
|
** (optionally) its edits. */ |
|
1509
|
break; |
|
1510
|
} |
|
1511
|
} |
|
1512
|
|
|
1513
|
/* Undocumented "threadtable" query parameter causes thread table to be |
|
1514
|
** displayed for debugging purposes. */ |
|
1515
|
if( PB("threadtable") ){ |
|
1516
|
@ <hr> |
|
1517
|
@ <table border="1" cellpadding="3" cellspacing="0"> |
|
1518
|
@ <tr><th>sid<th>rev<th>fpid<th>pIrt<th>pEditHead<th>pEditTail\ |
|
1519
|
@ <th>pEditNext<th>pEditPrev<th>pDisplay<th>hash |
|
1520
|
for(p=pThread->pFirst; p; p=p->pNext){ |
|
1521
|
@ <tr><td>%d(p->sid)<td>%d(p->rev)<td>%d(p->fpid)\ |
|
1522
|
@ <td>%d(p->pIrt ? p->pIrt->fpid : 0)\ |
|
1523
|
@ <td>%d(p->pEditHead ? p->pEditHead->fpid : 0)\ |
|
1524
|
@ <td>%d(p->pEditTail ? p->pEditTail->fpid : 0)\ |
|
1525
|
@ <td>%d(p->pEditNext ? p->pEditNext->fpid : 0)\ |
|
1526
|
@ <td>%d(p->pEditPrev ? p->pEditPrev->fpid : 0)\ |
|
1527
|
@ <td>%d(p->pDisplay ? p->pDisplay->fpid : 0)\ |
|
1528
|
@ <td>%S(p->zUuid)</tr> |
|
1529
|
} |
|
1530
|
@ </table> |
|
1531
|
} |
|
1532
|
|
|
1533
|
/* Clean up. */ |
|
1534
|
forumthread_delete(pThread); |
|
1535
|
} |
|
1536
|
|
|
1537
|
/* |
|
1538
|
** Emit Forum Javascript which applies (or optionally can apply) |
|
1539
|
** to all forum-related pages. It does not include page-specific |
|
1540
|
** code (e.g. "forum.js"). |
|
1541
|
*/ |
|
1542
|
static void forum_emit_js(void){ |
|
1543
|
builtin_fossil_js_bundle_or("copybutton", "pikchr", "confirmer", |
|
1544
|
"attach", "tabs", "storage", |
|
1545
|
"popupwidget", NULL); |
|
1546
|
builtin_request_js("fossil.page.forumpost.js"); |
|
1547
|
} |
|
1548
|
|
|
1549
|
/* |
|
1550
|
** WEBPAGE: forumpost |
|
1551
|
** |
|
1552
|
** Show a single forum posting. The posting is shown in context with |
|
1553
|
** its entire thread. The selected posting is enclosed within |
|
1554
|
** <div class='forumSel'>...</div>. Javascript is used to move the |
|
1555
|
** selected posting into view after the page loads. |
|
1556
|
** |
|
1557
|
** Query parameters: |
|
1558
|
** |
|
1559
|
** name=NAME Required. The NAME value is a hash prefix or timestamp |
|
1560
|
** for the post to be displayed. |
|
1561
|
** t=a Automatic display mode, i.e. hierarchical for |
|
1562
|
** desktop and chronological for mobile. This is the |
|
1563
|
** default if the "t" query parameter is omitted. |
|
1564
|
** t=c Show posts in the order they were written. |
|
1565
|
** t=h Show posts using hierarchical indenting. |
|
1566
|
** t=s Show only the post specified by "name=X". |
|
1567
|
** t=r Alias for "t=c" with "unf" and "hist". |
|
1568
|
** t=y Alias for "t=s" with "unf" and "hist". |
|
1569
|
** raw Alias for "t=s" with "unf". Additionally, omit |
|
1570
|
** the border around the post, and ignore "t" and "hist". |
|
1571
|
** unf Show the original, unformatted source text. |
|
1572
|
** hist Show edit history in addition to current posts. |
|
1573
|
*/ |
|
1574
|
void forumpost_page(void){ |
|
1575
|
forumthread_page(); |
|
1576
|
} |
|
1577
|
|
|
1578
|
/* |
|
1579
|
** WEBPAGE: forumthread |
|
1580
|
** |
|
1581
|
** Show all forum messages associated with a particular message thread. |
|
1582
|
** The result is basically the same as /forumpost except that none of |
|
1583
|
** the postings in the thread are selected. |
|
1584
|
** |
|
1585
|
** Query parameters: |
|
1586
|
** |
|
1587
|
** name=NAME Required. The NAME value is a hash prefix or timestamp |
|
1588
|
** for one of the posts in the thread. |
|
1589
|
** t=a Automatic display mode, i.e. hierarchical for |
|
1590
|
** desktop and chronological for mobile. This is the |
|
1591
|
** default if the "t" query parameter is omitted. |
|
1592
|
** t=c Show posts in the order they were written. |
|
1593
|
** t=h Show posts using hierarchical indenting. |
|
1594
|
** unf Show the original, unformatted source text. |
|
1595
|
** hist Show edit history in addition to current posts. |
|
1596
|
*/ |
|
1597
|
void forumthread_page(void){ |
|
1598
|
int fpid; |
|
1599
|
int froot; |
|
1600
|
char *zThreadTitle; |
|
1601
|
const char *zName = P("name"); |
|
1602
|
const char *zMode = PD("t","a"); |
|
1603
|
int bRaw = PB("raw"); |
|
1604
|
int bUnf = PB("unf"); |
|
1605
|
int bHist = PB("hist"); |
|
1606
|
int mode = 0; |
|
1607
|
int autoMode = 0; |
|
1608
|
login_check_credentials(); |
|
1609
|
if( !g.perm.RdForum ){ |
|
1610
|
login_needed(g.anon.RdForum); |
|
1611
|
return; |
|
1612
|
} |
|
1613
|
if( zName==0 ){ |
|
1614
|
webpage_error("Missing \"name=\" query parameter"); |
|
1615
|
return; |
|
1616
|
} |
|
1617
|
cgi_check_for_malice(); |
|
1618
|
fpid = symbolic_name_to_rid(zName, "f"); |
|
1619
|
if( fpid<=0 ){ |
|
1620
|
if( fpid==0 ){ |
|
1621
|
webpage_notfound_error("Unknown forum id: \"%s\"", zName); |
|
1622
|
}else{ |
|
1623
|
ambiguous_page(); |
|
1624
|
} |
|
1625
|
return; |
|
1626
|
} |
|
1627
|
froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid); |
|
1628
|
if( froot==0 ){ |
|
1629
|
webpage_notfound_error("Not a forum post: \"%s\"", zName); |
|
1630
|
return; |
|
1631
|
} |
|
1632
|
|
|
1633
|
/* Decode the mode parameters. */ |
|
1634
|
if( bRaw ){ |
|
1635
|
mode = FD_RAW; |
|
1636
|
bUnf = 1; |
|
1637
|
bHist = 0; |
|
1638
|
cgi_replace_query_parameter("unf", "on"); |
|
1639
|
cgi_delete_query_parameter("hist"); |
|
1640
|
cgi_delete_query_parameter("raw"); |
|
1641
|
}else{ |
|
1642
|
switch( *zMode ){ |
|
1643
|
case 'a': mode = cgi_from_mobile() ? FD_CHRONO : FD_HIER; |
|
1644
|
autoMode=1; break; |
|
1645
|
case 'c': mode = FD_CHRONO; break; |
|
1646
|
case 'h': mode = FD_HIER; break; |
|
1647
|
case 's': mode = FD_SINGLE; break; |
|
1648
|
case 'r': mode = FD_CHRONO; break; |
|
1649
|
case 'y': mode = FD_SINGLE; break; |
|
1650
|
default: webpage_error("Invalid thread mode: \"%s\"", zMode); |
|
1651
|
} |
|
1652
|
if( *zMode=='r' || *zMode=='y') { |
|
1653
|
bUnf = 1; |
|
1654
|
bHist = 1; |
|
1655
|
cgi_replace_query_parameter("t", mode==FD_CHRONO ? "c" : "s"); |
|
1656
|
cgi_replace_query_parameter("unf", "on"); |
|
1657
|
cgi_replace_query_parameter("hist", "on"); |
|
1658
|
} |
|
1659
|
} |
|
1660
|
|
|
1661
|
/* Define the page header. */ |
|
1662
|
zThreadTitle = db_text("", |
|
1663
|
"SELECT" |
|
1664
|
" substr(event.comment,instr(event.comment,':')+2)" |
|
1665
|
" FROM forumpost, event" |
|
1666
|
" WHERE event.objid=forumpost.fpid" |
|
1667
|
" AND forumpost.fpid=%d;", |
|
1668
|
fpid |
|
1669
|
); |
|
1670
|
style_set_current_feature("forum"); |
|
1671
|
style_header("%s%s", zThreadTitle, *zThreadTitle ? "" : "Forum"); |
|
1672
|
fossil_free(zThreadTitle); |
|
1673
|
if( mode!=FD_CHRONO ){ |
|
1674
|
style_submenu_element("Chronological", "%R/%s/%s?t=c%s%s", g.zPath, zName, |
|
1675
|
bUnf ? "&unf" : "", bHist ? "&hist" : ""); |
|
1676
|
} |
|
1677
|
if( mode!=FD_HIER ){ |
|
1678
|
style_submenu_element("Hierarchical", "%R/%s/%s?t=h%s%s", g.zPath, zName, |
|
1679
|
bUnf ? "&unf" : "", bHist ? "&hist" : ""); |
|
1680
|
} |
|
1681
|
style_submenu_checkbox("unf", "Unformatted", 0, 0); |
|
1682
|
style_submenu_checkbox("hist", "History", 0, 0); |
|
1683
|
if( g.perm.Admin ){ |
|
1684
|
style_submenu_element("Artifacts", "%R/forumthreadhashlist/%t", zName); |
|
1685
|
} |
|
1686
|
|
|
1687
|
/* Display the thread. */ |
|
1688
|
if( fossil_strcmp(g.zPath,"forumthread")==0 ) fpid = 0; |
|
1689
|
forum_display_thread(froot, fpid, mode, autoMode, bUnf, bHist); |
|
1690
|
|
|
1691
|
/* Emit Forum Javascript. */ |
|
1692
|
builtin_request_js("forum.js"); |
|
1693
|
forum_emit_js(); |
|
1694
|
|
|
1695
|
/* Emit the page style. */ |
|
1696
|
style_finish_page(); |
|
1697
|
} |
|
1698
|
|
|
1699
|
/* |
|
1700
|
** Return true if a forum post should be moderated. |
|
1701
|
*/ |
|
1702
|
int forum_need_moderation(void){ |
|
1703
|
if( P("domod") ) return 1; |
|
1704
|
if( g.perm.WrTForum ) return 0; |
|
1705
|
if( g.perm.ModForum ) return 0; |
|
1706
|
return 1; |
|
1707
|
} |
|
1708
|
|
|
1709
|
/* Flags for use with forum_post() */ |
|
1710
|
#define FPOST_NO_ALERT 1 /* do not send any alerts */ |
|
1711
|
#define FPOST_DRYRUN 2 /* do not save the artifact */ |
|
1712
|
|
|
1713
|
/* |
|
1714
|
** Return a flags value for use with the final argument to |
|
1715
|
** forum_post(), extracted from the CGI environment. |
|
1716
|
*/ |
|
1717
|
static int forum_post_flags(void){ |
|
1718
|
int iPostFlags = 0; |
|
1719
|
if( g.perm.Debug && P("fpsilent")!=0 ){ |
|
1720
|
iPostFlags |= FPOST_NO_ALERT; |
|
1721
|
} |
|
1722
|
if( P("dryrun")!=0 ){ |
|
1723
|
iPostFlags |= FPOST_DRYRUN; |
|
1724
|
} |
|
1725
|
return iPostFlags; |
|
1726
|
} |
|
1727
|
|
|
1728
|
/* |
|
1729
|
** Add a new Forum Post artifact to the repository. |
|
1730
|
** |
|
1731
|
** Return true if a redirect occurs. |
|
1732
|
*/ |
|
1733
|
static int forum_post( |
|
1734
|
const char *zTitle, /* Title. NULL for replies */ |
|
1735
|
int iInReplyTo, /* Post replying to. 0 for new threads */ |
|
1736
|
int iEdit, /* Post being edited, or zero for a new post */ |
|
1737
|
const char *zUser, /* Username. NULL means use login name */ |
|
1738
|
const char *zMimetype, /* Mimetype of content. */ |
|
1739
|
const char *zContent, /* Content */ |
|
1740
|
int iFlags /* FPOST_xyz flag values */ |
|
1741
|
){ |
|
1742
|
char *zDate; |
|
1743
|
char *zI; |
|
1744
|
char *zG; |
|
1745
|
int iBasis; |
|
1746
|
Blob x, cksum, formatCheck, errMsg; |
|
1747
|
Manifest *pPost; |
|
1748
|
int nContent = zContent ? (int)strlen(zContent) : 0; |
|
1749
|
|
|
1750
|
schema_forum(); |
|
1751
|
if( !g.perm.Admin && (iEdit || iInReplyTo) |
|
1752
|
&& forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){ |
|
1753
|
forumpost_error_closed(); |
|
1754
|
return 0; |
|
1755
|
} |
|
1756
|
if( iEdit==0 && fossil_all_whitespace(zContent) ){ |
|
1757
|
return 0; |
|
1758
|
} |
|
1759
|
if( iInReplyTo==0 && iEdit>0 ){ |
|
1760
|
iBasis = iEdit; |
|
1761
|
iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d", iEdit); |
|
1762
|
}else{ |
|
1763
|
iBasis = iInReplyTo; |
|
1764
|
} |
|
1765
|
webpage_assert( (zTitle==0)+(iInReplyTo==0)==1 ); |
|
1766
|
blob_init(&x, 0, 0); |
|
1767
|
zDate = date_in_standard_format("now"); |
|
1768
|
blob_appendf(&x, "D %s\n", zDate); |
|
1769
|
fossil_free(zDate); |
|
1770
|
zG = db_text(0, |
|
1771
|
"SELECT uuid FROM blob, forumpost" |
|
1772
|
" WHERE blob.rid==forumpost.froot" |
|
1773
|
" AND forumpost.fpid=%d", iBasis); |
|
1774
|
if( zG ){ |
|
1775
|
blob_appendf(&x, "G %s\n", zG); |
|
1776
|
fossil_free(zG); |
|
1777
|
} |
|
1778
|
if( zTitle ){ |
|
1779
|
blob_appendf(&x, "H %F\n", zTitle); |
|
1780
|
} |
|
1781
|
zI = rid_to_uuid(iInReplyTo); |
|
1782
|
if( zI ){ |
|
1783
|
blob_appendf(&x, "I %s\n", zI); |
|
1784
|
fossil_free(zI); |
|
1785
|
} |
|
1786
|
if( zMimetype!=0 |
|
1787
|
&& zMimetype[0]!=0 |
|
1788
|
&& fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){ |
|
1789
|
blob_appendf(&x, "N %F\n", zMimetype); |
|
1790
|
} |
|
1791
|
if( iEdit>0 ){ |
|
1792
|
char *zP = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", iEdit); |
|
1793
|
if( zP==0 ) webpage_error("missing edit artifact %d", iEdit); |
|
1794
|
blob_appendf(&x, "P %s\n", zP); |
|
1795
|
fossil_free(zP); |
|
1796
|
} |
|
1797
|
if( zUser==0 ){ |
|
1798
|
if( login_is_nobody() ){ |
|
1799
|
zUser = "anonymous"; |
|
1800
|
}else{ |
|
1801
|
zUser = login_name(); |
|
1802
|
} |
|
1803
|
} |
|
1804
|
blob_appendf(&x, "U %F\n", zUser); |
|
1805
|
blob_appendf(&x, "W %d\n%s\n", nContent, zContent); |
|
1806
|
md5sum_blob(&x, &cksum); |
|
1807
|
blob_appendf(&x, "Z %b\n", &cksum); |
|
1808
|
blob_reset(&cksum); |
|
1809
|
|
|
1810
|
/* Verify that the artifact we are creating is well-formed */ |
|
1811
|
blob_init(&formatCheck, 0, 0); |
|
1812
|
blob_init(&errMsg, 0, 0); |
|
1813
|
blob_copy(&formatCheck, &x); |
|
1814
|
pPost = manifest_parse(&formatCheck, 0, &errMsg); |
|
1815
|
if( pPost==0 ){ |
|
1816
|
webpage_error("malformed forum post artifact - %s", blob_str(&errMsg)); |
|
1817
|
} |
|
1818
|
webpage_assert( pPost->type==CFTYPE_FORUM ); |
|
1819
|
manifest_destroy(pPost); |
|
1820
|
|
|
1821
|
if( (iFlags & FPOST_DRYRUN)!=0 ){ |
|
1822
|
@ <div class='debug'> |
|
1823
|
@ This is the artifact that would have been generated: |
|
1824
|
@ <pre>%h(blob_str(&x))</pre> |
|
1825
|
@ </div> |
|
1826
|
blob_reset(&x); |
|
1827
|
return 0; |
|
1828
|
}else{ |
|
1829
|
int nrid; |
|
1830
|
db_begin_transaction(); |
|
1831
|
nrid = wiki_put(&x, iEdit>0 ? iEdit : 0, forum_need_moderation()); |
|
1832
|
blob_reset(&x); |
|
1833
|
if( (iFlags & FPOST_NO_ALERT)!=0 ){ |
|
1834
|
alert_unqueue('f', nrid); |
|
1835
|
} |
|
1836
|
db_commit_transaction(); |
|
1837
|
cgi_redirectf("%R/forumpost/%S", rid_to_uuid(nrid)); |
|
1838
|
return 1; |
|
1839
|
} |
|
1840
|
} |
|
1841
|
|
|
1842
|
/* |
|
1843
|
** Paint the form elements for entering a Forum post |
|
1844
|
*/ |
|
1845
|
static void forum_post_widget( |
|
1846
|
const char *zTitle, |
|
1847
|
const char *zMimetype, |
|
1848
|
const char *zContent |
|
1849
|
){ |
|
1850
|
if( zTitle ){ |
|
1851
|
@ Title: <input type="input" name="title" value="%h(zTitle)" size="50" |
|
1852
|
@ maxlength="125"><br> |
|
1853
|
} |
|
1854
|
@ %z(href("%R/markup_help"))Markup style</a>: |
|
1855
|
mimetype_option_menu(zMimetype, "mimetype"); |
|
1856
|
@ <div class="forum-editor-widget"> |
|
1857
|
@ <textarea aria-label="Content:" name="content" class="wikiedit" \ |
|
1858
|
@ cols="80" rows="25" wrap="virtual">%h(zContent)</textarea></div> |
|
1859
|
} |
|
1860
|
|
|
1861
|
/* |
|
1862
|
** If PD("fpid") refers to a forum post, its rid is returned, else |
|
1863
|
** this function emits an error does not does return. |
|
1864
|
*/ |
|
1865
|
static int forum_validate_fpid_param(void){ |
|
1866
|
const char *zFpid = PD("fpid",""); |
|
1867
|
int fpid = symbolic_name_to_rid(zFpid, "f"); |
|
1868
|
if( fpid<=0 ){ |
|
1869
|
webpage_error("Missing or invalid fpid parameter."); |
|
1870
|
} |
|
1871
|
return fpid; |
|
1872
|
} |
|
1873
|
|
|
1874
|
/* |
|
1875
|
** Internal helper for /forumpost_XYZ internal pages which tag/untag |
|
1876
|
** posts. |
|
1877
|
*/ |
|
1878
|
static void forumpost_action_helper(const char *zTag, const char *zVal, |
|
1879
|
int addTag, int validFpid){ |
|
1880
|
if( !cgi_csrf_safe(2) ){ |
|
1881
|
webpage_error("CSRF validation failed"); |
|
1882
|
}else{ |
|
1883
|
const int fpid = validFpid>0 ? validFpid : forum_validate_fpid_param(); |
|
1884
|
if( fpid>0 ){ |
|
1885
|
if( forumpost_tag(fpid, addTag, zTag, zVal) < 0 ){ |
|
1886
|
webpage_error("Tagging artifact failed: %s", g.zErrMsg); |
|
1887
|
}else{ |
|
1888
|
cgi_redirectf("%R/forumpost/%S",P("fpid")); |
|
1889
|
} |
|
1890
|
} |
|
1891
|
} |
|
1892
|
} |
|
1893
|
|
|
1894
|
/* |
|
1895
|
** WEBPAGE: forumpost_close hidden |
|
1896
|
** WEBPAGE: forumpost_reopen hidden |
|
1897
|
** |
|
1898
|
** fpid=X Hash of the post to be edited. REQUIRED |
|
1899
|
** |
|
1900
|
** Closes or re-opens the given forum post, within the bounds of the |
|
1901
|
** API for forumpost_tag(). After (perhaps) modifying the "closed" |
|
1902
|
** status of the given thread, it redirects to that post's thread |
|
1903
|
** view. Requires admin privileges. |
|
1904
|
*/ |
|
1905
|
void forum_page_close(void){ |
|
1906
|
login_check_credentials(); |
|
1907
|
if( forumpost_may_close()==0 ){ |
|
1908
|
login_needed(g.anon.Admin); |
|
1909
|
}else{ |
|
1910
|
const int bIsAdd = sqlite3_strglob("*_close*", g.zPath)==0; |
|
1911
|
forumpost_action_helper("closed", 0, bIsAdd, 0); |
|
1912
|
} |
|
1913
|
} |
|
1914
|
|
|
1915
|
/* |
|
1916
|
** WEBPAGE: forumpost_status hidden |
|
1917
|
** |
|
1918
|
** fpid=X Hash of the post to be edited. REQUIRED |
|
1919
|
** status=Y New status value. REQUIRED |
|
1920
|
** |
|
1921
|
** Updates the current status=Y tag on the first version of |
|
1922
|
** the forum post X. Requires forum_may_set_status() permissions. |
|
1923
|
*/ |
|
1924
|
void forum_page_status(void){ |
|
1925
|
int fpid; |
|
1926
|
login_check_credentials(); |
|
1927
|
fpid = forum_validate_fpid_param(); |
|
1928
|
if(forum_may_set_status(fpid)){ |
|
1929
|
const char *zStatus = PD("status",0); |
|
1930
|
if( !zStatus || !zStatus[0] ){ |
|
1931
|
webpage_error("Missing required status."); |
|
1932
|
} |
|
1933
|
forumpost_action_helper("status", zStatus, 1, fpid); |
|
1934
|
}else{ |
|
1935
|
webpage_error("You lack permissions to change this post's status."); |
|
1936
|
} |
|
1937
|
} |
|
1938
|
|
|
1939
|
/* |
|
1940
|
** WEBPAGE: forumnew |
|
1941
|
** WEBPAGE: forumedit |
|
1942
|
** |
|
1943
|
** Start a new thread on the forum or reply to an existing thread. |
|
1944
|
** But first prompt to see if the user would like to log in. |
|
1945
|
*/ |
|
1946
|
void forum_page_init(void){ |
|
1947
|
int isEdit; |
|
1948
|
char *zGoto; |
|
1949
|
|
|
1950
|
login_check_credentials(); |
|
1951
|
if( !g.perm.WrForum ){ |
|
1952
|
login_needed(g.anon.WrForum); |
|
1953
|
return; |
|
1954
|
} |
|
1955
|
if( sqlite3_strglob("*edit*", g.zPath)==0 ){ |
|
1956
|
zGoto = mprintf("forume2?fpid=%S",PD("fpid","")); |
|
1957
|
isEdit = 1; |
|
1958
|
}else{ |
|
1959
|
zGoto = mprintf("forume1"); |
|
1960
|
isEdit = 0; |
|
1961
|
} |
|
1962
|
if( login_is_individual() ){ |
|
1963
|
if( isEdit ){ |
|
1964
|
forumedit_page(); |
|
1965
|
}else{ |
|
1966
|
forumnew_page(); |
|
1967
|
} |
|
1968
|
return; |
|
1969
|
} |
|
1970
|
style_set_current_feature("forum"); |
|
1971
|
style_header("%h As Anonymous?", isEdit ? "Reply" : "Post"); |
|
1972
|
@ <p>You are not logged in. |
|
1973
|
@ <p><table border="0" cellpadding="10"> |
|
1974
|
@ <tr><td> |
|
1975
|
@ <form action="%s(zGoto)" method="POST"> |
|
1976
|
@ <input type="submit" value="Remain Anonymous"> |
|
1977
|
@ </form> |
|
1978
|
@ <td>Post to the forum anonymously |
|
1979
|
if( login_self_register_available(0) ){ |
|
1980
|
@ <tr><td> |
|
1981
|
@ <form action="%R/register" method="POST"> |
|
1982
|
@ <input type="hidden" name="g" value="%s(zGoto)"> |
|
1983
|
@ <input type="submit" value="Create An Account"> |
|
1984
|
@ </form> |
|
1985
|
@ <td>Create a new account and post using that new account |
|
1986
|
} |
|
1987
|
@ <tr><td> |
|
1988
|
@ <form action="%R/login" method="POST"> |
|
1989
|
@ <input type="hidden" name="g" value="%s(zGoto)"> |
|
1990
|
@ <input type="hidden" name="noanon" value="1"> |
|
1991
|
@ <input type="submit" value="Login"> |
|
1992
|
@ </form> |
|
1993
|
@ <td>Log into an existing account |
|
1994
|
@ </table> |
|
1995
|
forum_emit_js(); |
|
1996
|
style_finish_page(); |
|
1997
|
fossil_free(zGoto); |
|
1998
|
} |
|
1999
|
|
|
2000
|
/* |
|
2001
|
** Write the "From: USER" line on the webpage. |
|
2002
|
*/ |
|
2003
|
static void forum_from_line(void){ |
|
2004
|
if( login_is_nobody() ){ |
|
2005
|
@ From: anonymous<br> |
|
2006
|
}else{ |
|
2007
|
@ From: %h(login_name())<br> |
|
2008
|
} |
|
2009
|
} |
|
2010
|
|
|
2011
|
static void forum_render_debug_options(void){ |
|
2012
|
if( g.perm.Debug ){ |
|
2013
|
/* Give extra control over the post to users with the special |
|
2014
|
* Debug capability, which includes Admin and Setup users */ |
|
2015
|
@ <div class="debug"> |
|
2016
|
@ <label><input type="checkbox" name="dryrun" %s(PCK("dryrun"))> \ |
|
2017
|
@ Dry run</label> |
|
2018
|
@ <br><label><input type="checkbox" name="domod" %s(PCK("domod"))> \ |
|
2019
|
@ Require moderator approval</label> |
|
2020
|
@ <br><label><input type="checkbox" name="showqp" %s(PCK("showqp"))> \ |
|
2021
|
@ Show query parameters</label> |
|
2022
|
@ <br><label><input type="checkbox" name="fpsilent" %s(PCK("fpsilent"))> \ |
|
2023
|
@ Do not send notification emails</label> |
|
2024
|
@ </div> |
|
2025
|
} |
|
2026
|
} |
|
2027
|
|
|
2028
|
/* |
|
2029
|
** If the user has AttachForum permissions, emit a notice that |
|
2030
|
** attachments may be added after saving. If p is not NULL, also emit |
|
2031
|
** its list of attachments. We only emit this for No-JS environments, |
|
2032
|
** as in JS environments the interactive forum editor includes |
|
2033
|
** attachment support. |
|
2034
|
*/ |
|
2035
|
static void forum_render_attachment_notice(void){ |
|
2036
|
if( g.perm.AttachForum ){ |
|
2037
|
@ <noscript><div>You will be able to attach files to this post |
|
2038
|
@ after saving it.</div></noscript> |
|
2039
|
} |
|
2040
|
} |
|
2041
|
|
|
2042
|
/* |
|
2043
|
** WEBPAGE: forume1 hidden |
|
2044
|
** |
|
2045
|
** Start a new forum thread. |
|
2046
|
*/ |
|
2047
|
void forumnew_page(void){ |
|
2048
|
const char *zTitle = PDT("title",""); |
|
2049
|
const char *zMimetype = PD("mimetype",DEFAULT_FORUM_MIMETYPE); |
|
2050
|
const char *zContent = PDT("content",""); |
|
2051
|
|
|
2052
|
login_check_credentials(); |
|
2053
|
if( !g.perm.WrForum ){ |
|
2054
|
login_needed(g.anon.WrForum); |
|
2055
|
return; |
|
2056
|
} |
|
2057
|
if( P("submit") && cgi_csrf_safe(2) ){ |
|
2058
|
if( forum_post(zTitle, 0, 0, 0, zMimetype, zContent, |
|
2059
|
forum_post_flags()) ) return; |
|
2060
|
} |
|
2061
|
if( P("preview") && !fossil_all_whitespace(zContent) ){ |
|
2062
|
@ <h1>Preview:</h1> |
|
2063
|
forum_render(zTitle, zMimetype, zContent, "forumEdit", 1); |
|
2064
|
} |
|
2065
|
style_set_current_feature("forum"); |
|
2066
|
style_header("New Forum Thread"); |
|
2067
|
|
|
2068
|
@ <form action="%R/forume1" method="POST" \ |
|
2069
|
@ class="remove-if-replaced"> |
|
2070
|
@ <h1>New Thread:</h1> |
|
2071
|
forum_from_line(); |
|
2072
|
forum_post_widget(zTitle, zMimetype, zContent); |
|
2073
|
@ <input type="submit" name="preview" value="Preview"> |
|
2074
|
if( P("preview") && !fossil_all_whitespace(zContent) ){ |
|
2075
|
@ <input type="submit" name="submit" value="Submit"> |
|
2076
|
}else{ |
|
2077
|
@ <input type="submit" name="submit" value="Submit" disabled> |
|
2078
|
} |
|
2079
|
forum_render_debug_options(); |
|
2080
|
login_insert_csrf_secret(); |
|
2081
|
@ </form> |
|
2082
|
/* When JS is disabled the block above will work. When it's |
|
2083
|
enabled, the above will be removed and JS will render the editor |
|
2084
|
form in the next element. */ |
|
2085
|
@ <div hidden id='forumnew-placeholder'> |
|
2086
|
@ <input type='hidden' name='title' value='%h(zTitle)'> |
|
2087
|
login_insert_csrf_secret(); |
|
2088
|
@ </div> |
|
2089
|
forum_render_attachment_notice(); |
|
2090
|
forum_emit_js(); |
|
2091
|
style_finish_page(); |
|
2092
|
} |
|
2093
|
|
|
2094
|
/* |
|
2095
|
** WEBPAGE: forume2 hidden |
|
2096
|
** |
|
2097
|
** Edit an existing forum message. |
|
2098
|
** Query parameters: |
|
2099
|
** |
|
2100
|
** fpid=X Hash of the post to be edited. REQUIRED |
|
2101
|
*/ |
|
2102
|
void forumedit_page(void){ |
|
2103
|
int fpid; |
|
2104
|
int froot; |
|
2105
|
Manifest *pPost = 0; |
|
2106
|
Manifest *pRootPost = 0; |
|
2107
|
const char *zMimetype = 0; |
|
2108
|
const char *zContent = 0; |
|
2109
|
const char *zTitle = 0; |
|
2110
|
char *zDate = 0; |
|
2111
|
const char *zFpid = PD("fpid",""); |
|
2112
|
int isCsrfSafe; |
|
2113
|
int isDelete = 0; |
|
2114
|
int iClosed = 0; |
|
2115
|
int bSameUser; /* True if author is also the reader */ |
|
2116
|
int bPreview; /* True in preview mode. */ |
|
2117
|
int bPrivate; /* True if post is private (not yet moderated) */ |
|
2118
|
int bReply; /* True if replying to a post */ |
|
2119
|
|
|
2120
|
login_check_credentials(); |
|
2121
|
if( !g.perm.WrForum ){ |
|
2122
|
login_needed(g.anon.WrForum); |
|
2123
|
return; |
|
2124
|
} |
|
2125
|
fpid = symbolic_name_to_rid(zFpid, "f"); |
|
2126
|
if( fpid<=0 || (pPost = manifest_get(fpid, CFTYPE_FORUM, 0))==0 ){ |
|
2127
|
webpage_error("Missing or invalid fpid query parameter"); |
|
2128
|
} |
|
2129
|
froot = db_int(0, "SELECT froot FROM forumpost WHERE fpid=%d", fpid); |
|
2130
|
if( (froot==0 || (pRootPost = manifest_get(froot, CFTYPE_FORUM, 0))==0) |
|
2131
|
&& P("reject")==0 |
|
2132
|
){ |
|
2133
|
webpage_error("fpid does not appear to be a forum post: \"%d\"", fpid); |
|
2134
|
} |
|
2135
|
if( P("cancel") ){ |
|
2136
|
cgi_redirectf("%R/forumpost/%S",zFpid); |
|
2137
|
return; |
|
2138
|
} |
|
2139
|
bPreview = P("preview")!=0; |
|
2140
|
bReply = P("reply")!=0; |
|
2141
|
iClosed = forum_rid_is_tagged(fpid, "closed", 1); |
|
2142
|
isCsrfSafe = cgi_csrf_safe(2); |
|
2143
|
bPrivate = content_is_private(fpid); |
|
2144
|
bSameUser = login_is_individual() |
|
2145
|
&& fossil_strcmp(pPost->zUser, g.zLogin)==0; |
|
2146
|
if( isCsrfSafe && (g.perm.ModForum || (bPrivate && bSameUser)) ){ |
|
2147
|
if( g.perm.ModForum && P("approve") ){ |
|
2148
|
const char *zUserToTrust; |
|
2149
|
moderation_forumpost_approve(fpid); |
|
2150
|
if( g.perm.AdminForum |
|
2151
|
&& PB("trust") |
|
2152
|
&& (zUserToTrust = P("trustuser"))!=0 |
|
2153
|
){ |
|
2154
|
db_unprotect(PROTECT_USER); |
|
2155
|
db_multi_exec("UPDATE user SET cap=cap||'4' " |
|
2156
|
"WHERE login=%Q AND cap NOT GLOB '*4*'", |
|
2157
|
zUserToTrust); |
|
2158
|
db_protect_pop(); |
|
2159
|
} |
|
2160
|
cgi_redirectf("%R/forumpost/%S",P("fpid")); |
|
2161
|
return; |
|
2162
|
} |
|
2163
|
if( P("reject") ){ |
|
2164
|
char *zParent = |
|
2165
|
db_text(0, |
|
2166
|
"SELECT uuid FROM forumpost, blob" |
|
2167
|
" WHERE forumpost.fpid=%d AND blob.rid=forumpost.firt", |
|
2168
|
fpid |
|
2169
|
); |
|
2170
|
moderation_forumpost_disapprove(fpid); |
|
2171
|
if( zParent ){ |
|
2172
|
cgi_redirectf("%R/forumpost/%S",zParent); |
|
2173
|
}else{ |
|
2174
|
cgi_redirectf("%R/forum"); |
|
2175
|
} |
|
2176
|
return; |
|
2177
|
} |
|
2178
|
} |
|
2179
|
style_set_current_feature("forum"); |
|
2180
|
isDelete = P("nullout")!=0; |
|
2181
|
if( P("submit") |
|
2182
|
&& isCsrfSafe |
|
2183
|
&& (zContent = PDT("content",""))!=0 |
|
2184
|
&& (isDelete || !fossil_all_whitespace(zContent)) |
|
2185
|
){ |
|
2186
|
int done = 1; |
|
2187
|
const char *zMimetype = PD("mimetype",DEFAULT_FORUM_MIMETYPE); |
|
2188
|
if( bReply ){ |
|
2189
|
done = forum_post(0, fpid, 0, 0, zMimetype, zContent, |
|
2190
|
forum_post_flags()); |
|
2191
|
}else if( P("edit") || isDelete ){ |
|
2192
|
done = forum_post(P("title"), 0, fpid, 0, zMimetype, zContent, |
|
2193
|
forum_post_flags()); |
|
2194
|
}else{ |
|
2195
|
webpage_error("Missing 'reply' query parameter"); |
|
2196
|
} |
|
2197
|
if( done ) return; |
|
2198
|
} |
|
2199
|
if( isDelete ){ |
|
2200
|
zMimetype = "text/x-fossil-wiki"; |
|
2201
|
zContent = ""; |
|
2202
|
if( pPost->zThreadTitle ) zTitle = ""; |
|
2203
|
style_header("Delete %s", zTitle ? "Post" : "Reply"); |
|
2204
|
@ <h1>Original Post:</h1> |
|
2205
|
forum_render(pPost->zThreadTitle, pPost->zMimetype, pPost->zWiki, |
|
2206
|
"forumEdit", 1); |
|
2207
|
@ <h1>Change Into:</h1> |
|
2208
|
forum_render(zTitle, zMimetype, zContent,"forumEdit", 1); |
|
2209
|
@ <form action="%R/forume2" method="POST"> |
|
2210
|
login_insert_csrf_secret(); |
|
2211
|
@ <input type="hidden" name="fpid" value="%h(P("fpid"))"> |
|
2212
|
@ <input type="hidden" name="nullout" value="1"> |
|
2213
|
@ <input type="hidden" name="mimetype" value="%h(zMimetype)"> |
|
2214
|
@ <input type="hidden" name="content" value="%h(zContent)"> |
|
2215
|
if( zTitle ){ |
|
2216
|
@ <input aria-label="Title" type="hidden" name="title" value="%h(zTitle)"> |
|
2217
|
} |
|
2218
|
}else if( P("edit") ){ |
|
2219
|
/* Provide an edit to the fpid post */ |
|
2220
|
zMimetype = P("mimetype"); |
|
2221
|
zContent = PT("content"); |
|
2222
|
zTitle = P("title"); |
|
2223
|
if( zContent==0 ) zContent = fossil_strdup(pPost->zWiki); |
|
2224
|
if( zMimetype==0 ) zMimetype = fossil_strdup(pPost->zMimetype); |
|
2225
|
if( zTitle==0 && pPost->zThreadTitle!=0 ){ |
|
2226
|
zTitle = fossil_strdup(pPost->zThreadTitle); |
|
2227
|
} |
|
2228
|
style_header("Edit %s", zTitle ? "Post" : "Reply"); |
|
2229
|
@ <h2>Original Post:</h2> |
|
2230
|
forum_render(pPost->zThreadTitle, pPost->zMimetype, pPost->zWiki, |
|
2231
|
"forumEdit", 1); |
|
2232
|
if( bPreview ){ |
|
2233
|
@ <h2>Preview of Edited Post:</h2> |
|
2234
|
forum_render(zTitle, zMimetype, zContent,"forumEdit", 1); |
|
2235
|
} |
|
2236
|
@ <h2>Revised Message:</h2> |
|
2237
|
@ <form action="%R/forume2" method="POST"> |
|
2238
|
login_insert_csrf_secret(); |
|
2239
|
@ <input type="hidden" name="fpid" value="%h(P("fpid"))"> |
|
2240
|
@ <input type="hidden" name="edit" value="1"> |
|
2241
|
forum_from_line(); |
|
2242
|
forum_post_widget(zTitle, zMimetype, zContent); |
|
2243
|
}else{ |
|
2244
|
/* Reply */ |
|
2245
|
char *zDisplayName; |
|
2246
|
zMimetype = PD("mimetype",DEFAULT_FORUM_MIMETYPE); |
|
2247
|
zContent = PDT("content",""); |
|
2248
|
style_header("Reply"); |
|
2249
|
@ <h2>Replying to |
|
2250
|
@ <a href="%R/forumpost/%!S(zFpid)" target="_blank">%S(zFpid)</a> |
|
2251
|
if( pRootPost->zThreadTitle ){ |
|
2252
|
@ in thread |
|
2253
|
@ <span class="forumPostReplyTitle">%h(pRootPost->zThreadTitle)</span> |
|
2254
|
} |
|
2255
|
@ </h2> |
|
2256
|
zDate = db_text(0, "SELECT datetime(%.17g,toLocal())", pPost->rDate); |
|
2257
|
zDisplayName = display_name_from_login(pPost->zUser); |
|
2258
|
@ <h3 class='forumPostHdr'>By %s(zDisplayName) on %h(zDate)</h3> |
|
2259
|
fossil_free(zDisplayName); |
|
2260
|
fossil_free(zDate); |
|
2261
|
forum_render(0, pPost->zMimetype, pPost->zWiki, "forumEdit", 1); |
|
2262
|
if( bPreview && !fossil_all_whitespace(zContent) ){ |
|
2263
|
@ <h2>Preview:</h2> |
|
2264
|
forum_render(0, zMimetype,zContent, "forumEdit", 1); |
|
2265
|
} |
|
2266
|
@ <h2>Enter Reply:</h2> |
|
2267
|
@ <form action="%R/forume2" method="POST"> |
|
2268
|
@ <input type="hidden" name="fpid" value="%h(P("fpid"))"> |
|
2269
|
@ <input type="hidden" name="reply" value="1"> |
|
2270
|
forum_from_line(); |
|
2271
|
forum_post_widget(0, zMimetype, zContent); |
|
2272
|
} |
|
2273
|
if( !isDelete ){ |
|
2274
|
@ <input type="submit" name="preview" value="Preview"> |
|
2275
|
} |
|
2276
|
@ <input type="submit" name="cancel" value="Cancel"> |
|
2277
|
if( isDelete || (bPreview && !fossil_all_whitespace(zContent)) ){ |
|
2278
|
if( !iClosed || g.perm.Admin ) { |
|
2279
|
@ <input type="submit" name="submit" value="Submit"> |
|
2280
|
} |
|
2281
|
} |
|
2282
|
forum_render_debug_options(); |
|
2283
|
login_insert_csrf_secret(); |
|
2284
|
@ </form> |
|
2285
|
if( !bReply ){ |
|
2286
|
forum_render_attachment_list(rid_to_uuid(fpid)); |
|
2287
|
} |
|
2288
|
if( !isDelete ){ |
|
2289
|
forum_render_attachment_notice(); |
|
2290
|
} |
|
2291
|
forum_emit_js(); |
|
2292
|
style_finish_page(); |
|
2293
|
} |
|
2294
|
|
|
2295
|
/* |
|
2296
|
** SETTING: forum-close-policy boolean default=off |
|
2297
|
** If true, forum moderators may close/re-open forum posts, and reply |
|
2298
|
** to closed posts. If false, only administrators may do so. Note that |
|
2299
|
** this only affects the forum web UI, not post-closing tags which |
|
2300
|
** arrive via the command-line or from synchronization with a remote. |
|
2301
|
** This policy also determines whether moderators may delete forum |
|
2302
|
** attachments. |
|
2303
|
** |
|
2304
|
** SETTING: forum-title width=20 default=Forum |
|
2305
|
** This is the name or "title" of the Forum for this repository. The |
|
2306
|
** default is just "Forum". But in some setups, admins might want to |
|
2307
|
** change it to "Developer Forum" or "User Forum" or whatever other name |
|
2308
|
** seems more appropriate for the particular usage. |
|
2309
|
** |
|
2310
|
** SETTING: attachment-size-limit width=16 |
|
2311
|
** The maximum number of bytes for an attachment to a wiki page, |
|
2312
|
** ticket, tech note, or forum post. The default (or 0) is unlimited |
|
2313
|
** but a limit may be imposed by the web server or a proxy. |
|
2314
|
** |
|
2315
|
** SETTING: forum-statuses width=40 block-text |
|
2316
|
** This JSON5-formatted value defines an array of objects describing |
|
2317
|
** the available statuses of forum posts. Each entry of the array must |
|
2318
|
** be an object in the form {label:"X",value:"Y"}. |
|
2319
|
** The label is used in the UI and value becomes the value of the |
|
2320
|
** "status" tag on forum posts. Any forum post which has a status |
|
2321
|
** value which does not appear in this list is treated as if it had |
|
2322
|
** the first value from this list. If this setting is empty, is |
|
2323
|
** ill-formed JSON, or has only a single entry then the forum will |
|
2324
|
** lack the capability of setting and filtering by status. |
|
2325
|
*/ |
|
2326
|
|
|
2327
|
/* |
|
2328
|
** WEBPAGE: setup_forum |
|
2329
|
** |
|
2330
|
** Forum configuration and metrics. |
|
2331
|
*/ |
|
2332
|
void forum_setup(void){ |
|
2333
|
/* boolean config settings specific to the forum. */ |
|
2334
|
static const char *azForumSettings[] = { |
|
2335
|
"forum-close-policy", |
|
2336
|
"forum-title", |
|
2337
|
}; |
|
2338
|
|
|
2339
|
login_check_credentials(); |
|
2340
|
if( !g.perm.Setup ){ |
|
2341
|
login_needed(g.anon.Setup); |
|
2342
|
return; |
|
2343
|
} |
|
2344
|
style_set_current_feature("forum"); |
|
2345
|
style_header("Forum Setup"); |
|
2346
|
|
|
2347
|
@ <h2>Metrics</h2> |
|
2348
|
{ |
|
2349
|
int nPosts = db_int(0, "SELECT COUNT(*) FROM event WHERE type='f'"); |
|
2350
|
@ <p><a href='%R/forum'>Forum posts</a>: |
|
2351
|
@ <a href='%R/timeline?y=f'>%d(nPosts)</a></p> |
|
2352
|
} |
|
2353
|
|
|
2354
|
@ <h2>Supervisors</h2> |
|
2355
|
{ |
|
2356
|
Stmt q = empty_Stmt; |
|
2357
|
db_prepare(&q, "SELECT uid, login, cap FROM user " |
|
2358
|
"WHERE cap GLOB '*[as6]*' ORDER BY login"); |
|
2359
|
@ <table class='bordered'> |
|
2360
|
@ <thead><tr><th>User</th><th>Capabilities</th></tr></thead> |
|
2361
|
@ <tbody> |
|
2362
|
while( SQLITE_ROW==db_step(&q) ){ |
|
2363
|
const int iUid = db_column_int(&q, 0); |
|
2364
|
const char *zUser = db_column_text(&q, 1); |
|
2365
|
const char *zCap = db_column_text(&q, 2); |
|
2366
|
@ <tr> |
|
2367
|
@ <td><a href='%R/setup_uedit?id=%d(iUid)'>%h(zUser)</a></td> |
|
2368
|
@ <td>(%h(zCap))</td> |
|
2369
|
@ </tr> |
|
2370
|
} |
|
2371
|
db_finalize(&q); |
|
2372
|
@</tbody></table> |
|
2373
|
} |
|
2374
|
|
|
2375
|
@ <h2>Moderators</h2> |
|
2376
|
if( db_int(0, "SELECT count(*) FROM user " |
|
2377
|
" WHERE cap GLOB '*5*' AND cap NOT GLOB '*[as6]*'")==0 ){ |
|
2378
|
@ <p>No non-supervisor moderators |
|
2379
|
}else{ |
|
2380
|
Stmt q = empty_Stmt; |
|
2381
|
db_prepare(&q, "SELECT uid, login, cap FROM user " |
|
2382
|
"WHERE cap GLOB '*5*' AND cap NOT GLOB '*[as6]*'" |
|
2383
|
" ORDER BY login"); |
|
2384
|
@ <table class='bordered'> |
|
2385
|
@ <thead><tr><th>User</th><th>Capabilities</th></tr></thead> |
|
2386
|
@ <tbody> |
|
2387
|
while( SQLITE_ROW==db_step(&q) ){ |
|
2388
|
const int iUid = db_column_int(&q, 0); |
|
2389
|
const char *zUser = db_column_text(&q, 1); |
|
2390
|
const char *zCap = db_column_text(&q, 2); |
|
2391
|
@ <tr> |
|
2392
|
@ <td><a href='%R/setup_uedit?id=%d(iUid)'>%h(zUser)</a></td> |
|
2393
|
@ <td>(%h(zCap))</td> |
|
2394
|
@ </tr> |
|
2395
|
} |
|
2396
|
db_finalize(&q); |
|
2397
|
@ </tbody></table> |
|
2398
|
} |
|
2399
|
|
|
2400
|
@ <h2>Settings</h2> |
|
2401
|
if( P("submit") && cgi_csrf_safe(2) ){ |
|
2402
|
int i = 0; |
|
2403
|
db_begin_transaction(); |
|
2404
|
for(i=0; i<ArraySize(azForumSettings); i++){ |
|
2405
|
char zQP[4]; |
|
2406
|
const char *z; |
|
2407
|
const Setting *pSetting = setting_find(azForumSettings[i]); |
|
2408
|
if( pSetting==0 ) continue; |
|
2409
|
zQP[0] = 'a'+i; |
|
2410
|
zQP[1] = zQP[0]; |
|
2411
|
zQP[2] = 0; |
|
2412
|
z = P(zQP); |
|
2413
|
if( z==0 || z[0]==0 ) continue; |
|
2414
|
db_set(pSetting->name/*works-like:"x"*/, z, 0); |
|
2415
|
} |
|
2416
|
db_end_transaction(0); |
|
2417
|
@ <p><em>Settings saved.</em></p> |
|
2418
|
} |
|
2419
|
{ |
|
2420
|
int i = 0; |
|
2421
|
@ <form action="%R/setup_forum" method="post"> |
|
2422
|
login_insert_csrf_secret(); |
|
2423
|
@ <table class='forum-settings-list'><tbody> |
|
2424
|
for(i=0; i<ArraySize(azForumSettings); i++){ |
|
2425
|
char zQP[4]; |
|
2426
|
const Setting *pSetting = setting_find(azForumSettings[i]); |
|
2427
|
if( pSetting==0 ) continue; |
|
2428
|
zQP[0] = 'a'+i; |
|
2429
|
zQP[1] = zQP[0]; |
|
2430
|
zQP[2] = 0; |
|
2431
|
if( pSetting->width==0 ){ |
|
2432
|
/* Boolean setting */ |
|
2433
|
@ <tr><td align="right"> |
|
2434
|
@ <a href='%R/help/%h(pSetting->name)'>%h(pSetting->name)</a>: |
|
2435
|
@ </td><td> |
|
2436
|
onoff_attribute("", zQP, pSetting->name/*works-like:"x"*/, 0, 0); |
|
2437
|
@ </td></tr> |
|
2438
|
}else{ |
|
2439
|
/* Text value setting */ |
|
2440
|
@ <tr><td align="right"> |
|
2441
|
@ <a href='%R/help/%h(pSetting->name)'>%h(pSetting->name)</a>: |
|
2442
|
@ </td><td> |
|
2443
|
entry_attribute("", 25, pSetting->name, zQP/*works-like:""*/, |
|
2444
|
pSetting->def, 0); |
|
2445
|
@ </td></tr> |
|
2446
|
} |
|
2447
|
} |
|
2448
|
@ </tbody></table> |
|
2449
|
@ <input type='submit' name='submit' value='Apply changes'> |
|
2450
|
@ </form> |
|
2451
|
} |
|
2452
|
|
|
2453
|
style_finish_page(); |
|
2454
|
} |
|
2455
|
|
|
2456
|
/* |
|
2457
|
** If the forum-statuses setting is active and has 2 or more entries, |
|
2458
|
** this adds a submenu for selecting the status filter, else it emits |
|
2459
|
** nothing. |
|
2460
|
*/ |
|
2461
|
static void forum_status_submenu(void){ |
|
2462
|
const ForumStatusList * const fss = forum_statuses(); |
|
2463
|
static int i = 0; |
|
2464
|
static const char **az; |
|
2465
|
if( i==0 && fss->n>1 ){ |
|
2466
|
unsigned j; |
|
2467
|
az = fossil_malloc(sizeof(az[0]) * ((1 + fss->n) * 2)); |
|
2468
|
az[i++] = "*"; |
|
2469
|
az[i++] = "Any status"; |
|
2470
|
for( j = 0; j < fss->n; ++j ){ |
|
2471
|
const ForumStatus * fs = &fss->aStatus[j]; |
|
2472
|
/* Potential TODO: skip any entries for which there are no |
|
2473
|
** forum posts with a status=${fs->zValue} tag. */ |
|
2474
|
az[i++] = fs->zValue; |
|
2475
|
az[i++] = fs->zLabel; |
|
2476
|
} |
|
2477
|
//assert( i==(1+fss->n)*2 ); |
|
2478
|
} |
|
2479
|
if( i ){ |
|
2480
|
cookie_link_parameter("status","forumStatus","*"); |
|
2481
|
style_submenu_multichoice("status", i/2, az, 0); |
|
2482
|
} |
|
2483
|
} |
|
2484
|
|
|
2485
|
/* |
|
2486
|
** Transient SQL Function: status_match(FROOT) |
|
2487
|
** |
|
2488
|
** Return true if the forum thread identified by FROOT should be included |
|
2489
|
** in a list of threads. Used to implement the status=NAME query parameter |
|
2490
|
** on /forum. |
|
2491
|
** |
|
2492
|
** The result of this routine depends on the content of the |
|
2493
|
** ForumStatusMatch *pMData object that is available via sqlite3_user_data(). |
|
2494
|
** |
|
2495
|
** * If pMData==NULL, always return true. This means that no |
|
2496
|
** filtering of threads is being done. This is the common case. |
|
2497
|
** |
|
2498
|
** * If FROOT contains a status property value that matches |
|
2499
|
** pMData->iMatch, return true. |
|
2500
|
** |
|
2501
|
** * if pMData->iMatch==0 (meaning we want to match the default |
|
2502
|
** status value) and if the FROOT thread contains a status that |
|
2503
|
** is not on the list of statuses or if FROOT has no statue |
|
2504
|
** property at all, then return true. In other words, a forum |
|
2505
|
** thread with no status property or an unknown status property |
|
2506
|
** is treated as if it had the default status. |
|
2507
|
** |
|
2508
|
** * Otherwise, return false. |
|
2509
|
*/ |
|
2510
|
static void forum_status_match( |
|
2511
|
sqlite3_context *context, |
|
2512
|
int argc, |
|
2513
|
sqlite3_value **argv |
|
2514
|
){ |
|
2515
|
static Stmt q; |
|
2516
|
ForumStatusMatch *pMData = sqlite3_user_data(context); |
|
2517
|
int i; |
|
2518
|
|
|
2519
|
if( pMData==0 ){ |
|
2520
|
sqlite3_result_int(context, 1); |
|
2521
|
return; |
|
2522
|
} |
|
2523
|
db_static_prepare(&q, |
|
2524
|
"SELECT value FROM tagxref\n" |
|
2525
|
" WHERE tagid=%d\n" |
|
2526
|
" AND tagtype>=1\n" |
|
2527
|
" AND rid=:rid\n" |
|
2528
|
" ORDER BY mtime DESC LIMIT 1", |
|
2529
|
pMData->eStatusTag |
|
2530
|
); |
|
2531
|
db_bind_int(&q, ":rid", sqlite3_value_int(argv[0])); |
|
2532
|
if( db_step(&q)==SQLITE_ROW ){ |
|
2533
|
const char *zValue = (const char*)db_column_text(&q,0); |
|
2534
|
const ForumStatusList *pFses = pMData->pFses; |
|
2535
|
if( zValue==0 ){ |
|
2536
|
i = 0; |
|
2537
|
}else{ |
|
2538
|
for(i=0; i<pFses->n; i++){ |
|
2539
|
if( fossil_strcmp(pFses->aStatus[i].zValue,zValue)==0 ) break; |
|
2540
|
} |
|
2541
|
} |
|
2542
|
if( i>=pMData->pFses->n ) i = 0; |
|
2543
|
}else{ |
|
2544
|
i = 0; |
|
2545
|
} |
|
2546
|
db_reset(&q); |
|
2547
|
sqlite3_result_int(context, i==pMData->iMatch); |
|
2548
|
} |
|
2549
|
|
|
2550
|
/* |
|
2551
|
** WEBPAGE: forummain |
|
2552
|
** WEBPAGE: forum |
|
2553
|
** |
|
2554
|
** The main page for the forum feature. Show a list of recent forum |
|
2555
|
** threads. Also show a search box at the top if search is enabled, |
|
2556
|
** and a button for creating a new thread, if enabled. |
|
2557
|
** |
|
2558
|
** Query parameters: |
|
2559
|
** |
|
2560
|
** n=N The number of threads to show on each page |
|
2561
|
** x=X Skip the first X threads |
|
2562
|
** s=Y Search for term Y. |
|
2563
|
*/ |
|
2564
|
void forum_main_page(void){ |
|
2565
|
Stmt q; |
|
2566
|
int iLimit = 0, iOfst, iCnt; |
|
2567
|
int srchFlags; |
|
2568
|
const int isSearch = P("s")!=0; |
|
2569
|
const char *zStatusFilter; |
|
2570
|
char const *zLimit = 0; /* Value of the n= query parameter */ |
|
2571
|
int eStatusTag = 0; /* tagid for the "status" property */ |
|
2572
|
int bHasStatus = 0; /* True if forum-statuses setting exists */ |
|
2573
|
int bFilter = 0; /* True if status=NAME query parameter */ |
|
2574
|
int bHasForum = 0; /* True if forumpost table exists */ |
|
2575
|
const ForumStatusList *pFstat = forum_statuses(); |
|
2576
|
ForumStatusMatch sFSM; /* Aux data to status_match() SQL function */ |
|
2577
|
|
|
2578
|
login_check_credentials(); |
|
2579
|
srchFlags = search_restrict(SRCH_FORUM); |
|
2580
|
if( !g.perm.RdForum ){ |
|
2581
|
login_needed(g.anon.RdForum); |
|
2582
|
return; |
|
2583
|
} |
|
2584
|
cgi_check_for_malice(); |
|
2585
|
bHasForum = db_table_exists("repository","forumpost"); |
|
2586
|
if( bHasForum ){ |
|
2587
|
eStatusTag = db_int(0, "SELECT tagid FROM tag WHERE tagname='status'"); |
|
2588
|
bHasStatus = pFstat->n>1; |
|
2589
|
} |
|
2590
|
style_set_current_feature("forum"); |
|
2591
|
style_header("%s%s", db_get("forum-title","Forum"), |
|
2592
|
isSearch ? " Search Results" : ""); |
|
2593
|
style_submenu_element("Timeline", "%R/timeline?ss=v&y=f&vfx"); |
|
2594
|
if( g.perm.WrForum ){ |
|
2595
|
style_submenu_element("New Thread","%R/forumnew"); |
|
2596
|
}else{ |
|
2597
|
style_submenu_element("New Thread","%R/login"); |
|
2598
|
} |
|
2599
|
if( g.perm.ModForum && moderation_needed() ){ |
|
2600
|
style_submenu_element("Moderation Requests", "%R/modreq"); |
|
2601
|
} |
|
2602
|
if( (srchFlags & SRCH_FORUM)!=0 ){ |
|
2603
|
if( search_screen(SRCH_FORUM, 0) ){ |
|
2604
|
style_submenu_element("Recent Threads","%R/forum"); |
|
2605
|
style_finish_page(); |
|
2606
|
return; |
|
2607
|
} |
|
2608
|
} |
|
2609
|
cookie_read_parameter("n","forum-n"); |
|
2610
|
zLimit = P("n"); |
|
2611
|
if( zLimit!=0 ){ |
|
2612
|
iLimit = atoi(zLimit); |
|
2613
|
if( iLimit>=0 && P("udc")!=0 ){ |
|
2614
|
cookie_write_parameter("n","forum-n",0); |
|
2615
|
} |
|
2616
|
} |
|
2617
|
if( iLimit<=0 ){ |
|
2618
|
cgi_replace_query_parameter("n", fossil_strdup("25")) |
|
2619
|
/*for the sake of Max, below*/; |
|
2620
|
iLimit = 25; |
|
2621
|
} |
|
2622
|
style_submenu_entry("n","Max:",4,0); |
|
2623
|
forum_status_submenu(); |
|
2624
|
zStatusFilter = P("status") /*must be after forum_status_submenu()!*/; |
|
2625
|
iOfst = atoi(PD("x","0")); |
|
2626
|
iCnt = 0; |
|
2627
|
if( zStatusFilter ){ |
|
2628
|
if( zStatusFilter[0]==0 || 0==fossil_strcmp("*",zStatusFilter) ){ |
|
2629
|
zStatusFilter = 0; |
|
2630
|
}else{ |
|
2631
|
bFilter = bHasStatus; |
|
2632
|
} |
|
2633
|
} |
|
2634
|
if( bHasForum ){ |
|
2635
|
Stmt qStat = empty_Stmt; /* Query to get status information */ |
|
2636
|
if( bHasStatus ){ |
|
2637
|
/* The qStat query runs once for each output row generate by the |
|
2638
|
** q query. It determines the value and label of the status for |
|
2639
|
** the row with froot=:rowid |
|
2640
|
*/ |
|
2641
|
db_prepare(&qStat, |
|
2642
|
"SELECT tagxref.value, forumstatus.label\n" |
|
2643
|
" FROM forumstatus, tagxref\n" |
|
2644
|
" WHERE tagid=%d AND tagtype>=1\n" |
|
2645
|
" AND forumstatus.value=tagxref.value\n" |
|
2646
|
" AND rid=:rid\n" |
|
2647
|
" ORDER BY mtime DESC", |
|
2648
|
eStatusTag |
|
2649
|
); |
|
2650
|
} |
|
2651
|
|
|
2652
|
/* Create the status_match() SQL function that will determine |
|
2653
|
** whether or not each thread in the "q" query below is eligible |
|
2654
|
** for display |
|
2655
|
*/ |
|
2656
|
if( bFilter ){ |
|
2657
|
sFSM.pFses = pFstat; |
|
2658
|
sFSM.eStatusTag = eStatusTag; |
|
2659
|
for(sFSM.iMatch=0; sFSM.iMatch<pFstat->n; sFSM.iMatch++){ |
|
2660
|
if( 0==fossil_strcmp(zStatusFilter, |
|
2661
|
pFstat->aStatus[sFSM.iMatch].zValue) ){ |
|
2662
|
break; |
|
2663
|
} |
|
2664
|
} |
|
2665
|
sqlite3_create_function(g.db,"status_match",1,SQLITE_UTF8,(void*)&sFSM, |
|
2666
|
forum_status_match, 0, 0); |
|
2667
|
}else{ |
|
2668
|
sqlite3_create_function(g.db,"status_match",1,SQLITE_UTF8,0, |
|
2669
|
forum_status_match, 0, 0); |
|
2670
|
} |
|
2671
|
|
|
2672
|
db_prepare(&q, |
|
2673
|
"WITH thread(root,endtime,lastrid) AS (\n" |
|
2674
|
" SELECT\n" |
|
2675
|
" froot,\n" |
|
2676
|
" max(fmtime),\n" |
|
2677
|
" fpid\n" |
|
2678
|
" FROM forumpost\n" |
|
2679
|
" WHERE %s/*ModForum*/\n" |
|
2680
|
" GROUP BY froot\n" |
|
2681
|
" HAVING status_match(froot)\n" |
|
2682
|
" ORDER BY 2 DESC\n" |
|
2683
|
" LIMIT %d OFFSET %d\n" |
|
2684
|
")\n" |
|
2685
|
"SELECT\n" |
|
2686
|
" julianday('now') - thread.endtime,\n" /* 0 */ |
|
2687
|
" thread.endtime - " |
|
2688
|
"(SELECT fmtime FROM forumpost WHERE fpid=root),\n" /* 1 */ |
|
2689
|
" (SELECT sum(fprev IS NULL) FROM forumpost" |
|
2690
|
" WHERE froot=root),\n" /* 2 */ |
|
2691
|
" blob.uuid,\n" /* 3 */ |
|
2692
|
" substr(event.comment,instr(event.comment,':')+1),\n" /* 4 */ |
|
2693
|
" thread.lastrid,\n" /* 5 */ |
|
2694
|
" thread.root\n" /* 6 */ |
|
2695
|
" FROM thread, blob, event\n" |
|
2696
|
" WHERE blob.rid=thread.lastrid\n" |
|
2697
|
" AND event.objid=thread.lastrid\n" |
|
2698
|
" ORDER BY 1;", |
|
2699
|
g.perm.ModForum ? "true" : "fpid NOT IN private" /*safe-for-%s*/, |
|
2700
|
iLimit+1, iOfst |
|
2701
|
); |
|
2702
|
while( db_step(&q)==SQLITE_ROW ){ |
|
2703
|
char *zAge; |
|
2704
|
int nMsg; |
|
2705
|
const char *zUuid; |
|
2706
|
const char *zTitle; |
|
2707
|
const char *zStatus; |
|
2708
|
const char *zStatusLbl; |
|
2709
|
const int bShowStatus = bHasStatus && !zStatusFilter; |
|
2710
|
const int nCols = bShowStatus ? 4 : 3; |
|
2711
|
|
|
2712
|
if( qStat.pStmt ){ |
|
2713
|
/* Determine the status value for this row */ |
|
2714
|
db_reset(&qStat); |
|
2715
|
db_bind_int(&qStat, ":rid", db_column_int(&q,6)); |
|
2716
|
if( db_step(&qStat)==SQLITE_ROW ){ |
|
2717
|
zStatus = db_column_text(&qStat, 0); |
|
2718
|
zStatusLbl = db_column_text(&qStat, 1); |
|
2719
|
}else{ |
|
2720
|
zStatus = pFstat->aStatus[0].zValue; |
|
2721
|
zStatusLbl = pFstat->aStatus[0].zLabel; |
|
2722
|
} |
|
2723
|
}else{ |
|
2724
|
zStatus = zStatusLbl = NULL; |
|
2725
|
} |
|
2726
|
zAge = human_readable_age(db_column_double(&q,0)); |
|
2727
|
nMsg = db_column_int(&q, 2); |
|
2728
|
zUuid = db_column_text(&q, 3); |
|
2729
|
zTitle = db_column_text(&q, 4); |
|
2730
|
if( iCnt==0 ){ |
|
2731
|
char *zTail = bFilter ? mprintf(" with status=%Q", zStatusFilter): 0; |
|
2732
|
if( iOfst>0 ){ |
|
2733
|
@ <h1>Threads at least %s(zAge) old%h(zTail ? zTail : "")</h1> |
|
2734
|
}else{ |
|
2735
|
@ <h1>Most recent threads%h(zTail ? zTail : "")</h1> |
|
2736
|
} |
|
2737
|
fossil_free(zTail); |
|
2738
|
@ <div class='forumPosts fileage'><table width="100%%"> |
|
2739
|
if( iOfst>0 ){ |
|
2740
|
if( iOfst>iLimit ){ |
|
2741
|
@ <tr><td colspan="%d(nCols)">\ |
|
2742
|
@ <a href='%R/forum?x=%d(iOfst-iLimit)&n=%d(iLimit) \ |
|
2743
|
if( bFilter ){ |
|
2744
|
@ &status=%T(zStatusFilter)\ |
|
2745
|
} |
|
2746
|
@ '>↑ Newer...</a></td></tr> |
|
2747
|
}else{ |
|
2748
|
@ <tr><td colspan="%d(nCols)">\ |
|
2749
|
@ <a href='%R/forum?n=%d(iLimit)\ |
|
2750
|
if( bFilter ){ |
|
2751
|
@ &status=%T(zStatusFilter) \ |
|
2752
|
} |
|
2753
|
@ '>↑ Newer...</a></td></tr> |
|
2754
|
} |
|
2755
|
} |
|
2756
|
} |
|
2757
|
iCnt++; |
|
2758
|
if( iCnt>iLimit ){ |
|
2759
|
@ <tr><td colspan="%d(nCols)">\ |
|
2760
|
@ <a href='%R/forum?x=%d(iOfst+iLimit)&n=%d(iLimit) \ |
|
2761
|
if( bFilter ){ |
|
2762
|
@ &status=%T(zStatusFilter)\ |
|
2763
|
} |
|
2764
|
@ '>↓ Older...</a></td></tr> |
|
2765
|
fossil_free(zAge); |
|
2766
|
break; |
|
2767
|
} |
|
2768
|
@ <tr \ |
|
2769
|
if( bHasStatus ){ |
|
2770
|
@ data-status="%h(zStatus)"\ |
|
2771
|
} |
|
2772
|
@ ><td>%h(zAge) ago</td> |
|
2773
|
@ <td class='subject'>%z(href("%R/forumpost/%S",zUuid))%h(zTitle)</a>\ |
|
2774
|
@ </td><td>\ |
|
2775
|
if( g.perm.ModForum && moderation_pending(db_column_int(&q,5)) ){ |
|
2776
|
@ <span class="modpending">\ |
|
2777
|
@ Awaiting Moderator Approval</span><br> |
|
2778
|
} |
|
2779
|
if( nMsg<2 ){ |
|
2780
|
@ no replies\ |
|
2781
|
}else{ |
|
2782
|
char *zDuration = human_readable_age(db_column_double(&q,1)); |
|
2783
|
@ %d(nMsg) posts spanning %h(zDuration)\ |
|
2784
|
fossil_free(zDuration); |
|
2785
|
} |
|
2786
|
@ </td>\ |
|
2787
|
if( bShowStatus ){ |
|
2788
|
@ <td class='status'>%h(zStatusLbl)</td>\ |
|
2789
|
} |
|
2790
|
if( qStat.pStmt ){ |
|
2791
|
db_reset(&qStat); |
|
2792
|
} |
|
2793
|
@</tr> |
|
2794
|
fossil_free(zAge); |
|
2795
|
} |
|
2796
|
db_finalize(&q); |
|
2797
|
if( qStat.pStmt ) db_finalize(&qStat); |
|
2798
|
sqlite3_create_function(g.db,"status_match",1,SQLITE_UTF8,0,0,0,0); |
|
2799
|
} |
|
2800
|
if( iCnt>0 ){ |
|
2801
|
@ </table></div> |
|
2802
|
}else{ |
|
2803
|
@ <h1>No forum posts found</h1> |
|
2804
|
} |
|
2805
|
if( bHasStatus ){ |
|
2806
|
/* We need a JS-side kludge to avoid passing on the x=N |
|
2807
|
** URL arg when the status selection list is activated. */ |
|
2808
|
forum_emit_js(); |
|
2809
|
} |
|
2810
|
style_finish_page(); |
|
2811
|
} |
|
2812
|
|
|
2813
|
/* |
|
2814
|
** The AJAX counterpart of forum_post(). |
|
2815
|
** |
|
2816
|
** Returns the new artifact's RID on success, 0 if no changes were |
|
2817
|
** necessary (e.g. an empty new post or dry-run mode), and a negative |
|
2818
|
** value on error. If it returns a negative value then it will have |
|
2819
|
** populated the ajax response state with an error object. |
|
2820
|
** |
|
2821
|
** zTitle must be NULL if iInReplyTo>0 and must be non-empty if |
|
2822
|
** iInReplyTo==0. |
|
2823
|
** |
|
2824
|
** The caller must have started a transaction and must roll it back if |
|
2825
|
** this call returns <=0, noting that only the negative-value case is |
|
2826
|
** an error. |
|
2827
|
** |
|
2828
|
** This function does some work to try to ensure that duplicate |
|
2829
|
** entries are not save (this can happen as a side effect of the forum |
|
2830
|
** post editor added in 2026-06). If the given post will not have been |
|
2831
|
** materially edited by these changes, they are not applied and the |
|
2832
|
** rid of the existing entry is used. |
|
2833
|
** |
|
2834
|
** Maintenance reminders: |
|
2835
|
** |
|
2836
|
** - iInReplyTo==0 && iEdit==0: new thread |
|
2837
|
** - iInReplyTo==0 && iEdit>0 : edit top post or response |
|
2838
|
** - iInReplyTo>0 && iEdit==0: new response |
|
2839
|
** - iInReplyTo>0 && iEdit>0 : edit response |
|
2840
|
*/ |
|
2841
|
static int forum_post_ajax( |
|
2842
|
const char *zTitle, /* Title. NULL for replies */ |
|
2843
|
int iInReplyTo, /* Post replying to. 0 for new threads */ |
|
2844
|
int iEdit, /* Post being edited, or zero for a new post */ |
|
2845
|
const char *zUser, /* Username. NULL means use login name */ |
|
2846
|
const char *zMimetype, /* Mimetype of content. */ |
|
2847
|
const char *zContent, /* Content */ |
|
2848
|
int iFlags /* FPOST_xyz flag values */ |
|
2849
|
){ |
|
2850
|
char *zI; |
|
2851
|
char *zG; |
|
2852
|
char *zP = 0; |
|
2853
|
int iBasis; |
|
2854
|
Blob x = BLOB_INITIALIZER, |
|
2855
|
cksum = BLOB_INITIALIZER, |
|
2856
|
formatCheck = BLOB_INITIALIZER, |
|
2857
|
errMsg = BLOB_INITIALIZER; |
|
2858
|
Manifest *pPost = 0; |
|
2859
|
int nContent = zContent ? (int)strlen(zContent) : 0; |
|
2860
|
int rc = 0; |
|
2861
|
|
|
2862
|
assert( db_transaction_nesting_depth()>0 ); |
|
2863
|
schema_forum(); |
|
2864
|
if( iEdit==0 && fossil_all_whitespace(zContent) ){ |
|
2865
|
return 0; |
|
2866
|
} |
|
2867
|
if( !g.perm.Admin && (iEdit || iInReplyTo) |
|
2868
|
&& forum_rid_is_tagged(iEdit ? iEdit : iInReplyTo, "closed", 1) ){ |
|
2869
|
return -ajax_route_error(400, "Thread is closed."); |
|
2870
|
} |
|
2871
|
if( 0==iInReplyTo && fossil_all_whitespace(zTitle) ){ |
|
2872
|
return -ajax_route_error(400, "Empty title is not permitted."); |
|
2873
|
} |
|
2874
|
|
|
2875
|
if( zUser==0 ){ |
|
2876
|
if( login_is_nobody() ){ |
|
2877
|
zUser = "anonymous"; |
|
2878
|
}else{ |
|
2879
|
zUser = login_name(); |
|
2880
|
} |
|
2881
|
} |
|
2882
|
if( iEdit>0 |
|
2883
|
&& !g.perm.Admin |
|
2884
|
&& !forumpost_is_owner(iEdit, zUser) ){ |
|
2885
|
return -ajax_route_error( |
|
2886
|
403, "Only admins may edit other peoples' posts." |
|
2887
|
); |
|
2888
|
} |
|
2889
|
if( iInReplyTo==0 && iEdit>0 ){ |
|
2890
|
iBasis = iEdit; |
|
2891
|
iInReplyTo = db_int(0, "SELECT firt FROM forumpost WHERE fpid=%d", |
|
2892
|
iEdit); |
|
2893
|
}else{ |
|
2894
|
iBasis = iInReplyTo; |
|
2895
|
/* TODO (2026-06-008) If (iInReplyTo>0 && iEdit>0), validate that |
|
2896
|
** iInReplyTo is connected to iEdit properly, else we risk |
|
2897
|
** reparenting the new edit and having unrepredictable downstream |
|
2898
|
** side effects. */ |
|
2899
|
} |
|
2900
|
|
|
2901
|
if( 0!=zMimetype && 0==zMimetype[0] ){ |
|
2902
|
zMimetype = 0; |
|
2903
|
} |
|
2904
|
|
|
2905
|
if( 0!=zTitle && 0==zTitle[0] ) zTitle = 0; |
|
2906
|
webpage_assert( (zTitle==0)+(iInReplyTo==0)==1 ); |
|
2907
|
|
|
2908
|
if( iEdit>0 ){ |
|
2909
|
int cmp; |
|
2910
|
pPost = manifest_get(iEdit, CFTYPE_FORUM, 0); |
|
2911
|
if( pPost==0 ){ |
|
2912
|
rc = -ajax_route_error(404, "Missing edit artifact %d", iEdit); |
|
2913
|
goto post_ajax_end; |
|
2914
|
} |
|
2915
|
/* |
|
2916
|
** If the old content matches the new then do not save a new copy. |
|
2917
|
** It's easy to get re-posts of unedited content via the forum |
|
2918
|
** editor, especially since the one added in 2026-06, where a |
|
2919
|
** post's status and attachments may be amended from the editor |
|
2920
|
** without modifying any of the post's content. In the legacy |
|
2921
|
** editor such "out-of-band" changes weren't possible and users |
|
2922
|
** have never made a practice of re-posting unedited content. |
|
2923
|
** |
|
2924
|
** We compare the following fields to the original: user, mimetype, |
|
2925
|
** content, and (for root posts only) the title. |
|
2926
|
*/ |
|
2927
|
cmp = (0==pPost->zInReplyTo) |
|
2928
|
? fossil_strcmp(pPost->zThreadTitle, zTitle) |
|
2929
|
: 0; |
|
2930
|
if( 0==cmp ){ |
|
2931
|
cmp=fossil_strcmp(pPost->zWiki, zContent); |
|
2932
|
if( 0==cmp ){ |
|
2933
|
cmp = fossil_strcmp(pPost->zUser, zUser); |
|
2934
|
} |
|
2935
|
if( 0==cmp |
|
2936
|
&& 0!=(cmp=fossil_strcmp(pPost->zMimetype, zMimetype)) ){ |
|
2937
|
/* Extra mimetype checks for a common condition seen elsewhere */ |
|
2938
|
if( (0==zMimetype |
|
2939
|
&& 0==fossil_strcmp(pPost->zMimetype, "text/x-fossil-wiki")) |
|
2940
|
|| (0==pPost->zMimetype |
|
2941
|
&& 0==fossil_strcmp(zMimetype, "text/x-fossil-wiki")) ){ |
|
2942
|
cmp = 0; |
|
2943
|
} |
|
2944
|
} |
|
2945
|
if( 0==cmp ){ |
|
2946
|
rc = iEdit; |
|
2947
|
goto post_ajax_end; |
|
2948
|
} |
|
2949
|
} |
|
2950
|
zP = rid_to_uuid(iEdit); |
|
2951
|
} |
|
2952
|
|
|
2953
|
/* Write the new artifact */ |
|
2954
|
blob_init(&x, 0, 0); |
|
2955
|
blob_appendf(&x, "D %z\n", date_in_standard_format("now")); |
|
2956
|
zG = db_text( |
|
2957
|
0, |
|
2958
|
"SELECT uuid FROM blob, forumpost" |
|
2959
|
" WHERE blob.rid==forumpost.froot" |
|
2960
|
" AND forumpost.fpid=%d", |
|
2961
|
iBasis |
|
2962
|
); |
|
2963
|
if( zG ){ |
|
2964
|
blob_appendf(&x, "G %z\n", zG); |
|
2965
|
} |
|
2966
|
if( zTitle ){ |
|
2967
|
blob_appendf(&x, "H %F\n", zTitle); |
|
2968
|
} |
|
2969
|
if( iInReplyTo>0 ){ |
|
2970
|
zI = rid_to_uuid(iInReplyTo); |
|
2971
|
if( 0==zI ){ |
|
2972
|
rc = -ajax_route_error(404, "Missing in-reply-to artifact %d", |
|
2973
|
iInReplyTo); |
|
2974
|
goto post_ajax_end; |
|
2975
|
} |
|
2976
|
blob_appendf(&x, "I %z\n", zI); |
|
2977
|
} |
|
2978
|
if( zMimetype!=0 |
|
2979
|
&& fossil_strcmp(zMimetype,"text/x-fossil-wiki")!=0 ){ |
|
2980
|
blob_appendf(&x, "N %F\n", zMimetype); |
|
2981
|
} |
|
2982
|
if( zP ){ |
|
2983
|
blob_appendf(&x, "P %s\n", zP); |
|
2984
|
} |
|
2985
|
|
|
2986
|
blob_appendf(&x, "U %F\n", zUser); |
|
2987
|
blob_appendf(&x, "W %d\n%s\n", nContent, zContent); |
|
2988
|
md5sum_blob(&x, &cksum); |
|
2989
|
blob_appendf(&x, "Z %b\n", &cksum); |
|
2990
|
blob_reset(&cksum); |
|
2991
|
|
|
2992
|
/* Verify that the artifact we are creating is well-formed */ |
|
2993
|
blob_init(&formatCheck, 0, 0); |
|
2994
|
blob_init(&errMsg, 0, 0); |
|
2995
|
blob_copy(&formatCheck, &x); |
|
2996
|
pPost = manifest_parse(&formatCheck, 0, &errMsg); |
|
2997
|
if( pPost==0 ){ |
|
2998
|
ajax_route_error(500, "Malformed forum post artifact: %b", &errMsg); |
|
2999
|
rc = -500; |
|
3000
|
goto post_ajax_end; |
|
3001
|
} |
|
3002
|
webpage_assert( pPost->type==CFTYPE_FORUM ); |
|
3003
|
|
|
3004
|
if( (iFlags & FPOST_DRYRUN)!=0 ){ |
|
3005
|
rc = 0; |
|
3006
|
}else{ |
|
3007
|
int nrid; |
|
3008
|
db_begin_transaction(); |
|
3009
|
nrid = wiki_put(&x, iEdit>0 ? iEdit : 0, forum_need_moderation()); |
|
3010
|
blob_reset(&x); |
|
3011
|
if( (iFlags & FPOST_NO_ALERT)!=0 ){ |
|
3012
|
alert_unqueue('f', nrid); |
|
3013
|
} |
|
3014
|
rc = nrid; |
|
3015
|
db_end_transaction(0); |
|
3016
|
} |
|
3017
|
post_ajax_end: |
|
3018
|
manifest_destroy(pPost); |
|
3019
|
fossil_free(zP); |
|
3020
|
blob_reset(&x); |
|
3021
|
blob_reset(&cksum); |
|
3022
|
blob_reset(&formatCheck); |
|
3023
|
return rc; |
|
3024
|
} |
|
3025
|
/* |
|
3026
|
** WEBPAGE: forumajax_save hidden |
|
3027
|
** |
|
3028
|
** WIP |
|
3029
|
** |
|
3030
|
** Response JSON: |
|
3031
|
** |
|
3032
|
** { uuid: hash, ...tbd } |
|
3033
|
*/ |
|
3034
|
void forum_ajax_save_page(void){ |
|
3035
|
const char *zFpid; |
|
3036
|
const char *zTitle; |
|
3037
|
const char *zIrt; |
|
3038
|
const char *zMimetype; |
|
3039
|
const char *zContent; |
|
3040
|
const char *zStatus; |
|
3041
|
const int bHasAttachment = P("file1")!=0; |
|
3042
|
Manifest *pPost = 0; |
|
3043
|
char *zNewUuid = 0; |
|
3044
|
int firt = 0; /* In-reply-to rid or 0 */ |
|
3045
|
int fpid = 0; /* Post rid being edited or 0 */ |
|
3046
|
int rc = 0; /* Result code. */ |
|
3047
|
int nrid = 0; /* New artifact rid. */ |
|
3048
|
int iPostFlags; /* forum_post_flags() (after perms check) */ |
|
3049
|
int bRollback; /* True = roll back. */ |
|
3050
|
int nAttach = 0; /* Number of attachments added */ |
|
3051
|
int bStatusSet = 0; /* True if status tag set. */ |
|
3052
|
|
|
3053
|
if( !ajax_route_bootstrap(0, 1) ){ |
|
3054
|
return; |
|
3055
|
}else if( !g.perm.WrForum |
|
3056
|
|| (bHasAttachment && !g.perm.AttachForum) ){ |
|
3057
|
ajax_route_error_forbidden(); |
|
3058
|
return; |
|
3059
|
}else if( !ajax_check_csrf(2) ){ |
|
3060
|
ajax_route_error_csrf(); |
|
3061
|
return; |
|
3062
|
} |
|
3063
|
|
|
3064
|
iPostFlags = forum_post_flags(/*must come after permissions init*/); |
|
3065
|
bRollback = (FPOST_DRYRUN & iPostFlags); |
|
3066
|
zFpid = P("fpid"); |
|
3067
|
zIrt = P("firt"); |
|
3068
|
zMimetype = P("mimetype"); |
|
3069
|
zContent = P("content"); |
|
3070
|
zStatus = P("status"); |
|
3071
|
db_begin_transaction(); |
|
3072
|
if( zFpid && zFpid[0] ){ |
|
3073
|
fpid = symbolic_name_to_rid(zFpid, "f"); |
|
3074
|
if( fpid<0 ){ |
|
3075
|
rc = -ajax_route_error(400, "Ambiguous forum ID."); |
|
3076
|
goto ajax_save_end; |
|
3077
|
}else if( 0==fpid |
|
3078
|
|| 0==(pPost = manifest_get(fpid, CFTYPE_FORUM, 0)) ){ |
|
3079
|
rc = -ajax_route_error(404, "Cannot resolve forum post ID."); |
|
3080
|
goto ajax_save_end; |
|
3081
|
} |
|
3082
|
} |
|
3083
|
/* |
|
3084
|
** Problem: if we derive firt from fpid/pPost then there's a race |
|
3085
|
** condition where the IRT post is edited between the time that this |
|
3086
|
** edit was initiated and when it is posted: the new edit's IRT will |
|
3087
|
** point to the edit which was made in the meantime, not the one the |
|
3088
|
** user intended to respond to. However, if we accept firt from the |
|
3089
|
** enviornment, we "really should" validate that it's actually in |
|
3090
|
** the current chain, to prohibit that malicious posts could move |
|
3091
|
** posts around. |
|
3092
|
** |
|
3093
|
** forum_post_ajax() will, if fpid>0 && !firt, select fpid's current |
|
3094
|
** firt. |
|
3095
|
*/ |
|
3096
|
if( zIrt && zIrt[0] ){ |
|
3097
|
firt = symbolic_name_to_rid(zIrt, "f"); |
|
3098
|
if( firt<0 ){ |
|
3099
|
rc = -ajax_route_error(400, "Ambiguous in-reply-do ID."); |
|
3100
|
goto ajax_save_end; |
|
3101
|
}else if( 0==firt ){ |
|
3102
|
rc = -ajax_route_error(404, "Cannot resolve in-reply-do ID."); |
|
3103
|
goto ajax_save_end; |
|
3104
|
} |
|
3105
|
} |
|
3106
|
|
|
3107
|
if( 0 ){ |
|
3108
|
rc = -ajax_route_error(400, "Save is TODO. " |
|
3109
|
"iPostFlags=%d debug=%d", |
|
3110
|
iPostFlags, g.perm.Debug); |
|
3111
|
goto ajax_save_end; |
|
3112
|
} |
|
3113
|
|
|
3114
|
zTitle = firt ? 0 : P("title"); |
|
3115
|
nrid = forum_post_ajax(zTitle, firt, fpid, 0, zMimetype, |
|
3116
|
zContent, iPostFlags); |
|
3117
|
if( nrid<0 ){ |
|
3118
|
rc = nrid; |
|
3119
|
goto ajax_save_end; |
|
3120
|
}else if( nrid==0 ){ |
|
3121
|
if( 0==(FPOST_DRYRUN & iPostFlags) ){ |
|
3122
|
bRollback = 1; |
|
3123
|
CX("{\"message\": \"No saving needed.\"}\n"); |
|
3124
|
}else{ |
|
3125
|
CX("{\"message\": \"Rolled back for dry-run.\"," |
|
3126
|
"\"iPostFlags\":%d}\n", iPostFlags); |
|
3127
|
} |
|
3128
|
goto ajax_save_end; |
|
3129
|
}else{ |
|
3130
|
const int bNeedsModeration = forum_need_moderation(); |
|
3131
|
const int fpHead = forumpost_head_rid(nrid); |
|
3132
|
assert( nrid>0 ); |
|
3133
|
assert( fpHead>0 ); |
|
3134
|
zNewUuid = rid_to_uuid(nrid); |
|
3135
|
if( 0!=P("file1") ){ |
|
3136
|
/* Attachments */ |
|
3137
|
if( !g.perm.Admin && !g.perm.AttachForum ){ |
|
3138
|
rc = -ajax_route_error(403, "No permission no attach files."); |
|
3139
|
goto ajax_save_end; |
|
3140
|
}else{ |
|
3141
|
char *zRoot = (nrid==fpHead) ? 0 : rid_to_uuid(fpHead); |
|
3142
|
nAttach = attachments_ajax_from_POST(zRoot ? zRoot : zNewUuid, |
|
3143
|
bNeedsModeration); |
|
3144
|
fossil_free(zRoot); |
|
3145
|
if( nAttach<0 ){ |
|
3146
|
rc = nAttach; |
|
3147
|
goto ajax_save_end; |
|
3148
|
} |
|
3149
|
if( nAttach>0 |
|
3150
|
&& (iPostFlags & FPOST_NO_ALERT)!=0 |
|
3151
|
&& db_table_exists("repository","pending_alert") ){ |
|
3152
|
/* Unqueue any alerts for these attachments. Recall that |
|
3153
|
** they're attached to the first version of the post, which |
|
3154
|
** means we actually risk cancelling _other_ pending |
|
3155
|
** notifications for attachments on this same post. C'est la |
|
3156
|
** vie.*/ |
|
3157
|
db_multi_exec( |
|
3158
|
"WITH x(id) AS (\n" |
|
3159
|
" SELECT 'f%d'\n" |
|
3160
|
" UNION ALL\n" |
|
3161
|
" SELECT 'f'||a.attachid FROM blob b, attachment a\n" |
|
3162
|
" WHERE b.rid=%d\n" |
|
3163
|
" AND b.uuid=a.target\n" |
|
3164
|
") DELETE FROM pending_alert WHERE eventid IN x", |
|
3165
|
fpHead, fpHead |
|
3166
|
); |
|
3167
|
} |
|
3168
|
} |
|
3169
|
} |
|
3170
|
if( 0==bNeedsModeration |
|
3171
|
/* ^^^ Do not allow a status tag on a pending-moderation post |
|
3172
|
** because it will introduce a reference to an artifact which |
|
3173
|
** will become a phantom if it is rejected by a moderator. */ |
|
3174
|
&& zStatus!=0 && zStatus[0]!=0 |
|
3175
|
&& forum_may_set_status(nrid) |
|
3176
|
&& (bStatusSet=forumpost_tag(nrid, 1, "status", zStatus))<0 ){ |
|
3177
|
rc = -ajax_route_error(500, "Tagging failed: %s", g.zErrMsg); |
|
3178
|
goto ajax_save_end; |
|
3179
|
} |
|
3180
|
} |
|
3181
|
|
|
3182
|
assert( 0==rc ); |
|
3183
|
assert( zNewUuid ); |
|
3184
|
CX("{\"uuid\": %!j, \"attachedCount\": %d, " |
|
3185
|
"\"statusModified\": %d, " |
|
3186
|
"\"dryrun\": %s, \"iPostFlags\":%d}\n", |
|
3187
|
zNewUuid, nAttach, |
|
3188
|
bStatusSet, bRollback ? "true" : "false", iPostFlags); |
|
3189
|
|
|
3190
|
ajax_save_end: |
|
3191
|
manifest_destroy(pPost); |
|
3192
|
fossil_free(zNewUuid); |
|
3193
|
db_end_transaction(rc || bRollback); |
|
3194
|
} |
|
3195
|
|