Fossil SCM

Extend attachment_target_type() to optionally allow a prefix match, working towards historical compatibility regarding passing-around of tech-note/ticket prefixes. Re-enable the attachment description fields because /attachlist indeed shows those. Add user info to JSON-format attachment lists.

stephan 2026-06-04 13:07 UTC attach-v2
Commit 33f6106490c97c984b11dd69765a1e56ab914165d88bce1030cb4c32465c999e
+120 -86
--- src/attach.c
+++ src/attach.c
@@ -24,87 +24,115 @@
2424
/*
2525
** Given a presumedly legal attachment target name, this guesses the
2626
** target type and returns one of CFTYPE_FORUM, CFTYPE_WIKI,
2727
** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot
2828
** distinguish the target type.
29
+**
30
+** If bFull is true then it requires zTarget to be an exact matches
31
+** for wiki, tech-note, and ticket IDs. If bFull is false then
32
+** tech-notes and tickets will perform a prefix match, but it is up to
33
+** the caller to provide enough of a prefix to rule out a
34
+** collision[^1]. Forum posts are a special case and ignore the bFull
35
+** flag. When called repeatedly, this routine can run a bit faster and
36
+** more efficiently if bFull is true, but some historical use cases
37
+** call for prefix matches.
2938
**
3039
** In the case of CFTYPE_FORUM, it is up to the caller to ensure that,
3140
** if needed, they resolve zTarget using forumpost_head_rid2() so that
3241
** they get the RID of the earliest version of the post, as that is
3342
** the only one which attachments should target.
3443
**
35
-** FIXME (2026-06-03)? Figuring out if an event or a ticket are a
36
-** unique prefix is more work than this function performs. This makes
37
-** it unsuitable as a drop-in replacement for legacy
38
-** /attachadd?x=SHORT_ID links using newer /attachadd?target=ID URLs
39
-** because the the former, via /attachadd?technote=ID or ?ticket=ID,
40
-** accept unique prefixes whereas the latter, via this function, does
41
-** not.
42
-*/
43
-int attachment_target_type(const char *zTarget){
44
- static Stmt q = empty_Stmt_m;
45
- int rc = 0;
46
- if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){
47
- return 0;
48
- }else if( forumpost_head_rid2(zTarget)>0 ){
49
- return CFTYPE_FORUM;
50
- }else if( !q.pStmt ){
51
- db_static_prepare(
52
- &q,
53
- "SELECT CASE "
54
- "WHEN 'tkt-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
55
- "WHEN 'event-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
56
- "WHEN 'wiki-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
57
- "ELSE 0 END",
58
- CFTYPE_TICKET, CFTYPE_EVENT, CFTYPE_WIKI
59
- );
60
- }
61
- db_bind_text(&q, ":tgt", zTarget);
62
- if( SQLITE_ROW==db_step(&q) ){
63
- rc = db_column_int(&q, 0);
64
- }
65
- db_reset(&q);
66
- return rc;
67
-}
68
-
69
-/*
70
-** Given a full attachment target ID, returns its blob.rid. zTarget
71
-** must be a full wiki page name, tech-note ID, ticket ID, or forum
72
-** post hash. Returns 0 if no match is found.
73
-*/
74
-int attachment_resolve_target(const char *zTarget){
75
- int rid = 0;
76
- int eType = attachment_target_type(zTarget);
77
- switch(eType){
78
- case CFTYPE_EVENT:
79
- rid = db_int(
80
- 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
81
- "WHERE tagname='event-%q'\n"
82
- "AND x.tagtype>0\n"
83
- "AND x.tagid=t.tagid\n"
84
- "AND x.rid=b.rid\n"
85
- "ORDER BY x.mtime DESC",
86
- zTarget
87
- );
88
- break;
89
- case CFTYPE_FORUM:
90
- rid = db_int(
91
- 0, "SELECT f.fpid FROM forumpost f, blob b\n"
92
- "WHERE f.fpid=b.rid\n"
93
- "AND b.uuid=%Q",
94
- zTarget
95
- );
96
- break;
97
- case CFTYPE_TICKET:
98
- rid = db_int(
99
- 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
100
- "WHERE tagname='tkt-%q'\n"
101
- "AND x.tagtype>0\n"
102
- "AND x.tagid=t.tagid\n"
103
- "AND x.rid=b.rid\n"
104
- "ORDER BY x.mtime DESC",
105
- zTarget
44
+** [^1]: Historically (as of 2026-06) attachment target lookups have
45
+** used GLOB prefix matching but taken no measures to ensure that the
46
+** prefix is unambiguous. Ergo we don't here, either. It is assumed
47
+** that the caller passes enough of a prefix to be unambiguous and
48
+** that's worked out fine so far.
49
+*/
50
+int attachment_target_type(const char *zTarget, int bFull){
51
+ if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){
52
+ return 0;
53
+ }
54
+ if( symbolic_name_to_rid(zTarget, "f")>0 ){
55
+ /* Check forum posts first because they are the most likely target
56
+ ** as of 2026. */
57
+ return CFTYPE_FORUM;
58
+ }
59
+ if( bFull ){
60
+ static Stmt q = empty_Stmt_m;
61
+ int rc = 0;
62
+ if( !q.pStmt ){
63
+ db_static_prepare(
64
+ &q,
65
+ "SELECT CASE "
66
+ /* Ordered by presumed likelihood of attachments. */
67
+ "WHEN (SELECT 1 FROM tag WHERE tagname='tkt-'||:tgt) THEN %d\n"
68
+ "WHEN (SELECT 1 FROM tag WHERE tagname='wiki-'||:tgt) THEN %d\n"
69
+ "WHEN (SELECT 1 FROM tag WHERE tagname='event-'||:tgt) THEN %d\n"
70
+ "ELSE 0 END",
71
+ CFTYPE_TICKET, CFTYPE_WIKI, CFTYPE_EVENT
72
+ );
73
+ }
74
+ db_bind_text(&q, ":tgt", zTarget);
75
+ if( SQLITE_ROW==db_step(&q) ){
76
+ rc = db_column_int(&q, 0);
77
+ }
78
+ db_reset(&q);
79
+ return rc;
80
+ }else{
81
+ return db_int(
82
+ 0,
83
+ "SELECT CASE "
84
+ "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'tkt-%q*')"
85
+ " THEN %d\n"
86
+ "WHEN (SELECT tagid FROM tag WHERE tagname='wiki-%q')"
87
+ " THEN %d\n"
88
+ "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'event-%q*')"
89
+ " THEN %d\n"
90
+ "ELSE 0 END",
91
+ zTarget, CFTYPE_TICKET,
92
+ zTarget, CFTYPE_WIKI,
93
+ zTarget, CFTYPE_EVENT
94
+ );
95
+ }
96
+}
97
+
98
+/*
99
+** Given an attachment target name, returns the target's blob.rid.
100
+** zTarget must be a full wiki page name, tech-note ID, ticket ID, or
101
+** forum post hash. Returns 0 if no match is found.
102
+**
103
+** bFull is interpreted as per attachment_target_type().
104
+*/
105
+int attachment_target_rid(const char *zTarget, int bFull){
106
+ int rid = 0;
107
+ const int eType = attachment_target_type(zTarget, bFull);
108
+ switch(eType){
109
+ case CFTYPE_TICKET:
110
+ case CFTYPE_EVENT:{
111
+ const char *zTagPrefix = (eType==CFTYPE_EVENT) ? "event" : "tkt";
112
+ rid = db_int(
113
+ 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
114
+ "WHERE tagname %s '%s-%q%s'\n"
115
+ "AND x.tagtype>0\n"
116
+ "AND x.tagid=t.tagid\n"
117
+ "AND x.rid=b.rid\n"
118
+ "ORDER BY x.mtime DESC",
119
+ bFull ? "=" : "GLOB"/*safe-for-%s*/,
120
+ zTagPrefix/*safe-for-%s*/,
121
+ zTarget,
122
+ bFull ? "" : "*"/*safe-for-%s*/
123
+ );
124
+ break;
125
+ }
126
+ case CFTYPE_FORUM:
127
+ rid = db_int(
128
+ 0, "SELECT f.fpid FROM forumpost f, blob b\n"
129
+ "WHERE f.fpid=b.rid\n"
130
+ "AND b.uuid %s '%q%s'",
131
+ bFull ? "=" : "GLOB"/*safe-for-%s*/,
132
+ zTarget,
133
+ bFull ? "" : "*"/*safe-for-%s*/
106134
);
107135
break;
108136
case CFTYPE_WIKI:
109137
rid = db_int(
110138
0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
@@ -122,12 +150,12 @@
122150
return rid;
123151
}
124152
125153
/*
126154
** For a given aritfact ID and type (from the CFTYPE_xyz enum),
127
-** returns true if the current user could hypothetically attach
128
-** something to it, else returns 0.
155
+** returns true if the current user could hypothetically apply and
156
+** attachment to it, else returns 0.
129157
**
130158
** The rid is currently only relevant when eArtifactType is
131159
** CFTYPE_FORUM. For forum posts, it checks precisely the rid given,
132160
** not the head RID, to keep non-admins from attaching files to
133161
** threads which have since been taken over by another user (this
@@ -150,11 +178,11 @@
150178
}
151179
}
152180
153181
/*
154182
** Emits a single-button FORM which invokes
155
-** /attachadd?target=$zTarget.
183
+** /attachadd with target=$zTarget.
156184
*/
157185
void attach_render_attachadd_button(const char *zTarget){
158186
/* This could be changed from POST to GET, and arguably should so
159187
** that the target=X part becomes part of the resulting URL. */
160188
@ <form method="post" action="%R/attachadd">\
@@ -266,11 +294,11 @@
266294
if( zFilename[i]=='/' && zFilename[i+1]!=0 ){
267295
zFilename = &zFilename[i+1];
268296
i = -1;
269297
}
270298
}
271
- type = attachment_target_type(zTarget);
299
+ type = attachment_target_type(zTarget, 1);
272300
switch( type ){
273301
case CFTYPE_TICKET:
274302
zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename);
275303
break;
276304
case CFTYPE_EVENT:
@@ -728,11 +756,11 @@
728756
ajax_route_error(403, "Invalid CSRF signature.");
729757
return;
730758
}
731759
db_begin_transaction();
732760
zTarget = P("target");
733
- iTgtType = attachment_target_type(zTarget);
761
+ iTgtType = attachment_target_type(zTarget, 1);
734762
CX("{");
735763
switch( iTgtType ){
736764
default:
737765
case 0:
738766
ajax_route_error(400, "Invalid attachment target.");
@@ -877,11 +905,11 @@
877905
if( P("cancel") ) cgi_redirect(zFrom);
878906
if( 0==zTarget ){
879907
webpage_error("Requires target=X");
880908
}
881909
login_check_credentials();
882
- eTgtType = attachment_target_type(zTarget);
910
+ eTgtType = attachment_target_type(zTarget, 1);
883911
switch( eTgtType ){
884912
default:
885913
case 0:
886914
webpage_error("Cannot resolve target=%h.", zTarget);
887915
break;
@@ -1300,11 +1328,11 @@
13001328
const char *zSrc = db_column_text(&q, 4);
13011329
const char *zTarget = db_column_text(&q, 5);
13021330
const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous";
13031331
const char *zTypeArg = 0; /* URL arg name for /attachdownload */
13041332
const int aid = db_column_int(&q, 6);
1305
- const int iAType = attachment_target_type(zTarget);
1333
+ const int iAType = attachment_target_type(zTarget, 1);
13061334
if( (flags & ATTACHLIST_HIDE_UNAPPROVED)
13071335
&& moderation_pending(aid)
13081336
&& !moderation_user_could(aid, 1, 0) ){
13091337
continue;
13101338
}
@@ -1552,11 +1580,11 @@
15521580
default:
15531581
goto empty_result;
15541582
}
15551583
db_prepare(&q,
15561584
"SELECT datetime(mtime), a.src, a.target, a.filename, a.isLatest,\n"
1557
- " b2.size, b1.uuid, a.comment\n"
1585
+ " b2.size, b1.uuid, a.user, a.comment\n"
15581586
" FROM attachment a, blob b1, blob b2\n"
15591587
" WHERE a.target=%Q\n"
15601588
" AND b1.rid=a.attachid\n"
15611589
" AND b2.uuid=a.src\n"
15621590
" AND b2.size>0\n"
@@ -1570,23 +1598,25 @@
15701598
const char *zTarget = db_column_text(&q, 2);
15711599
const char *zName = db_column_text(&q, 3);
15721600
const int isLatest = db_column_int(&q, 4);
15731601
const int sz = db_column_int(&q, 5);
15741602
const char *zUuid = db_column_text(&q, 6);
1575
- const char *zComment = db_column_text(&q, 7);
1603
+ const char *zUser = db_column_text(&q, 7);
1604
+ const char *zComment = db_column_text(&q, 8);
15761605
if(!i++){
15771606
blob_append_char(pOut, '[');
15781607
}else{
15791608
blob_append_char(pOut, ',');
15801609
}
15811610
blob_appendf(
15821611
pOut,
15831612
"{\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
15841613
"\"filename\": %!j, \"size\":%d, \"mtime\": %!j, "
1585
- "\"isLatest\": %s,\"comment\": ",
1614
+ "\"isLatest\": %s, \"user\": %!j, \"comment\": ",
15861615
zUuid, zSrc, zTarget,
1587
- zName, sz, zTime, isLatest ? "true" : "false"
1616
+ zName, sz, zTime, isLatest ? "true" : "false",
1617
+ zUser
15881618
);
15891619
if( zComment && zComment[0] ){
15901620
blob_appendf(pOut, "%!j", zComment);
15911621
}else{
15921622
blob_append_literal(pOut, "null");
@@ -1609,35 +1639,39 @@
16091639
}
16101640
16111641
/*
16121642
** COMMAND: test-attachments-to-json
16131643
**
1614
-** Usage: %fossil test-attachments-to-json FULL_TARGET_ID
1644
+** Usage: %fossil test-attachments-to-json TARGET_ID
16151645
**
16161646
** Options:
16171647
** --old List all versions of attachments. Default is to
16181648
** list only the latest.
1649
+** --full Require a full target ID, not a prefix.
16191650
**
16201651
** Emits a JSON array of attachments for the given attachment target.
1621
-** The given ID must be a full wiki page name, ticket hash, tech-note
1622
-** hash, or forum post hash. It does not accept partial prefixes.
1652
+** The given ID must be a wiki page name, ticket hash, tech-note hash,
1653
+** or forum post hash. By default it accepts prefixes but does not
1654
+** detection of ambiguity or cross-type prefix collisions so may emit
1655
+** curious results if given short/colliding IDs.
16231656
*/
16241657
void test_attachments_to_json_cmd(void){
16251658
Manifest *pManifest;
16261659
Blob b = BLOB_INITIALIZER;
16271660
int bLatestOnly = find_option("old",0,0)==0;
16281661
int emptyPolicy = 1;
16291662
int rid;
1663
+ int bFullId = find_option("full",0,0)!=0;
16301664
const char *zTarget;
16311665
verify_all_options();
16321666
db_find_and_open_repository(0, 0);
16331667
if( g.argc<3 ){
16341668
usage("test-attachments-to-json TARGET_ID");
16351669
return;
16361670
}
16371671
zTarget = g.argv[2];
1638
- rid = attachment_resolve_target(zTarget);
1672
+ rid = attachment_target_rid(zTarget, bFullId);
16391673
if( 0==rid ){
16401674
fossil_fatal("Cannot resolve ID.");
16411675
}
16421676
pManifest = manifest_get(rid, CFTYPE_ANY, NULL);
16431677
attachments_to_json(pManifest, &b, bLatestOnly, emptyPolicy);
16441678
--- src/attach.c
+++ src/attach.c
@@ -24,87 +24,115 @@
24 /*
25 ** Given a presumedly legal attachment target name, this guesses the
26 ** target type and returns one of CFTYPE_FORUM, CFTYPE_WIKI,
27 ** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot
28 ** distinguish the target type.
 
 
 
 
 
 
 
 
 
29 **
30 ** In the case of CFTYPE_FORUM, it is up to the caller to ensure that,
31 ** if needed, they resolve zTarget using forumpost_head_rid2() so that
32 ** they get the RID of the earliest version of the post, as that is
33 ** the only one which attachments should target.
34 **
35 ** FIXME (2026-06-03)? Figuring out if an event or a ticket are a
36 ** unique prefix is more work than this function performs. This makes
37 ** it unsuitable as a drop-in replacement for legacy
38 ** /attachadd?x=SHORT_ID links using newer /attachadd?target=ID URLs
39 ** because the the former, via /attachadd?technote=ID or ?ticket=ID,
40 ** accept unique prefixes whereas the latter, via this function, does
41 ** not.
42 */
43 int attachment_target_type(const char *zTarget){
44 static Stmt q = empty_Stmt_m;
45 int rc = 0;
46 if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){
47 return 0;
48 }else if( forumpost_head_rid2(zTarget)>0 ){
49 return CFTYPE_FORUM;
50 }else if( !q.pStmt ){
51 db_static_prepare(
52 &q,
53 "SELECT CASE "
54 "WHEN 'tkt-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
55 "WHEN 'event-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
56 "WHEN 'wiki-'||:tgt IN (SELECT tagname FROM tag) THEN %d "
57 "ELSE 0 END",
58 CFTYPE_TICKET, CFTYPE_EVENT, CFTYPE_WIKI
59 );
60 }
61 db_bind_text(&q, ":tgt", zTarget);
62 if( SQLITE_ROW==db_step(&q) ){
63 rc = db_column_int(&q, 0);
64 }
65 db_reset(&q);
66 return rc;
67 }
68
69 /*
70 ** Given a full attachment target ID, returns its blob.rid. zTarget
71 ** must be a full wiki page name, tech-note ID, ticket ID, or forum
72 ** post hash. Returns 0 if no match is found.
73 */
74 int attachment_resolve_target(const char *zTarget){
75 int rid = 0;
76 int eType = attachment_target_type(zTarget);
77 switch(eType){
78 case CFTYPE_EVENT:
79 rid = db_int(
80 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
81 "WHERE tagname='event-%q'\n"
82 "AND x.tagtype>0\n"
83 "AND x.tagid=t.tagid\n"
84 "AND x.rid=b.rid\n"
85 "ORDER BY x.mtime DESC",
86 zTarget
87 );
88 break;
89 case CFTYPE_FORUM:
90 rid = db_int(
91 0, "SELECT f.fpid FROM forumpost f, blob b\n"
92 "WHERE f.fpid=b.rid\n"
93 "AND b.uuid=%Q",
94 zTarget
95 );
96 break;
97 case CFTYPE_TICKET:
98 rid = db_int(
99 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
100 "WHERE tagname='tkt-%q'\n"
101 "AND x.tagtype>0\n"
102 "AND x.tagid=t.tagid\n"
103 "AND x.rid=b.rid\n"
104 "ORDER BY x.mtime DESC",
105 zTarget
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
106 );
107 break;
108 case CFTYPE_WIKI:
109 rid = db_int(
110 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
@@ -122,12 +150,12 @@
122 return rid;
123 }
124
125 /*
126 ** For a given aritfact ID and type (from the CFTYPE_xyz enum),
127 ** returns true if the current user could hypothetically attach
128 ** something to it, else returns 0.
129 **
130 ** The rid is currently only relevant when eArtifactType is
131 ** CFTYPE_FORUM. For forum posts, it checks precisely the rid given,
132 ** not the head RID, to keep non-admins from attaching files to
133 ** threads which have since been taken over by another user (this
@@ -150,11 +178,11 @@
150 }
151 }
152
153 /*
154 ** Emits a single-button FORM which invokes
155 ** /attachadd?target=$zTarget.
156 */
157 void attach_render_attachadd_button(const char *zTarget){
158 /* This could be changed from POST to GET, and arguably should so
159 ** that the target=X part becomes part of the resulting URL. */
160 @ <form method="post" action="%R/attachadd">\
@@ -266,11 +294,11 @@
266 if( zFilename[i]=='/' && zFilename[i+1]!=0 ){
267 zFilename = &zFilename[i+1];
268 i = -1;
269 }
270 }
271 type = attachment_target_type(zTarget);
272 switch( type ){
273 case CFTYPE_TICKET:
274 zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename);
275 break;
276 case CFTYPE_EVENT:
@@ -728,11 +756,11 @@
728 ajax_route_error(403, "Invalid CSRF signature.");
729 return;
730 }
731 db_begin_transaction();
732 zTarget = P("target");
733 iTgtType = attachment_target_type(zTarget);
734 CX("{");
735 switch( iTgtType ){
736 default:
737 case 0:
738 ajax_route_error(400, "Invalid attachment target.");
@@ -877,11 +905,11 @@
877 if( P("cancel") ) cgi_redirect(zFrom);
878 if( 0==zTarget ){
879 webpage_error("Requires target=X");
880 }
881 login_check_credentials();
882 eTgtType = attachment_target_type(zTarget);
883 switch( eTgtType ){
884 default:
885 case 0:
886 webpage_error("Cannot resolve target=%h.", zTarget);
887 break;
@@ -1300,11 +1328,11 @@
1300 const char *zSrc = db_column_text(&q, 4);
1301 const char *zTarget = db_column_text(&q, 5);
1302 const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous";
1303 const char *zTypeArg = 0; /* URL arg name for /attachdownload */
1304 const int aid = db_column_int(&q, 6);
1305 const int iAType = attachment_target_type(zTarget);
1306 if( (flags & ATTACHLIST_HIDE_UNAPPROVED)
1307 && moderation_pending(aid)
1308 && !moderation_user_could(aid, 1, 0) ){
1309 continue;
1310 }
@@ -1552,11 +1580,11 @@
1552 default:
1553 goto empty_result;
1554 }
1555 db_prepare(&q,
1556 "SELECT datetime(mtime), a.src, a.target, a.filename, a.isLatest,\n"
1557 " b2.size, b1.uuid, a.comment\n"
1558 " FROM attachment a, blob b1, blob b2\n"
1559 " WHERE a.target=%Q\n"
1560 " AND b1.rid=a.attachid\n"
1561 " AND b2.uuid=a.src\n"
1562 " AND b2.size>0\n"
@@ -1570,23 +1598,25 @@
1570 const char *zTarget = db_column_text(&q, 2);
1571 const char *zName = db_column_text(&q, 3);
1572 const int isLatest = db_column_int(&q, 4);
1573 const int sz = db_column_int(&q, 5);
1574 const char *zUuid = db_column_text(&q, 6);
1575 const char *zComment = db_column_text(&q, 7);
 
1576 if(!i++){
1577 blob_append_char(pOut, '[');
1578 }else{
1579 blob_append_char(pOut, ',');
1580 }
1581 blob_appendf(
1582 pOut,
1583 "{\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
1584 "\"filename\": %!j, \"size\":%d, \"mtime\": %!j, "
1585 "\"isLatest\": %s,\"comment\": ",
1586 zUuid, zSrc, zTarget,
1587 zName, sz, zTime, isLatest ? "true" : "false"
 
1588 );
1589 if( zComment && zComment[0] ){
1590 blob_appendf(pOut, "%!j", zComment);
1591 }else{
1592 blob_append_literal(pOut, "null");
@@ -1609,35 +1639,39 @@
1609 }
1610
1611 /*
1612 ** COMMAND: test-attachments-to-json
1613 **
1614 ** Usage: %fossil test-attachments-to-json FULL_TARGET_ID
1615 **
1616 ** Options:
1617 ** --old List all versions of attachments. Default is to
1618 ** list only the latest.
 
1619 **
1620 ** Emits a JSON array of attachments for the given attachment target.
1621 ** The given ID must be a full wiki page name, ticket hash, tech-note
1622 ** hash, or forum post hash. It does not accept partial prefixes.
 
 
1623 */
1624 void test_attachments_to_json_cmd(void){
1625 Manifest *pManifest;
1626 Blob b = BLOB_INITIALIZER;
1627 int bLatestOnly = find_option("old",0,0)==0;
1628 int emptyPolicy = 1;
1629 int rid;
 
1630 const char *zTarget;
1631 verify_all_options();
1632 db_find_and_open_repository(0, 0);
1633 if( g.argc<3 ){
1634 usage("test-attachments-to-json TARGET_ID");
1635 return;
1636 }
1637 zTarget = g.argv[2];
1638 rid = attachment_resolve_target(zTarget);
1639 if( 0==rid ){
1640 fossil_fatal("Cannot resolve ID.");
1641 }
1642 pManifest = manifest_get(rid, CFTYPE_ANY, NULL);
1643 attachments_to_json(pManifest, &b, bLatestOnly, emptyPolicy);
1644
--- src/attach.c
+++ src/attach.c
@@ -24,87 +24,115 @@
24 /*
25 ** Given a presumedly legal attachment target name, this guesses the
26 ** target type and returns one of CFTYPE_FORUM, CFTYPE_WIKI,
27 ** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot
28 ** distinguish the target type.
29 **
30 ** If bFull is true then it requires zTarget to be an exact matches
31 ** for wiki, tech-note, and ticket IDs. If bFull is false then
32 ** tech-notes and tickets will perform a prefix match, but it is up to
33 ** the caller to provide enough of a prefix to rule out a
34 ** collision[^1]. Forum posts are a special case and ignore the bFull
35 ** flag. When called repeatedly, this routine can run a bit faster and
36 ** more efficiently if bFull is true, but some historical use cases
37 ** call for prefix matches.
38 **
39 ** In the case of CFTYPE_FORUM, it is up to the caller to ensure that,
40 ** if needed, they resolve zTarget using forumpost_head_rid2() so that
41 ** they get the RID of the earliest version of the post, as that is
42 ** the only one which attachments should target.
43 **
44 ** [^1]: Historically (as of 2026-06) attachment target lookups have
45 ** used GLOB prefix matching but taken no measures to ensure that the
46 ** prefix is unambiguous. Ergo we don't here, either. It is assumed
47 ** that the caller passes enough of a prefix to be unambiguous and
48 ** that's worked out fine so far.
49 */
50 int attachment_target_type(const char *zTarget, int bFull){
51 if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){
52 return 0;
53 }
54 if( symbolic_name_to_rid(zTarget, "f")>0 ){
55 /* Check forum posts first because they are the most likely target
56 ** as of 2026. */
57 return CFTYPE_FORUM;
58 }
59 if( bFull ){
60 static Stmt q = empty_Stmt_m;
61 int rc = 0;
62 if( !q.pStmt ){
63 db_static_prepare(
64 &q,
65 "SELECT CASE "
66 /* Ordered by presumed likelihood of attachments. */
67 "WHEN (SELECT 1 FROM tag WHERE tagname='tkt-'||:tgt) THEN %d\n"
68 "WHEN (SELECT 1 FROM tag WHERE tagname='wiki-'||:tgt) THEN %d\n"
69 "WHEN (SELECT 1 FROM tag WHERE tagname='event-'||:tgt) THEN %d\n"
70 "ELSE 0 END",
71 CFTYPE_TICKET, CFTYPE_WIKI, CFTYPE_EVENT
72 );
73 }
74 db_bind_text(&q, ":tgt", zTarget);
75 if( SQLITE_ROW==db_step(&q) ){
76 rc = db_column_int(&q, 0);
77 }
78 db_reset(&q);
79 return rc;
80 }else{
81 return db_int(
82 0,
83 "SELECT CASE "
84 "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'tkt-%q*')"
85 " THEN %d\n"
86 "WHEN (SELECT tagid FROM tag WHERE tagname='wiki-%q')"
87 " THEN %d\n"
88 "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'event-%q*')"
89 " THEN %d\n"
90 "ELSE 0 END",
91 zTarget, CFTYPE_TICKET,
92 zTarget, CFTYPE_WIKI,
93 zTarget, CFTYPE_EVENT
94 );
95 }
96 }
97
98 /*
99 ** Given an attachment target name, returns the target's blob.rid.
100 ** zTarget must be a full wiki page name, tech-note ID, ticket ID, or
101 ** forum post hash. Returns 0 if no match is found.
102 **
103 ** bFull is interpreted as per attachment_target_type().
104 */
105 int attachment_target_rid(const char *zTarget, int bFull){
106 int rid = 0;
107 const int eType = attachment_target_type(zTarget, bFull);
108 switch(eType){
109 case CFTYPE_TICKET:
110 case CFTYPE_EVENT:{
111 const char *zTagPrefix = (eType==CFTYPE_EVENT) ? "event" : "tkt";
112 rid = db_int(
113 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
114 "WHERE tagname %s '%s-%q%s'\n"
115 "AND x.tagtype>0\n"
116 "AND x.tagid=t.tagid\n"
117 "AND x.rid=b.rid\n"
118 "ORDER BY x.mtime DESC",
119 bFull ? "=" : "GLOB"/*safe-for-%s*/,
120 zTagPrefix/*safe-for-%s*/,
121 zTarget,
122 bFull ? "" : "*"/*safe-for-%s*/
123 );
124 break;
125 }
126 case CFTYPE_FORUM:
127 rid = db_int(
128 0, "SELECT f.fpid FROM forumpost f, blob b\n"
129 "WHERE f.fpid=b.rid\n"
130 "AND b.uuid %s '%q%s'",
131 bFull ? "=" : "GLOB"/*safe-for-%s*/,
132 zTarget,
133 bFull ? "" : "*"/*safe-for-%s*/
134 );
135 break;
136 case CFTYPE_WIKI:
137 rid = db_int(
138 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n"
@@ -122,12 +150,12 @@
150 return rid;
151 }
152
153 /*
154 ** For a given aritfact ID and type (from the CFTYPE_xyz enum),
155 ** returns true if the current user could hypothetically apply and
156 ** attachment to it, else returns 0.
157 **
158 ** The rid is currently only relevant when eArtifactType is
159 ** CFTYPE_FORUM. For forum posts, it checks precisely the rid given,
160 ** not the head RID, to keep non-admins from attaching files to
161 ** threads which have since been taken over by another user (this
@@ -150,11 +178,11 @@
178 }
179 }
180
181 /*
182 ** Emits a single-button FORM which invokes
183 ** /attachadd with target=$zTarget.
184 */
185 void attach_render_attachadd_button(const char *zTarget){
186 /* This could be changed from POST to GET, and arguably should so
187 ** that the target=X part becomes part of the resulting URL. */
188 @ <form method="post" action="%R/attachadd">\
@@ -266,11 +294,11 @@
294 if( zFilename[i]=='/' && zFilename[i+1]!=0 ){
295 zFilename = &zFilename[i+1];
296 i = -1;
297 }
298 }
299 type = attachment_target_type(zTarget, 1);
300 switch( type ){
301 case CFTYPE_TICKET:
302 zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename);
303 break;
304 case CFTYPE_EVENT:
@@ -728,11 +756,11 @@
756 ajax_route_error(403, "Invalid CSRF signature.");
757 return;
758 }
759 db_begin_transaction();
760 zTarget = P("target");
761 iTgtType = attachment_target_type(zTarget, 1);
762 CX("{");
763 switch( iTgtType ){
764 default:
765 case 0:
766 ajax_route_error(400, "Invalid attachment target.");
@@ -877,11 +905,11 @@
905 if( P("cancel") ) cgi_redirect(zFrom);
906 if( 0==zTarget ){
907 webpage_error("Requires target=X");
908 }
909 login_check_credentials();
910 eTgtType = attachment_target_type(zTarget, 1);
911 switch( eTgtType ){
912 default:
913 case 0:
914 webpage_error("Cannot resolve target=%h.", zTarget);
915 break;
@@ -1300,11 +1328,11 @@
1328 const char *zSrc = db_column_text(&q, 4);
1329 const char *zTarget = db_column_text(&q, 5);
1330 const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous";
1331 const char *zTypeArg = 0; /* URL arg name for /attachdownload */
1332 const int aid = db_column_int(&q, 6);
1333 const int iAType = attachment_target_type(zTarget, 1);
1334 if( (flags & ATTACHLIST_HIDE_UNAPPROVED)
1335 && moderation_pending(aid)
1336 && !moderation_user_could(aid, 1, 0) ){
1337 continue;
1338 }
@@ -1552,11 +1580,11 @@
1580 default:
1581 goto empty_result;
1582 }
1583 db_prepare(&q,
1584 "SELECT datetime(mtime), a.src, a.target, a.filename, a.isLatest,\n"
1585 " b2.size, b1.uuid, a.user, a.comment\n"
1586 " FROM attachment a, blob b1, blob b2\n"
1587 " WHERE a.target=%Q\n"
1588 " AND b1.rid=a.attachid\n"
1589 " AND b2.uuid=a.src\n"
1590 " AND b2.size>0\n"
@@ -1570,23 +1598,25 @@
1598 const char *zTarget = db_column_text(&q, 2);
1599 const char *zName = db_column_text(&q, 3);
1600 const int isLatest = db_column_int(&q, 4);
1601 const int sz = db_column_int(&q, 5);
1602 const char *zUuid = db_column_text(&q, 6);
1603 const char *zUser = db_column_text(&q, 7);
1604 const char *zComment = db_column_text(&q, 8);
1605 if(!i++){
1606 blob_append_char(pOut, '[');
1607 }else{
1608 blob_append_char(pOut, ',');
1609 }
1610 blob_appendf(
1611 pOut,
1612 "{\"uuid\": %!j, \"src\": %!j, \"target\": %!j, "
1613 "\"filename\": %!j, \"size\":%d, \"mtime\": %!j, "
1614 "\"isLatest\": %s, \"user\": %!j, \"comment\": ",
1615 zUuid, zSrc, zTarget,
1616 zName, sz, zTime, isLatest ? "true" : "false",
1617 zUser
1618 );
1619 if( zComment && zComment[0] ){
1620 blob_appendf(pOut, "%!j", zComment);
1621 }else{
1622 blob_append_literal(pOut, "null");
@@ -1609,35 +1639,39 @@
1639 }
1640
1641 /*
1642 ** COMMAND: test-attachments-to-json
1643 **
1644 ** Usage: %fossil test-attachments-to-json TARGET_ID
1645 **
1646 ** Options:
1647 ** --old List all versions of attachments. Default is to
1648 ** list only the latest.
1649 ** --full Require a full target ID, not a prefix.
1650 **
1651 ** Emits a JSON array of attachments for the given attachment target.
1652 ** The given ID must be a wiki page name, ticket hash, tech-note hash,
1653 ** or forum post hash. By default it accepts prefixes but does not
1654 ** detection of ambiguity or cross-type prefix collisions so may emit
1655 ** curious results if given short/colliding IDs.
1656 */
1657 void test_attachments_to_json_cmd(void){
1658 Manifest *pManifest;
1659 Blob b = BLOB_INITIALIZER;
1660 int bLatestOnly = find_option("old",0,0)==0;
1661 int emptyPolicy = 1;
1662 int rid;
1663 int bFullId = find_option("full",0,0)!=0;
1664 const char *zTarget;
1665 verify_all_options();
1666 db_find_and_open_repository(0, 0);
1667 if( g.argc<3 ){
1668 usage("test-attachments-to-json TARGET_ID");
1669 return;
1670 }
1671 zTarget = g.argv[2];
1672 rid = attachment_target_rid(zTarget, bFullId);
1673 if( 0==rid ){
1674 fossil_fatal("Cannot resolve ID.");
1675 }
1676 pManifest = manifest_get(rid, CFTYPE_ANY, NULL);
1677 attachments_to_json(pManifest, &b, bLatestOnly, emptyPolicy);
1678
+1 -1
--- src/forum.c
+++ src/forum.c
@@ -1045,11 +1045,11 @@
10451045
/*
10461046
** Renders the attachment list for the given forum post.
10471047
** Emits no output if there are no attachments.
10481048
*/
10491049
static void forum_render_attachment_list(const char *zUuid){
1050
- char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%s'>"
1050
+ char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%!S'>"
10511051
"Attachments</a>:", zUuid);
10521052
attachment_list(zUuid, zLbl,
10531053
ATTACHLIST_HRULE_ABOVE
10541054
| ATTACHLIST_SIZE
10551055
| ATTACHLIST_HIDE_UNAPPROVED);
10561056
--- src/forum.c
+++ src/forum.c
@@ -1045,11 +1045,11 @@
1045 /*
1046 ** Renders the attachment list for the given forum post.
1047 ** Emits no output if there are no attachments.
1048 */
1049 static void forum_render_attachment_list(const char *zUuid){
1050 char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%s'>"
1051 "Attachments</a>:", zUuid);
1052 attachment_list(zUuid, zLbl,
1053 ATTACHLIST_HRULE_ABOVE
1054 | ATTACHLIST_SIZE
1055 | ATTACHLIST_HIDE_UNAPPROVED);
1056
--- src/forum.c
+++ src/forum.c
@@ -1045,11 +1045,11 @@
1045 /*
1046 ** Renders the attachment list for the given forum post.
1047 ** Emits no output if there are no attachments.
1048 */
1049 static void forum_render_attachment_list(const char *zUuid){
1050 char * zLbl = mprintf("<a href='%R/attachlist?forumpost=%!S'>"
1051 "Attachments</a>:", zUuid);
1052 attachment_list(zUuid, zLbl,
1053 ATTACHLIST_HRULE_ABOVE
1054 | ATTACHLIST_SIZE
1055 | ATTACHLIST_HIDE_UNAPPROVED);
1056
--- src/fossil.attach.js
+++ src/fossil.attach.js
@@ -257,11 +257,11 @@
257257
eInfo.append(eFilename, eSize);
258258
const eDesc = this.#opt.description
259259
? D.addClass(
260260
D.attr(D.textarea(), 'placeholder',
261261
'Optional description...'),
262
- 'hidden', 'attach-desc'
262
+ 'attach-desc'
263263
)
264264
: undefined;
265265
const eRemove = D.addClass(
266266
D.button('X', (ev)=>{
267267
ev.stopPropagation();
@@ -512,11 +512,11 @@
512512
const att = new Attacher({
513513
container: eAttachWrapper,
514514
startWith: 1,
515515
listener: cbAttacherChange,
516516
controls: [eBtnSubmit],
517
- description: false
517
+ description: true
518518
});
519519
eBtnSubmit.addEventListener('click', async (ev)=>{
520520
att.reportError();
521521
const li = att.collectState();
522522
if( !li.length ) return;
523523
--- src/fossil.attach.js
+++ src/fossil.attach.js
@@ -257,11 +257,11 @@
257 eInfo.append(eFilename, eSize);
258 const eDesc = this.#opt.description
259 ? D.addClass(
260 D.attr(D.textarea(), 'placeholder',
261 'Optional description...'),
262 'hidden', 'attach-desc'
263 )
264 : undefined;
265 const eRemove = D.addClass(
266 D.button('X', (ev)=>{
267 ev.stopPropagation();
@@ -512,11 +512,11 @@
512 const att = new Attacher({
513 container: eAttachWrapper,
514 startWith: 1,
515 listener: cbAttacherChange,
516 controls: [eBtnSubmit],
517 description: false
518 });
519 eBtnSubmit.addEventListener('click', async (ev)=>{
520 att.reportError();
521 const li = att.collectState();
522 if( !li.length ) return;
523
--- src/fossil.attach.js
+++ src/fossil.attach.js
@@ -257,11 +257,11 @@
257 eInfo.append(eFilename, eSize);
258 const eDesc = this.#opt.description
259 ? D.addClass(
260 D.attr(D.textarea(), 'placeholder',
261 'Optional description...'),
262 'attach-desc'
263 )
264 : undefined;
265 const eRemove = D.addClass(
266 D.button('X', (ev)=>{
267 ev.stopPropagation();
@@ -512,11 +512,11 @@
512 const att = new Attacher({
513 container: eAttachWrapper,
514 startWith: 1,
515 listener: cbAttacherChange,
516 controls: [eBtnSubmit],
517 description: true
518 });
519 eBtnSubmit.addEventListener('click', async (ev)=>{
520 att.reportError();
521 const li = att.collectState();
522 if( !li.length ) return;
523
+1 -1
--- src/info.c
+++ src/info.c
@@ -1931,11 +1931,11 @@
19311931
@ Also attachment "%h(zFilename)" to
19321932
}else{
19331933
@ Attachment "%h(zFilename)" to
19341934
}
19351935
objType |= OBJTYPE_ATTACHMENT;
1936
- switch( attachment_target_type(zTarget) ){
1936
+ switch( attachment_target_type(zTarget, 1) ){
19371937
case CFTYPE_FORUM:
19381938
if( g.perm.Hyperlink && g.anon.RdForum ){
19391939
@ forum post [%z(href("%R/forumpost/%!S",zTarget))%S(zTarget)</a>]
19401940
}else{
19411941
@ forum post [%S(zTarget)]
19421942
--- src/info.c
+++ src/info.c
@@ -1931,11 +1931,11 @@
1931 @ Also attachment "%h(zFilename)" to
1932 }else{
1933 @ Attachment "%h(zFilename)" to
1934 }
1935 objType |= OBJTYPE_ATTACHMENT;
1936 switch( attachment_target_type(zTarget) ){
1937 case CFTYPE_FORUM:
1938 if( g.perm.Hyperlink && g.anon.RdForum ){
1939 @ forum post [%z(href("%R/forumpost/%!S",zTarget))%S(zTarget)</a>]
1940 }else{
1941 @ forum post [%S(zTarget)]
1942
--- src/info.c
+++ src/info.c
@@ -1931,11 +1931,11 @@
1931 @ Also attachment "%h(zFilename)" to
1932 }else{
1933 @ Attachment "%h(zFilename)" to
1934 }
1935 objType |= OBJTYPE_ATTACHMENT;
1936 switch( attachment_target_type(zTarget, 1) ){
1937 case CFTYPE_FORUM:
1938 if( g.perm.Hyperlink && g.anon.RdForum ){
1939 @ forum post [%z(href("%R/forumpost/%!S",zTarget))%S(zTarget)</a>]
1940 }else{
1941 @ forum post [%S(zTarget)]
1942
+1 -1
--- src/manifest.c
+++ src/manifest.c
@@ -2646,11 +2646,11 @@
26462646
" WHERE target=%Q AND filename=%Q))"
26472647
" WHERE target=%Q AND filename=%Q",
26482648
p->zAttachTarget, p->zAttachName,
26492649
p->zAttachTarget, p->zAttachName
26502650
);
2651
- switch( attachment_target_type(p->zAttachTarget) ){
2651
+ switch( attachment_target_type(p->zAttachTarget, 1) ){
26522652
case 0:
26532653
/* It is possible that p->zAttachTarget is not yet in this
26542654
** copy of the repository. If we cannot identify it yet,
26552655
** generate a generic /artifact link to it instead of a
26562656
** type-specific link or an error message. */
26572657
--- src/manifest.c
+++ src/manifest.c
@@ -2646,11 +2646,11 @@
2646 " WHERE target=%Q AND filename=%Q))"
2647 " WHERE target=%Q AND filename=%Q",
2648 p->zAttachTarget, p->zAttachName,
2649 p->zAttachTarget, p->zAttachName
2650 );
2651 switch( attachment_target_type(p->zAttachTarget) ){
2652 case 0:
2653 /* It is possible that p->zAttachTarget is not yet in this
2654 ** copy of the repository. If we cannot identify it yet,
2655 ** generate a generic /artifact link to it instead of a
2656 ** type-specific link or an error message. */
2657
--- src/manifest.c
+++ src/manifest.c
@@ -2646,11 +2646,11 @@
2646 " WHERE target=%Q AND filename=%Q))"
2647 " WHERE target=%Q AND filename=%Q",
2648 p->zAttachTarget, p->zAttachName,
2649 p->zAttachTarget, p->zAttachName
2650 );
2651 switch( attachment_target_type(p->zAttachTarget, 1) ){
2652 case 0:
2653 /* It is possible that p->zAttachTarget is not yet in this
2654 ** copy of the repository. If we cannot identify it yet,
2655 ** generate a generic /artifact link to it instead of a
2656 ** type-specific link or an error message. */
2657

Keyboard Shortcuts

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