Fossil SCM

Add the skeleton for ajax-friendly forumpost saving. Restructure the draft post object to more easily support additional persistent fields. Fix a bad call to rollback when attachment saving fails due to a bad captcha.

stephan 2026-06-07 08:58 UTC forum-editor-2026
Commit 2ee0f61f8c26d515aaeba85bb145e8afc9baff5ce7fbb5fa104ea027a458247e
+8
--- src/ajax.c
+++ src/ajax.c
@@ -209,14 +209,22 @@
209209
}
210210
211211
void ajax_route_error_forbidden(){
212212
ajax_route_error(403, "Permission denied.");
213213
}
214
+
215
+void ajax_route_error_captcha(){
216
+ ajax_route_error(400, "Invalid captcha response.");
217
+}
214218
215219
void ajax_route_error_csrf(){
216220
ajax_route_error(403, "Invalid CSRF signature.");
217221
}
222
+
223
+void ajax_route_error_404(const char *zMsg){
224
+ ajax_route_error(404, "%s", zMsg ? zMsg : "Resource not found.");
225
+}
218226
219227
int ajax_check_csrf(int level){
220228
if( 0==cgi_csrf_safe(level) ){
221229
ajax_route_error_csrf();
222230
return 0;
223231
--- src/ajax.c
+++ src/ajax.c
@@ -209,14 +209,22 @@
209 }
210
211 void ajax_route_error_forbidden(){
212 ajax_route_error(403, "Permission denied.");
213 }
 
 
 
 
214
215 void ajax_route_error_csrf(){
216 ajax_route_error(403, "Invalid CSRF signature.");
217 }
 
 
 
 
218
219 int ajax_check_csrf(int level){
220 if( 0==cgi_csrf_safe(level) ){
221 ajax_route_error_csrf();
222 return 0;
223
--- src/ajax.c
+++ src/ajax.c
@@ -209,14 +209,22 @@
209 }
210
211 void ajax_route_error_forbidden(){
212 ajax_route_error(403, "Permission denied.");
213 }
214
215 void ajax_route_error_captcha(){
216 ajax_route_error(400, "Invalid captcha response.");
217 }
218
219 void ajax_route_error_csrf(){
220 ajax_route_error(403, "Invalid CSRF signature.");
221 }
222
223 void ajax_route_error_404(const char *zMsg){
224 ajax_route_error(404, "%s", zMsg ? zMsg : "Resource not found.");
225 }
226
227 int ajax_check_csrf(int level){
228 if( 0==cgi_csrf_safe(level) ){
229 ajax_route_error_csrf();
230 return 0;
231
+27 -22
--- src/attach.c
+++ src/attach.c
@@ -766,35 +766,37 @@
766766
**
767767
** target=ATTACHMENT_TARGET
768768
** file1..fileN=FILE_OBJECTS
769769
** dryrun=0|1
770770
**
771
-** Each posted file in the set file1..fileN gets attached to the
772
-** given target, permissions permitting. If dryrun>0 then the change
773
-** is rolled back instead of committed. target=X must refer to a full
774
-** target ID, not a prefix.
771
+** Each posted file in the set file1..fileN gets attached to the given
772
+** target, permissions permitting. If dryrun>0 then the change is
773
+** rolled back instead of committed. target=X must refer to a full
774
+** target ID, not a prefix.
775775
**
776
-** Responds with JSON: an empty object on success and
777
-** {error:"message"} on error. The on-success response structure is
778
-** subject to amendment.
776
+** Responds with JSON: an empty object on success and
777
+** {error:"message"} on error. The on-success response structure is
778
+** subject to amendment.
779779
*/
780780
void attachadd_ajax_post(void){
781781
const char *zTarget;
782782
char *zExtraFree = 0;
783783
int eTgtType = 0;
784784
int bNeedsModeration = 0;
785785
int goodCaptcha = 1;
786786
int bRollback = 0; /* Roll back if true. */
787
+ int bInTransaction = 0;
787788
788789
if( ! ajax_route_bootstrap(0, 1) ){
789790
return;
790791
}else if( !(goodCaptcha = captcha_is_correct(0)) ){
791
- goto ajax_post_403;
792
+ goto ajax_err_403;
792793
}else if( !ajax_check_csrf(2) ){
793794
return;
794795
}
795796
db_begin_transaction();
797
+ bInTransaction = 1;
796798
zTarget = P("target");
797799
eTgtType = attachment_target_type(zTarget, 1);
798800
CX("{");
799801
switch( eTgtType ){
800802
default:
@@ -803,15 +805,15 @@
803805
db_rollback_transaction();
804806
return;
805807
case CFTYPE_FORUM:{
806808
int fpid;
807809
if( g.perm.AttachForum==0 ){
808
- goto ajax_post_403;
810
+ goto ajax_err_403;
809811
}
810812
fpid = forumpost_head_rid2(zTarget);
811813
if( fpid<=0 ){
812
- goto ajax_post_404;
814
+ goto ajax_err_404;
813815
}else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
814816
ajax_route_error(403, "Only admins can attach files to "
815817
"other users' forum posts.");
816818
db_rollback_transaction();
817819
return;
@@ -820,66 +822,69 @@
820822
bNeedsModeration = forum_need_moderation();
821823
break;
822824
}
823825
case CFTYPE_EVENT:{
824826
if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
825
- goto ajax_post_403;
827
+ goto ajax_err_403;
826828
}
827829
if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
828830
zTarget) ){
829831
zTarget = zExtraFree =
830832
db_text(0, "SELECT substr(tagname,7) FROM tag"
831833
" WHERE tagname GLOB 'event-%q*'", zTarget);
832834
if( zTarget==0){
833
- goto ajax_post_404;
835
+ goto ajax_err_404;
834836
}
835837
}
836838
bNeedsModeration = 0;
837839
break;
838840
}
839841
case CFTYPE_TICKET:{
840842
if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
841
- goto ajax_post_403;
843
+ goto ajax_err_403;
842844
}
843845
if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
844846
zTarget) ){
845847
zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
846848
" WHERE tagname GLOB 'tkt-%q*'", zTarget);
847849
if( zTarget==0 ){
848
- goto ajax_post_404;
850
+ goto ajax_err_404;
849851
}
850852
}
851853
bNeedsModeration = ticket_need_moderation(0);
852854
break;
853855
}
854856
case CFTYPE_WIKI:{
855857
if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
856
- goto ajax_post_403;
858
+ goto ajax_err_403;
857859
}
858860
if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
859861
zTarget) ){
860
- goto ajax_post_404;
862
+ goto ajax_err_404;
861863
}
862864
bNeedsModeration = wiki_need_moderation(0);
863865
break;
864866
}
865867
}
866868
867
- if( attachments_from_POST_ajax(zTarget, bNeedsModeration)>=0 ){
869
+ if( attachments_ajax_from_POST(zTarget, bNeedsModeration)>=0 ){
868870
CX("}");
869871
if( atoi(PD("dryrun","0"))>0 ){
870872
bRollback = 1;
871873
}
872874
}/*else error response was set up*/
873875
fossil_free(zExtraFree);
874876
db_end_transaction(bRollback);
875877
return;
876
-ajax_post_403:
877
- db_rollback_transaction();
878
+ajax_err_403:
879
+ if( bInTransaction ){
880
+ db_rollback_transaction();
881
+ }
878882
ajax_route_error_forbidden();
879883
return;
880
-ajax_post_404:
884
+ajax_err_404:
885
+ assert( bInTransaction );
881886
db_rollback_transaction();
882887
ajax_route_error(404, "Target not found.");
883888
return;
884889
}
885890
@@ -902,11 +907,11 @@
902907
**
903908
** If this returns a negative value, it will have populated an error
904909
** response using ajax_route_error(). On success it produces no
905910
** output.
906911
*/
907
-int attachments_from_POST_ajax(const char *zTarget, int bNeedsModeration){
912
+int attachments_ajax_from_POST(const char *zTarget, int bNeedsModeration){
908913
int i;
909914
int rc = 0;
910915
int n = 0;
911916
int szLimit; /* attachment-max-size setting */
912917
char aKeyPrefix[20]; /* Buffer for key "file%d" */
@@ -933,11 +938,11 @@
933938
rc = -1;
934939
ajax_route_error(400,"Invalid file size: %d", szContent);
935940
break;
936941
}else if( szLimit>0 && szContent>szLimit ){
937942
rc = -2;
938
- ajax_route_error(400, "File size limit is %d bytes.", szLimit);
943
+ ajax_route_error(413, "File size limit is %d bytes.", szLimit);
939944
break;
940945
}else{
941946
sqlite3_snprintf(sizeof(aKeyName), aKeyName, "%s:filename",
942947
aKeyPrefix);
943948
sqlite3_snprintf(sizeof(aKeyDesc), aKeyDesc, "%s_desc",
944949
--- src/attach.c
+++ src/attach.c
@@ -766,35 +766,37 @@
766 **
767 ** target=ATTACHMENT_TARGET
768 ** file1..fileN=FILE_OBJECTS
769 ** dryrun=0|1
770 **
771 ** Each posted file in the set file1..fileN gets attached to the
772 ** given target, permissions permitting. If dryrun>0 then the change
773 ** is rolled back instead of committed. target=X must refer to a full
774 ** target ID, not a prefix.
775 **
776 ** Responds with JSON: an empty object on success and
777 ** {error:"message"} on error. The on-success response structure is
778 ** subject to amendment.
779 */
780 void attachadd_ajax_post(void){
781 const char *zTarget;
782 char *zExtraFree = 0;
783 int eTgtType = 0;
784 int bNeedsModeration = 0;
785 int goodCaptcha = 1;
786 int bRollback = 0; /* Roll back if true. */
 
787
788 if( ! ajax_route_bootstrap(0, 1) ){
789 return;
790 }else if( !(goodCaptcha = captcha_is_correct(0)) ){
791 goto ajax_post_403;
792 }else if( !ajax_check_csrf(2) ){
793 return;
794 }
795 db_begin_transaction();
 
796 zTarget = P("target");
797 eTgtType = attachment_target_type(zTarget, 1);
798 CX("{");
799 switch( eTgtType ){
800 default:
@@ -803,15 +805,15 @@
803 db_rollback_transaction();
804 return;
805 case CFTYPE_FORUM:{
806 int fpid;
807 if( g.perm.AttachForum==0 ){
808 goto ajax_post_403;
809 }
810 fpid = forumpost_head_rid2(zTarget);
811 if( fpid<=0 ){
812 goto ajax_post_404;
813 }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
814 ajax_route_error(403, "Only admins can attach files to "
815 "other users' forum posts.");
816 db_rollback_transaction();
817 return;
@@ -820,66 +822,69 @@
820 bNeedsModeration = forum_need_moderation();
821 break;
822 }
823 case CFTYPE_EVENT:{
824 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
825 goto ajax_post_403;
826 }
827 if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
828 zTarget) ){
829 zTarget = zExtraFree =
830 db_text(0, "SELECT substr(tagname,7) FROM tag"
831 " WHERE tagname GLOB 'event-%q*'", zTarget);
832 if( zTarget==0){
833 goto ajax_post_404;
834 }
835 }
836 bNeedsModeration = 0;
837 break;
838 }
839 case CFTYPE_TICKET:{
840 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
841 goto ajax_post_403;
842 }
843 if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
844 zTarget) ){
845 zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
846 " WHERE tagname GLOB 'tkt-%q*'", zTarget);
847 if( zTarget==0 ){
848 goto ajax_post_404;
849 }
850 }
851 bNeedsModeration = ticket_need_moderation(0);
852 break;
853 }
854 case CFTYPE_WIKI:{
855 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
856 goto ajax_post_403;
857 }
858 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
859 zTarget) ){
860 goto ajax_post_404;
861 }
862 bNeedsModeration = wiki_need_moderation(0);
863 break;
864 }
865 }
866
867 if( attachments_from_POST_ajax(zTarget, bNeedsModeration)>=0 ){
868 CX("}");
869 if( atoi(PD("dryrun","0"))>0 ){
870 bRollback = 1;
871 }
872 }/*else error response was set up*/
873 fossil_free(zExtraFree);
874 db_end_transaction(bRollback);
875 return;
876 ajax_post_403:
877 db_rollback_transaction();
 
 
878 ajax_route_error_forbidden();
879 return;
880 ajax_post_404:
 
881 db_rollback_transaction();
882 ajax_route_error(404, "Target not found.");
883 return;
884 }
885
@@ -902,11 +907,11 @@
902 **
903 ** If this returns a negative value, it will have populated an error
904 ** response using ajax_route_error(). On success it produces no
905 ** output.
906 */
907 int attachments_from_POST_ajax(const char *zTarget, int bNeedsModeration){
908 int i;
909 int rc = 0;
910 int n = 0;
911 int szLimit; /* attachment-max-size setting */
912 char aKeyPrefix[20]; /* Buffer for key "file%d" */
@@ -933,11 +938,11 @@
933 rc = -1;
934 ajax_route_error(400,"Invalid file size: %d", szContent);
935 break;
936 }else if( szLimit>0 && szContent>szLimit ){
937 rc = -2;
938 ajax_route_error(400, "File size limit is %d bytes.", szLimit);
939 break;
940 }else{
941 sqlite3_snprintf(sizeof(aKeyName), aKeyName, "%s:filename",
942 aKeyPrefix);
943 sqlite3_snprintf(sizeof(aKeyDesc), aKeyDesc, "%s_desc",
944
--- src/attach.c
+++ src/attach.c
@@ -766,35 +766,37 @@
766 **
767 ** target=ATTACHMENT_TARGET
768 ** file1..fileN=FILE_OBJECTS
769 ** dryrun=0|1
770 **
771 ** Each posted file in the set file1..fileN gets attached to the given
772 ** target, permissions permitting. If dryrun>0 then the change is
773 ** rolled back instead of committed. target=X must refer to a full
774 ** target ID, not a prefix.
775 **
776 ** Responds with JSON: an empty object on success and
777 ** {error:"message"} on error. The on-success response structure is
778 ** subject to amendment.
779 */
780 void attachadd_ajax_post(void){
781 const char *zTarget;
782 char *zExtraFree = 0;
783 int eTgtType = 0;
784 int bNeedsModeration = 0;
785 int goodCaptcha = 1;
786 int bRollback = 0; /* Roll back if true. */
787 int bInTransaction = 0;
788
789 if( ! ajax_route_bootstrap(0, 1) ){
790 return;
791 }else if( !(goodCaptcha = captcha_is_correct(0)) ){
792 goto ajax_err_403;
793 }else if( !ajax_check_csrf(2) ){
794 return;
795 }
796 db_begin_transaction();
797 bInTransaction = 1;
798 zTarget = P("target");
799 eTgtType = attachment_target_type(zTarget, 1);
800 CX("{");
801 switch( eTgtType ){
802 default:
@@ -803,15 +805,15 @@
805 db_rollback_transaction();
806 return;
807 case CFTYPE_FORUM:{
808 int fpid;
809 if( g.perm.AttachForum==0 ){
810 goto ajax_err_403;
811 }
812 fpid = forumpost_head_rid2(zTarget);
813 if( fpid<=0 ){
814 goto ajax_err_404;
815 }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){
816 ajax_route_error(403, "Only admins can attach files to "
817 "other users' forum posts.");
818 db_rollback_transaction();
819 return;
@@ -820,66 +822,69 @@
822 bNeedsModeration = forum_need_moderation();
823 break;
824 }
825 case CFTYPE_EVENT:{
826 if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){
827 goto ajax_err_403;
828 }
829 if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'",
830 zTarget) ){
831 zTarget = zExtraFree =
832 db_text(0, "SELECT substr(tagname,7) FROM tag"
833 " WHERE tagname GLOB 'event-%q*'", zTarget);
834 if( zTarget==0){
835 goto ajax_err_404;
836 }
837 }
838 bNeedsModeration = 0;
839 break;
840 }
841 case CFTYPE_TICKET:{
842 if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){
843 goto ajax_err_403;
844 }
845 if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'",
846 zTarget) ){
847 zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag"
848 " WHERE tagname GLOB 'tkt-%q*'", zTarget);
849 if( zTarget==0 ){
850 goto ajax_err_404;
851 }
852 }
853 bNeedsModeration = ticket_need_moderation(0);
854 break;
855 }
856 case CFTYPE_WIKI:{
857 if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){
858 goto ajax_err_403;
859 }
860 if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'",
861 zTarget) ){
862 goto ajax_err_404;
863 }
864 bNeedsModeration = wiki_need_moderation(0);
865 break;
866 }
867 }
868
869 if( attachments_ajax_from_POST(zTarget, bNeedsModeration)>=0 ){
870 CX("}");
871 if( atoi(PD("dryrun","0"))>0 ){
872 bRollback = 1;
873 }
874 }/*else error response was set up*/
875 fossil_free(zExtraFree);
876 db_end_transaction(bRollback);
877 return;
878 ajax_err_403:
879 if( bInTransaction ){
880 db_rollback_transaction();
881 }
882 ajax_route_error_forbidden();
883 return;
884 ajax_err_404:
885 assert( bInTransaction );
886 db_rollback_transaction();
887 ajax_route_error(404, "Target not found.");
888 return;
889 }
890
@@ -902,11 +907,11 @@
907 **
908 ** If this returns a negative value, it will have populated an error
909 ** response using ajax_route_error(). On success it produces no
910 ** output.
911 */
912 int attachments_ajax_from_POST(const char *zTarget, int bNeedsModeration){
913 int i;
914 int rc = 0;
915 int n = 0;
916 int szLimit; /* attachment-max-size setting */
917 char aKeyPrefix[20]; /* Buffer for key "file%d" */
@@ -933,11 +938,11 @@
938 rc = -1;
939 ajax_route_error(400,"Invalid file size: %d", szContent);
940 break;
941 }else if( szLimit>0 && szContent>szLimit ){
942 rc = -2;
943 ajax_route_error(413, "File size limit is %d bytes.", szLimit);
944 break;
945 }else{
946 sqlite3_snprintf(sizeof(aKeyName), aKeyName, "%s:filename",
947 aKeyPrefix);
948 sqlite3_snprintf(sizeof(aKeyDesc), aKeyDesc, "%s_desc",
949
+67 -1
--- src/forum.c
+++ src/forum.c
@@ -2053,11 +2053,11 @@
20532053
const char *zMimetype = 0;
20542054
const char *zContent = 0;
20552055
const char *zTitle = 0;
20562056
char *zDate = 0;
20572057
const char *zFpid = PD("fpid","");
2058
- const int bLegacy = PB("legacy"); /* True for legacy HTML form */
2058
+ const int bLegacy = 1 ? 1 : PB("legacy"); /* True for legacy HTML form */
20592059
int isCsrfSafe;
20602060
int isDelete = 0;
20612061
int iClosed = 0;
20622062
int bSameUser; /* True if author is also the reader */
20632063
int bPreview; /* True in preview mode. */
@@ -2787,5 +2787,71 @@
27872787
** URL arg when the status selection list is activated. */
27882788
forum_emit_js();
27892789
}
27902790
style_finish_page();
27912791
}
2792
+
2793
+/*
2794
+** WEBPAGE: forumajax_save hidden
2795
+**
2796
+** WIP
2797
+**
2798
+** Response JSON:
2799
+**
2800
+** { uuid: hash, ...tbd }
2801
+*/
2802
+void forum_ajax_save(void){
2803
+ const char *zUuid;
2804
+ const char *zTitle;
2805
+ const char *zIrt;
2806
+ const char *zMimetype;
2807
+ const char *zContent;
2808
+ const char *zStatus;
2809
+ const int bHasAttachment = P("file1")!=0;
2810
+ int bNeedsModeration = 0;
2811
+ int goodCaptcha = 1;
2812
+ int bRollback = 0;
2813
+
2814
+ if( !ajax_route_bootstrap(0, 1) ){
2815
+ return;
2816
+ }else if( !g.perm.WrForum
2817
+ || (bHasAttachment && !g.perm.AttachForum) ){
2818
+ ajax_route_error_forbidden();
2819
+ return;
2820
+ }else if( !ajax_check_csrf(2) ){
2821
+ ajax_route_error_csrf();
2822
+ return;
2823
+ }else if( 0==(goodCaptcha = captcha_is_correct(0)) ){
2824
+ ajax_route_error_captcha();
2825
+ return;
2826
+ }
2827
+ db_begin_transaction();
2828
+ /*
2829
+ ** TODOs include:
2830
+ **
2831
+ ** - Permissions and sanity checks, of course.
2832
+ **
2833
+ ** - Fork forum_post() into an AJAX-friendly form. It currently
2834
+ ** assumes HTML output.
2835
+ **
2836
+ ** - If zUuid then this is an edit. Else...
2837
+ **
2838
+ ** - If zIrt then this is a new response.
2839
+ **
2840
+ ** - zTitle is only honored if !zIrt, i.e. zUuid is the root post.
2841
+ **
2842
+ ** - attachments_ajax_from_POST()
2843
+ **
2844
+ ** - Allow status change only if permissions allow.
2845
+ */
2846
+
2847
+ (void)bNeedsModeration;
2848
+ (void)zUuid;
2849
+ (void)zTitle;
2850
+ (void)zIrt;
2851
+ (void)zMimetype;
2852
+ (void)zContent;
2853
+ (void)zStatus;
2854
+
2855
+ ajax_route_error(400, "Save is TODO");
2856
+ db_end_transaction(bRollback);
2857
+}
27922858
--- src/forum.c
+++ src/forum.c
@@ -2053,11 +2053,11 @@
2053 const char *zMimetype = 0;
2054 const char *zContent = 0;
2055 const char *zTitle = 0;
2056 char *zDate = 0;
2057 const char *zFpid = PD("fpid","");
2058 const int bLegacy = PB("legacy"); /* True for legacy HTML form */
2059 int isCsrfSafe;
2060 int isDelete = 0;
2061 int iClosed = 0;
2062 int bSameUser; /* True if author is also the reader */
2063 int bPreview; /* True in preview mode. */
@@ -2787,5 +2787,71 @@
2787 ** URL arg when the status selection list is activated. */
2788 forum_emit_js();
2789 }
2790 style_finish_page();
2791 }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2792
--- src/forum.c
+++ src/forum.c
@@ -2053,11 +2053,11 @@
2053 const char *zMimetype = 0;
2054 const char *zContent = 0;
2055 const char *zTitle = 0;
2056 char *zDate = 0;
2057 const char *zFpid = PD("fpid","");
2058 const int bLegacy = 1 ? 1 : PB("legacy"); /* True for legacy HTML form */
2059 int isCsrfSafe;
2060 int isDelete = 0;
2061 int iClosed = 0;
2062 int bSameUser; /* True if author is also the reader */
2063 int bPreview; /* True in preview mode. */
@@ -2787,5 +2787,71 @@
2787 ** URL arg when the status selection list is activated. */
2788 forum_emit_js();
2789 }
2790 style_finish_page();
2791 }
2792
2793 /*
2794 ** WEBPAGE: forumajax_save hidden
2795 **
2796 ** WIP
2797 **
2798 ** Response JSON:
2799 **
2800 ** { uuid: hash, ...tbd }
2801 */
2802 void forum_ajax_save(void){
2803 const char *zUuid;
2804 const char *zTitle;
2805 const char *zIrt;
2806 const char *zMimetype;
2807 const char *zContent;
2808 const char *zStatus;
2809 const int bHasAttachment = P("file1")!=0;
2810 int bNeedsModeration = 0;
2811 int goodCaptcha = 1;
2812 int bRollback = 0;
2813
2814 if( !ajax_route_bootstrap(0, 1) ){
2815 return;
2816 }else if( !g.perm.WrForum
2817 || (bHasAttachment && !g.perm.AttachForum) ){
2818 ajax_route_error_forbidden();
2819 return;
2820 }else if( !ajax_check_csrf(2) ){
2821 ajax_route_error_csrf();
2822 return;
2823 }else if( 0==(goodCaptcha = captcha_is_correct(0)) ){
2824 ajax_route_error_captcha();
2825 return;
2826 }
2827 db_begin_transaction();
2828 /*
2829 ** TODOs include:
2830 **
2831 ** - Permissions and sanity checks, of course.
2832 **
2833 ** - Fork forum_post() into an AJAX-friendly form. It currently
2834 ** assumes HTML output.
2835 **
2836 ** - If zUuid then this is an edit. Else...
2837 **
2838 ** - If zIrt then this is a new response.
2839 **
2840 ** - zTitle is only honored if !zIrt, i.e. zUuid is the root post.
2841 **
2842 ** - attachments_ajax_from_POST()
2843 **
2844 ** - Allow status change only if permissions allow.
2845 */
2846
2847 (void)bNeedsModeration;
2848 (void)zUuid;
2849 (void)zTitle;
2850 (void)zIrt;
2851 (void)zMimetype;
2852 (void)zContent;
2853 (void)zStatus;
2854
2855 ajax_route_error(400, "Save is TODO");
2856 db_end_transaction(bRollback);
2857 }
2858
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -28,10 +28,12 @@
2828
/* DOM element of the current active tab. */
2929
#activeTab;
3030
/* Extra input[type=hidden] fields imported from fossil's
3131
static page generation. */
3232
#extraFields;
33
+ /* Persistent draft message object. */
34
+ #draft;
3335
3436
/**
3537
Options:
3638
3739
opt.draftKey[string=undefined]: if set then this object's state
@@ -45,11 +47,18 @@
4547
// replyTo: hash
4648
// edit: hash
4749
draftKey: undefined
4850
}, opt);
4951
opt.isNewThread = !opt.replyTo && !opt.edit;
50
- if( !opt.draftKey) opt.draftKey = '';
52
+ if( opt.draftKey ){
53
+ this.#draft = F.storage.getJSON(opt.draftKey, F.nu({
54
+ title: undefined,
55
+ content: undefined,
56
+ mimetype: undefined,
57
+ status: undefined
58
+ }));
59
+ }
5160
const e = this.#e = F.nu({
5261
mimetype: F.nu(),
5362
button: F.nu()
5463
});
5564
const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
@@ -62,16 +71,16 @@
6271
e.title.setAttribute('maxlength', 125);
6372
e.titleBar.append(
6473
D.append(D.span(), "Title:"),
6574
e.title
6675
);
67
- if( opt.draftKey ){
68
- const key = opt.draftKey+'.title';
76
+ if( this.#draft ){
6977
e.title.addEventListener('blur', ()=>{
70
- F.storage.set(key, e.title.value)
78
+ this.#draft.title = e.title.value;
79
+ this.#storeDraft();
7180
});
72
- e.title.value = opt.title || F.storage.get(key, '');
81
+ e.title.value = opt.title || this.#draft.title;
7382
}else if( opt.title ){
7483
e.title.value = opt.title;
7584
}
7685
wrapper.append(e.titleBar);
7786
}
@@ -154,10 +163,13 @@
154163
if( e.help.$needsInit ){
155164
delete e.help.$needsInit;
156165
this.#initHelpTab();
157166
}
158167
break;
168
+ case e.tabAttach:
169
+ if( !this.#att ) this.#initAttacherTab();
170
+ break;
159171
}
160172
});
161173
wrapper.append( e.tabs );
162174
163175
e.tabEdit = D.div();
@@ -169,15 +181,17 @@
169181
);
170182
e.tabEdit.append(e.editor);
171183
e.tabEdit.dataset.tabLabel = 'Edit';
172184
this.#tabs.addTab( e.tabEdit );
173185
this.#tabs.switchToTab( e.tabEdit );
174
- if( opt.draftKey ){
175
- const key = opt.draftKey+'.content';
176
- this.editorContent = F.storage.get(key,'');
186
+ if( this.#draft ){
187
+ this.editorContent = this.#draft.content;
177188
e.editor.addEventListener(
178
- 'blur', ()=>F.storage.set(key, this.editorContent)
189
+ 'blur', ()=>{
190
+ this.#draft.content = this.editorContent;
191
+ this.#storeDraft();
192
+ }
179193
);
180194
}
181195
e.preview = D.addClass(D.div(), 'preview');
182196
e.preview.dataset.tabLabel = 'Preview';
183197
this.#tabs.addTab( e.preview );
@@ -210,10 +224,11 @@
210224
added to it.
211225
*/
212226
if( F.config.forumStatuses?.length>0 ){
213227
const sel = e.status = D.select();
214228
D.option(sel, "", "- Status -").disabled = true;
229
+ sel.dataset.originalValue = opt.status;
215230
for( const status of F.config.forumStatuses ){
216231
D.option(sel, status.value, status.label);
217232
}
218233
e.buttons.append(sel);
219234
if( opt.status ){
@@ -221,20 +236,17 @@
221236
}
222237
}
223238
}
224239
225240
if( F.user.mayAttachForum ){
226
- this.#att = new F.Attacher({
227
- reverse: true
228
- });
229241
//e.buttons.append( e.button.addAttach = this.#att.takeAddButton() );
230
- e.tabAttach = D.append(D.div(), this.#att.widget);
242
+ e.tabAttach = D.div();
231243
e.tabAttach.setAttribute('id', idPrefix+'-attach');
232244
e.tabAttach.dataset.tabLabel = 'Attachments';
233245
this.#tabs.addTab(e.tabAttach);
234246
/* Reminder: we don't currently have a way to disable/enable
235
- an Attacher's controls. */
247
+ an Attacher's controls during ajax traffic. */
236248
}
237249
e.buttons.append(e.button.preview, e.button.submit);
238250
this.#toDisable.push(e.button.preview);
239251
240252
e.help = D.attr(D.div(), 'id', idPrefix+'-help');
@@ -329,20 +341,16 @@
329341
330342
set editorContent(v){
331343
this.#e.editor.value = v;
332344
}
333345
334
- get status(){
335
- return this.#e.status?.value;
336
- }
337
-
338
- /** Clears any draft state. */
339
- clearDraft(){
340
- const k = this.#opt.draftKey;
341
- if( k ){
342
- F.storage.remove(k+'.content');
343
- F.storage.remove(k+'.title');
346
+ /** Clears any persistent draft state. Does not clear the UI
347
+ widgets. */
348
+ #clearDraft(){
349
+ if( this.#draft ){
350
+ F.storage.remove(this.#opt.draftKey);
351
+ this.#draft = F.nu();
344352
}
345353
}
346354
347355
/**
348356
Reports an error by appending each argument to the error widget
@@ -395,10 +403,17 @@
395403
D.attr(D.a(F.repoUrl('markup_help'), 'Markup styles'),
396404
'target', '_new')
397405
);
398406
eh.append(list);
399407
}
408
+
409
+ #initAttacherTab(){
410
+ this.#att = new F.Attacher({
411
+ reverse: true
412
+ });
413
+ this.#e.tabAttach.append(this.#att.widget);
414
+ }
400415
401416
#newFormData(addThisContent){
402417
const fd = new FormData;
403418
for(const f of this.#extraFields){
404419
fd.append(f.name, f.value);
@@ -451,10 +466,11 @@
451466
return;
452467
}
453468
this.#isWaiting = true;
454469
D.clearElement(e.preview);
455470
const content = this.editorContent.trim();
471
+ //console.debug("content to preview", content);
456472
if( !content ){
457473
return;
458474
}
459475
D.disable(this.#toDisable, e.button.submit);
460476
e.preview.textContent = "Fetching preview...";
@@ -493,28 +509,48 @@
493509
if( this.#isWaiting ) return;
494510
if( !this.#validate() ) return;
495511
this.#isWaiting = true;
496512
const e = this.#e;
497513
D.disable(e.button.submit);
498
- this.reportError("Submit is TODO.");
499514
const fd = this.#newFormData();
500
- this.#att.populateFormData(fd);
515
+ if( this.#att ){
516
+ this.#att.populateFormData(fd);
517
+ }
501518
if( this.#e.status ){
502
- fd.append( "status", this.status );
519
+ /* Send the status only if it was modified, otherwise we may
520
+ add a superfluous tag. */
521
+ const v = this.#e.status.value;
522
+ if( this.#e.status.dataset.originalValue !== v ){
523
+ fd.append( "status", v );
524
+ }
503525
}
504526
console.warn("Ready to submit",fd);
505527
/*
506528
TODO: save it, set #isWaiting=false, then handle error or
507529
redirect to the post (if this is a new post) or, if replying
508530
inline, replace this object with a static rendering from the
509531
response.
510532
*/
511
- if( 0 && this.#opt.draftKey ){
512
- F.storage.remove(this.#opt.draftKey+'.content');
513
- F.storage.remove(this.#opt.draftKey+'.title');
533
+ const resp = window.fetch(F.repoUrl('forumajax_save'), {
534
+ method: 'POST',
535
+ body: fd
536
+ }).then(r=>r.json())
537
+ .then(j=>{
538
+ if( j.error ){
539
+ throw new Error(j.error);
540
+ }
541
+ this.#clearDraft();
542
+ window.location = F.repoUrl('forumpost/'+j.uuid);
543
+ })
544
+ .catch((e)=>this.reportError(e.message))
545
+ .finally(()=>this.#isWaiting = false);
546
+ }
547
+
548
+ #storeDraft(){
549
+ if( this.#draft ){
550
+ F.storage.setJSON(this.#opt.draftKey, this.#draft);
514551
}
515
- this.#isWaiting = false;
516552
}
517553
518554
async #fetchPost(){
519555
/*
520556
TODO: when editing an existing post, fetch the raw body of the
521557
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -28,10 +28,12 @@
28 /* DOM element of the current active tab. */
29 #activeTab;
30 /* Extra input[type=hidden] fields imported from fossil's
31 static page generation. */
32 #extraFields;
 
 
33
34 /**
35 Options:
36
37 opt.draftKey[string=undefined]: if set then this object's state
@@ -45,11 +47,18 @@
45 // replyTo: hash
46 // edit: hash
47 draftKey: undefined
48 }, opt);
49 opt.isNewThread = !opt.replyTo && !opt.edit;
50 if( !opt.draftKey) opt.draftKey = '';
 
 
 
 
 
 
 
51 const e = this.#e = F.nu({
52 mimetype: F.nu(),
53 button: F.nu()
54 });
55 const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
@@ -62,16 +71,16 @@
62 e.title.setAttribute('maxlength', 125);
63 e.titleBar.append(
64 D.append(D.span(), "Title:"),
65 e.title
66 );
67 if( opt.draftKey ){
68 const key = opt.draftKey+'.title';
69 e.title.addEventListener('blur', ()=>{
70 F.storage.set(key, e.title.value)
 
71 });
72 e.title.value = opt.title || F.storage.get(key, '');
73 }else if( opt.title ){
74 e.title.value = opt.title;
75 }
76 wrapper.append(e.titleBar);
77 }
@@ -154,10 +163,13 @@
154 if( e.help.$needsInit ){
155 delete e.help.$needsInit;
156 this.#initHelpTab();
157 }
158 break;
 
 
 
159 }
160 });
161 wrapper.append( e.tabs );
162
163 e.tabEdit = D.div();
@@ -169,15 +181,17 @@
169 );
170 e.tabEdit.append(e.editor);
171 e.tabEdit.dataset.tabLabel = 'Edit';
172 this.#tabs.addTab( e.tabEdit );
173 this.#tabs.switchToTab( e.tabEdit );
174 if( opt.draftKey ){
175 const key = opt.draftKey+'.content';
176 this.editorContent = F.storage.get(key,'');
177 e.editor.addEventListener(
178 'blur', ()=>F.storage.set(key, this.editorContent)
 
 
 
179 );
180 }
181 e.preview = D.addClass(D.div(), 'preview');
182 e.preview.dataset.tabLabel = 'Preview';
183 this.#tabs.addTab( e.preview );
@@ -210,10 +224,11 @@
210 added to it.
211 */
212 if( F.config.forumStatuses?.length>0 ){
213 const sel = e.status = D.select();
214 D.option(sel, "", "- Status -").disabled = true;
 
215 for( const status of F.config.forumStatuses ){
216 D.option(sel, status.value, status.label);
217 }
218 e.buttons.append(sel);
219 if( opt.status ){
@@ -221,20 +236,17 @@
221 }
222 }
223 }
224
225 if( F.user.mayAttachForum ){
226 this.#att = new F.Attacher({
227 reverse: true
228 });
229 //e.buttons.append( e.button.addAttach = this.#att.takeAddButton() );
230 e.tabAttach = D.append(D.div(), this.#att.widget);
231 e.tabAttach.setAttribute('id', idPrefix+'-attach');
232 e.tabAttach.dataset.tabLabel = 'Attachments';
233 this.#tabs.addTab(e.tabAttach);
234 /* Reminder: we don't currently have a way to disable/enable
235 an Attacher's controls. */
236 }
237 e.buttons.append(e.button.preview, e.button.submit);
238 this.#toDisable.push(e.button.preview);
239
240 e.help = D.attr(D.div(), 'id', idPrefix+'-help');
@@ -329,20 +341,16 @@
329
330 set editorContent(v){
331 this.#e.editor.value = v;
332 }
333
334 get status(){
335 return this.#e.status?.value;
336 }
337
338 /** Clears any draft state. */
339 clearDraft(){
340 const k = this.#opt.draftKey;
341 if( k ){
342 F.storage.remove(k+'.content');
343 F.storage.remove(k+'.title');
344 }
345 }
346
347 /**
348 Reports an error by appending each argument to the error widget
@@ -395,10 +403,17 @@
395 D.attr(D.a(F.repoUrl('markup_help'), 'Markup styles'),
396 'target', '_new')
397 );
398 eh.append(list);
399 }
 
 
 
 
 
 
 
400
401 #newFormData(addThisContent){
402 const fd = new FormData;
403 for(const f of this.#extraFields){
404 fd.append(f.name, f.value);
@@ -451,10 +466,11 @@
451 return;
452 }
453 this.#isWaiting = true;
454 D.clearElement(e.preview);
455 const content = this.editorContent.trim();
 
456 if( !content ){
457 return;
458 }
459 D.disable(this.#toDisable, e.button.submit);
460 e.preview.textContent = "Fetching preview...";
@@ -493,28 +509,48 @@
493 if( this.#isWaiting ) return;
494 if( !this.#validate() ) return;
495 this.#isWaiting = true;
496 const e = this.#e;
497 D.disable(e.button.submit);
498 this.reportError("Submit is TODO.");
499 const fd = this.#newFormData();
500 this.#att.populateFormData(fd);
 
 
501 if( this.#e.status ){
502 fd.append( "status", this.status );
 
 
 
 
 
503 }
504 console.warn("Ready to submit",fd);
505 /*
506 TODO: save it, set #isWaiting=false, then handle error or
507 redirect to the post (if this is a new post) or, if replying
508 inline, replace this object with a static rendering from the
509 response.
510 */
511 if( 0 && this.#opt.draftKey ){
512 F.storage.remove(this.#opt.draftKey+'.content');
513 F.storage.remove(this.#opt.draftKey+'.title');
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514 }
515 this.#isWaiting = false;
516 }
517
518 async #fetchPost(){
519 /*
520 TODO: when editing an existing post, fetch the raw body of the
521
--- src/fossil.page.forumpost.js
+++ src/fossil.page.forumpost.js
@@ -28,10 +28,12 @@
28 /* DOM element of the current active tab. */
29 #activeTab;
30 /* Extra input[type=hidden] fields imported from fossil's
31 static page generation. */
32 #extraFields;
33 /* Persistent draft message object. */
34 #draft;
35
36 /**
37 Options:
38
39 opt.draftKey[string=undefined]: if set then this object's state
@@ -45,11 +47,18 @@
47 // replyTo: hash
48 // edit: hash
49 draftKey: undefined
50 }, opt);
51 opt.isNewThread = !opt.replyTo && !opt.edit;
52 if( opt.draftKey ){
53 this.#draft = F.storage.getJSON(opt.draftKey, F.nu({
54 title: undefined,
55 content: undefined,
56 mimetype: undefined,
57 status: undefined
58 }));
59 }
60 const e = this.#e = F.nu({
61 mimetype: F.nu(),
62 button: F.nu()
63 });
64 const wrapper = e.widget = D.addClass(D.div(), 'ForumPostEditor');
@@ -62,16 +71,16 @@
71 e.title.setAttribute('maxlength', 125);
72 e.titleBar.append(
73 D.append(D.span(), "Title:"),
74 e.title
75 );
76 if( this.#draft ){
 
77 e.title.addEventListener('blur', ()=>{
78 this.#draft.title = e.title.value;
79 this.#storeDraft();
80 });
81 e.title.value = opt.title || this.#draft.title;
82 }else if( opt.title ){
83 e.title.value = opt.title;
84 }
85 wrapper.append(e.titleBar);
86 }
@@ -154,10 +163,13 @@
163 if( e.help.$needsInit ){
164 delete e.help.$needsInit;
165 this.#initHelpTab();
166 }
167 break;
168 case e.tabAttach:
169 if( !this.#att ) this.#initAttacherTab();
170 break;
171 }
172 });
173 wrapper.append( e.tabs );
174
175 e.tabEdit = D.div();
@@ -169,15 +181,17 @@
181 );
182 e.tabEdit.append(e.editor);
183 e.tabEdit.dataset.tabLabel = 'Edit';
184 this.#tabs.addTab( e.tabEdit );
185 this.#tabs.switchToTab( e.tabEdit );
186 if( this.#draft ){
187 this.editorContent = this.#draft.content;
 
188 e.editor.addEventListener(
189 'blur', ()=>{
190 this.#draft.content = this.editorContent;
191 this.#storeDraft();
192 }
193 );
194 }
195 e.preview = D.addClass(D.div(), 'preview');
196 e.preview.dataset.tabLabel = 'Preview';
197 this.#tabs.addTab( e.preview );
@@ -210,10 +224,11 @@
224 added to it.
225 */
226 if( F.config.forumStatuses?.length>0 ){
227 const sel = e.status = D.select();
228 D.option(sel, "", "- Status -").disabled = true;
229 sel.dataset.originalValue = opt.status;
230 for( const status of F.config.forumStatuses ){
231 D.option(sel, status.value, status.label);
232 }
233 e.buttons.append(sel);
234 if( opt.status ){
@@ -221,20 +236,17 @@
236 }
237 }
238 }
239
240 if( F.user.mayAttachForum ){
 
 
 
241 //e.buttons.append( e.button.addAttach = this.#att.takeAddButton() );
242 e.tabAttach = D.div();
243 e.tabAttach.setAttribute('id', idPrefix+'-attach');
244 e.tabAttach.dataset.tabLabel = 'Attachments';
245 this.#tabs.addTab(e.tabAttach);
246 /* Reminder: we don't currently have a way to disable/enable
247 an Attacher's controls during ajax traffic. */
248 }
249 e.buttons.append(e.button.preview, e.button.submit);
250 this.#toDisable.push(e.button.preview);
251
252 e.help = D.attr(D.div(), 'id', idPrefix+'-help');
@@ -329,20 +341,16 @@
341
342 set editorContent(v){
343 this.#e.editor.value = v;
344 }
345
346 /** Clears any persistent draft state. Does not clear the UI
347 widgets. */
348 #clearDraft(){
349 if( this.#draft ){
350 F.storage.remove(this.#opt.draftKey);
351 this.#draft = F.nu();
 
 
 
 
352 }
353 }
354
355 /**
356 Reports an error by appending each argument to the error widget
@@ -395,10 +403,17 @@
403 D.attr(D.a(F.repoUrl('markup_help'), 'Markup styles'),
404 'target', '_new')
405 );
406 eh.append(list);
407 }
408
409 #initAttacherTab(){
410 this.#att = new F.Attacher({
411 reverse: true
412 });
413 this.#e.tabAttach.append(this.#att.widget);
414 }
415
416 #newFormData(addThisContent){
417 const fd = new FormData;
418 for(const f of this.#extraFields){
419 fd.append(f.name, f.value);
@@ -451,10 +466,11 @@
466 return;
467 }
468 this.#isWaiting = true;
469 D.clearElement(e.preview);
470 const content = this.editorContent.trim();
471 //console.debug("content to preview", content);
472 if( !content ){
473 return;
474 }
475 D.disable(this.#toDisable, e.button.submit);
476 e.preview.textContent = "Fetching preview...";
@@ -493,28 +509,48 @@
509 if( this.#isWaiting ) return;
510 if( !this.#validate() ) return;
511 this.#isWaiting = true;
512 const e = this.#e;
513 D.disable(e.button.submit);
 
514 const fd = this.#newFormData();
515 if( this.#att ){
516 this.#att.populateFormData(fd);
517 }
518 if( this.#e.status ){
519 /* Send the status only if it was modified, otherwise we may
520 add a superfluous tag. */
521 const v = this.#e.status.value;
522 if( this.#e.status.dataset.originalValue !== v ){
523 fd.append( "status", v );
524 }
525 }
526 console.warn("Ready to submit",fd);
527 /*
528 TODO: save it, set #isWaiting=false, then handle error or
529 redirect to the post (if this is a new post) or, if replying
530 inline, replace this object with a static rendering from the
531 response.
532 */
533 const resp = window.fetch(F.repoUrl('forumajax_save'), {
534 method: 'POST',
535 body: fd
536 }).then(r=>r.json())
537 .then(j=>{
538 if( j.error ){
539 throw new Error(j.error);
540 }
541 this.#clearDraft();
542 window.location = F.repoUrl('forumpost/'+j.uuid);
543 })
544 .catch((e)=>this.reportError(e.message))
545 .finally(()=>this.#isWaiting = false);
546 }
547
548 #storeDraft(){
549 if( this.#draft ){
550 F.storage.setJSON(this.#opt.draftKey, this.#draft);
551 }
 
552 }
553
554 async #fetchPost(){
555 /*
556 TODO: when editing an existing post, fetch the raw body of the
557

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button