/* ** Copyright (c) 2010 D. Richard Hipp ** ** This program is free software; you can redistribute it and/or ** modify it under the terms of the Simplified BSD License (also ** known as the "2-Clause License" or "FreeBSD License".) ** ** This program is distributed in the hope that it will be useful, ** but without any warranty; without even the implied warranty of ** merchantability or fitness for a particular purpose. ** ** Author contact information: ** drh@hwaci.com ** http://www.hwaci.com/drh/ ** ******************************************************************************* ** ** This file contains code for dealing with attachments. */ #include "config.h" #include "attach.h" #include /* ** Given a presumedly legal attachment target name, this guesses the ** target type and returns one of CFTYPE_FORUM, CFTYPE_WIKI, ** CFTYPE_TICKET, or CFTYPE_EVENT. Returns 0 if it cannot distinguish ** the target type. ** ** zTarget is an attachment target name: wiki page name, tech-note ID, ** ticket ID, or forumpost hash. ** ** If bFull is true then it requires zTarget to be a full ID for ** tech-notes and tickets, otherwise such IDs may be prefixes. If ** bFull is false then tech-notes and tickets will perform a prefix ** match, but it is up to the caller to provide enough of a prefix to ** rule out ambiguity[^1]. When called repeatedly, this routine can ** run a bit faster and more efficiently if bFull is true, but some ** historical use cases call for prefix matches. ** ** Wiki page names always require an exact match. ** ** Forum posts are a special case: ** ** - They ignore the bFull flag. That is, they will do prefix matches ** but will not match an ambiguous prefix. ** ** - It is up to the caller to, if needed, resolve zTarget using ** forumpost_head_rid2() to resolve the RID of the earliest version ** of the post, as that is the only one which attachments should ** target. ** ** [^1]: Historically (from the perspective of 2026-06) attachment ** target lookups have used GLOB prefix matching but have taken no ** measures to ensure that the prefix is unambiguous. Ergo we do the ** same here. It is assumed that the caller passes enough of a prefix ** to be unambiguous and that's worked out fine so far. */ int attachment_target_type(const char *zTarget, int bFull){ if( !zTarget || !zTarget[0] || strlen(zTarget)>64/*vs. abuse*/ ){ return 0; } if( symbolic_name_to_rid(zTarget, "f")>0 ){ /* Check forum posts first because they are the most likely target ** as of 2026. We should arguably use something more ** specialized/efficient than symbolic_name_to_rid(). */ return CFTYPE_FORUM; } if( bFull ){ static Stmt q = empty_Stmt_m; int rc = 0; if( !q.pStmt ){ db_static_prepare( &q, "SELECT CASE " /* Ordered by presumed likelihood of attachments. */ "WHEN (SELECT 1 FROM tag WHERE tagname='tkt-'||:tgt) THEN %d\n" "WHEN (SELECT 1 FROM tag WHERE tagname='wiki-'||:tgt) THEN %d\n" "WHEN (SELECT 1 FROM tag WHERE tagname='event-'||:tgt) THEN %d\n" "ELSE 0 END", CFTYPE_TICKET, CFTYPE_WIKI, CFTYPE_EVENT ); } db_bind_text(&q, ":tgt", zTarget); if( SQLITE_ROW==db_step(&q) ){ rc = db_column_int(&q, 0); } db_reset(&q); return rc; }else{ return db_int( 0, "SELECT CASE " "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'tkt-%q*')" " THEN %d\n" "WHEN (SELECT tagid FROM tag WHERE tagname='wiki-%q')" " THEN %d\n" "WHEN (SELECT tagid FROM tag WHERE tagname GLOB 'event-%q*')" " THEN %d\n" "ELSE 0 END", zTarget, CFTYPE_TICKET, zTarget, CFTYPE_WIKI, zTarget, CFTYPE_EVENT ); } } /* ** Given an attachment target name, returns the target's blob.rid. ** zTarget and bFull work as described for attachment_target_type(). ** ** For forum posts, this always returns the RID of the first version ** of the post, as attachments should always target that instance. */ int attachment_target_rid(const char *zTarget, int bFull){ int rid = 0; const int eType = attachment_target_type(zTarget, bFull); switch(eType){ case CFTYPE_TICKET: case CFTYPE_EVENT:{ const char *zTagPrefix = (eType==CFTYPE_EVENT) ? "event" : "tkt"; rid = db_int( 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n" "WHERE tagname %s '%s-%q%s'\n" "AND x.tagtype>0\n" "AND x.tagid=t.tagid\n" "AND x.rid=b.rid\n" "ORDER BY x.mtime DESC", bFull ? "=" : "GLOB"/*safe-for-%s*/, zTagPrefix/*safe-for-%s*/, zTarget, bFull ? "" : "*"/*safe-for-%s*/ ); break; } case CFTYPE_FORUM: rid = db_int( 0, "SELECT f.fpid FROM forumpost f, blob b\n" "WHERE f.fpid=b.rid\n" "AND b.uuid %s '%q%s'", bFull ? "=" : "GLOB"/*safe-for-%s*/, zTarget, bFull ? "" : "*"/*safe-for-%s*/ ); if( rid>0 ){ rid = forumpost_head_rid(rid); } break; case CFTYPE_WIKI: rid = db_int( 0, "SELECT b.rid FROM blob b, tag t, tagxref x\n" "WHERE tagname='wiki-%q'\n" "AND x.tagtype>0\n" "AND x.tagid=t.tagid\n" "AND x.rid=b.rid\n" "ORDER BY x.mtime DESC", zTarget ); break; default: break; } return rid; } /* ** For a given aritfact ID and type (from the CFTYPE_xyz enum), ** returns true if the current user could hypothetically apply and ** attachment to it, else returns 0. ** ** The rid is currently only relevant when eArtifactType is ** CFTYPE_FORUM. For forum posts, it checks precisely the rid given, ** not the head RID, to keep non-admins from attaching files to ** threads which have since been taken over by another user (this ** happens when an admin edits another user's post). */ int attach_user_may(int rid, int eArtifactType){ if( g.perm.Admin ) return 1; if( !login_is_individual() ) return 0; switch(eArtifactType){ case CFTYPE_FORUM: return g.perm.AttachForum && forumpost_is_owner(rid, 0); case CFTYPE_WIKI: return g.perm.ApndWiki && g.perm.Attach; case CFTYPE_TICKET: return g.perm.ApndTkt && g.perm.Attach; case CFTYPE_EVENT: return g.perm.Write && g.perm.ApndWiki && g.perm.Attach; default: return 0; } } /* ** Emits a single-button FORM which invokes ** /attachadd with target=$zTarget. */ void attach_render_attachadd_button(const char *zTarget){ /* This could be changed from POST to GET, and arguably should so ** that the target=X part becomes part of the resulting URL. */ @
\ @ \ @ @
\ } /* ** WEBPAGE: attachlist ** List attachments. ** ** tkt=HASH ** page=WIKIPAGE ** technote=HASH ** forumpost=HASH ** ** At most one of technote=, tkt=, forumpost=, or page= may be supplied. ** ** If none are given, all attachments are listed. If one is given, only ** attachments for the designated technote, ticket or wiki page are shown. ** ** HASH may be just a prefix of the relevant forum post, technical ** note, or ticket artifact hash, in which case all attachments of all ** technical notes or tickets with the prefix will be listed. Forum ** posts, on the other hand, require a unique hash or hash prefix. */ void attachlist_page(void){ const char *zPage = P("page"); const char *zTkt = P("tkt"); const char *zTechNote = P("technote"); const char *zForumPost = P("forumpost"); char *zLink = 0; Blob sql; Stmt q; if( zPage && zTkt ) zTkt = 0; login_check_credentials(); style_set_current_feature("attach"); blob_zero(&sql); blob_append_sql(&sql, "SELECT datetime(mtime,toLocal()), src, target, filename," " comment, user," " (SELECT uuid FROM blob WHERE rid=attachid), attachid" " FROM attachment" ); if( zForumPost ){ int fnid; if( g.perm.RdForum==0 ){ login_needed(g.anon.RdForum); return; } style_header("Attachments To Forum post %S", zForumPost); fnid = forumpost_head_rid2(zForumPost); if( fnid<=0 ){ webpage_error("Invalid forum post ID: %h", zForumPost); } blob_append_sql(&sql, " WHERE target=" "(SELECT uuid FROM blob WHERE rid=%d)", fnid); zLink = mprintf("forum post %#h", zForumPost, hash_digits(0), zForumPost); }else if( zPage ){ if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; } style_header("Attachments To Wiki page %h", zPage); blob_append_sql(&sql, " WHERE target=%Q", zPage); zLink = mprintf("wiki page %h", zPage, zPage); }else if( zTkt ){ if( g.perm.RdTkt==0 ){ login_needed(g.anon.RdTkt); return; } style_header("Attachments To Ticket %S", zTkt); blob_append_sql(&sql, " WHERE target GLOB '%q*'", zTkt); zLink = mprintf("ticket %#h", zTkt, hash_digits(0), zTkt); }else if( zTechNote ){ if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; } style_header("Attachments To Tech Note %S", zTechNote); blob_append_sql(&sql, " WHERE target GLOB '%q*'", zTechNote); zLink = mprintf("tech-note %#h", zTechNote, hash_digits(0), zTechNote); }else{ if( g.perm.RdTkt==0 && g.perm.RdWiki==0 ){ login_needed(g.anon.RdTkt || g.anon.RdWiki); return; } style_header("All Attachments"); } blob_append_sql(&sql, " ORDER BY mtime DESC"); db_prepare(&q, "%s", blob_sql_text(&sql)); if( zLink ){ @

Attachments for %s(zLink)

fossil_free(zLink); zLink = 0; } @
    while( db_step(&q)==SQLITE_ROW ){ const char *zDate; const char *zSrc; const char *zTarget; const char *zFilename; const char *zComment; const char *zUser; const char *zUuid; const char *zDispUser; const int attachid = db_column_int(&q, 7); int type; int i; int bDeleted; char *zUrlTail = 0; if( moderation_pending(attachid) && !moderation_user_could(attachid, 1, 0) ){ /* Elide entries which are currently pending moderation unless ** the user would be able to moderate the entry themselves. */ continue; } zDate = db_column_text(&q, 0); zSrc = db_column_text(&q, 1); zTarget = db_column_text(&q, 2); zFilename = db_column_text(&q, 3); zComment = db_column_text(&q, 4); zUser = db_column_text(&q, 5); zUuid = db_column_text(&q, 6); zDispUser = zUser && zUser[0] ? zUser : "anonymous"; for(i=0; zFilename[i]; i++){ if( zFilename[i]=='/' && zFilename[i+1]!=0 ){ zFilename = &zFilename[i+1]; i = -1; } } bDeleted = 0==zSrc || 0==zSrc[0]; type = attachment_target_type(zTarget, 1); switch( type ){ case CFTYPE_TICKET: zUrlTail = mprintf("tkt=%s&file=%t", zTarget, zFilename); break; case CFTYPE_EVENT: zUrlTail = mprintf("technote=%s&file=%t", zTarget, zFilename); break; case CFTYPE_FORUM: zUrlTail = mprintf("forumpost=%t&file=%t", zTarget, zFilename); break; case CFTYPE_WIKI: zUrlTail = mprintf("page=%t&file=%t", zTarget, zFilename); break; } @
  1. if( bDeleted ){ @ \ } @ Attachment %z(href("%R/ainfo/%!S",zUuid))%S(zUuid)\ moderation_pending_www(attachid); @
    \ @ %h(zFilename) @ [download]\ if( bDeleted ){ @
    } @
    if( zComment ) while( fossil_isspace(zComment[0]) ) zComment++; if( zComment && zComment[0] ){ /* FIXME (2026-06-05): Honor the N-card (comment mimetype). %W ** (historically used here) assumes fossil-wiki and the ** fileformat.wiki doc has always claimed that it defaults to ** text/plain. /ainfo assumes it is plain text. */ @ %h(zComment)
    } if( zForumPost==0 && zPage==0 && zTkt==0 && zTechNote==0 ){ if( bDeleted ){ zSrc = "Deleted from"; }else { zSrc = "Added to"; } switch( type ){ case CFTYPE_TICKET: @ %s(zSrc) ticket @ %S(zTarget) break; case CFTYPE_EVENT: @ %s(zSrc) tech note @ %S(zTarget) break; case CFTYPE_WIKI: @ %s(zSrc) wiki page @ %h(zTarget) break; case CFTYPE_FORUM: @ %s(zSrc) forum post @ %h(zTarget) break; default: @ %s(zSrc) cannot determine target type @ of %h(zTarget) break; } }else{ if( zSrc==0 || zSrc[0]==0 ){ @ Deleted }else { @ Added } } @ by %h(zDispUser) on hyperlink_to_date(zDate, "."); free(zUrlTail); } db_finalize(&q); @

style_finish_page(); return; } /* ** WEBPAGE: attachdownload ** WEBPAGE: attachimage ** WEBPAGE: attachview ** ** Download or display an attachment. ** ** Query parameters: ** ** tkt=HASH ** page=WIKIPAGE ** technote=HASH ** forumpost=HASH ** file=FILENAME ** attachid=ID ** */ void attachview_page(void){ const char *zPage = P("page"); const char *zTkt = P("tkt"); const char *zTechNote = P("technote"); const char *zForumPost = P("forumpost"); const char *zFile = P("file"); const char *zTarget = 0; int attachid = atoi(PD("attachid","0")); char *zUUID = 0; if( zFile==0 ) fossil_redirect_home(); login_check_credentials(); style_set_current_feature("attach"); if( zForumPost ){ int fnid; if( g.perm.RdForum==0 ){ login_needed(g.anon.RdForum); return; } /* Forum attachments are always tied to the post's initial version */ fnid = forumpost_head_rid2(zForumPost); if( fnid>0 ) zTarget = rid_to_uuid(fnid); }else if( zPage ){ if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; } zTarget = zPage; }else if( zTkt ){ if( g.perm.RdTkt==0 ){ login_needed(g.anon.RdTkt); return; } zTarget = zTkt; }else if( zTechNote ){ if( g.perm.RdWiki==0 ){ login_needed(g.anon.RdWiki); return; } zTarget = zTechNote; }else{ fossil_redirect_home(); } if( attachid>0 ){ zUUID = db_text(0, "SELECT coalesce(src,'x') FROM attachment" " WHERE target=%Q AND attachid=%d", zTarget, attachid ); }else{ zUUID = db_text(0, "SELECT coalesce(src,'x') FROM attachment" " WHERE target=%Q AND filename=%Q" " ORDER BY mtime DESC LIMIT 1", zTarget, zFile ); } if( zUUID==0 || zUUID[0]==0 ){ style_header("No Such Attachment"); @ No such attachment.... style_finish_page(); return; }else if( zUUID[0]=='x' ){ style_header("Missing"); @ Attachment has been deleted style_finish_page(); return; }else{ g.perm.Read = 1; cgi_replace_parameter("name",zUUID); if( fossil_strcmp(g.zPath,"attachview")==0 ){ artifact_page(); }else{ cgi_replace_parameter("m", mimetype_from_name(zFile)); rawartifact_page(); } } } /* ** Save an attachment control artifact into the repository */ static void attach_put( Blob *pAttach, /* Text of the Attachment record */ int attachRid, /* RID for the file that is being attached */ int needMod /* True if the attachment is subject to moderation */ ){ int rid; if( needMod ){ rid = content_put_ex(pAttach, 0, 0, 0, 1); moderation_table_create(); db_multi_exec( "INSERT INTO modreq(objid,attachRid) VALUES(%d,%d);", rid, attachRid ); }else{ rid = content_put(pAttach); db_add_unsent(rid); db_multi_exec("INSERT OR IGNORE INTO unclustered VALUES(%d);", rid); } manifest_crosslink(rid, pAttach, MC_NONE); } /* ** Commit a new attachment into the repository */ void attach_commit( const char *zName, /* The filename of the attachment */ const char *zTarget, /* The artifact hash to attach to */ const char *aContent, /* The content of the attachment */ int szContent, /* The length of the attachment */ int needModerator, /* Moderate the attachment? */ const char *zComment /* The comment for the attachment */ ){ Blob content; Blob manifest; Blob cksum; char *zUUID; char *zDate; int rid; int i, n; int addCompress = 0; Manifest *pManifest; db_begin_transaction(); blob_init(&content, aContent, szContent); pManifest = manifest_parse(&content, 0, 0); addCompress = pManifest!=0; manifest_destroy(pManifest); blob_init(&content, aContent, szContent); if( addCompress ){ blob_compress(&content, &content); } rid = content_put_ex(&content, 0, 0, 0, needModerator); zUUID = rid_to_uuid(rid); blob_zero(&manifest); for(i=n=0; zName[i]; i++){ if( zName[i]=='/' || zName[i]=='\\' ) n = i+1; } zName += n; if( zName[0]==0 ) zName = "unknown"; blob_appendf(&manifest, "A %F%s %F %s\n", zName, addCompress ? ".gz" : "", zTarget, zUUID); if( zComment!=0 && zComment[0]!=0 ){ while( fossil_isspace(zComment[0]) ) zComment++; n = strlen(zComment); while( n>0 && fossil_isspace(zComment[n-1]) ){ n--; } if( n>0 ){ blob_appendf(&manifest, "C %#F\n", n, zComment); } } zDate = date_in_standard_format("now"); blob_appendf(&manifest, "D %z\n", zDate); blob_appendf(&manifest, "U %F\n", login_name()); md5sum_blob(&manifest, &cksum); blob_appendf(&manifest, "Z %b\n", &cksum); attach_put(&manifest, rid, needModerator); assert( blob_is_reset(&manifest) ); db_end_transaction(0); } /* ** Renders the "legacy" (static) /attachadd form. One of the first ** four arguments must be non-NULL and the other three must be NULL. ** zComment may be NULL, as may zFrom. See the call sites for more ** context. */ static void attach_render_legacy_form(const char *zForumPost, const char *zTechNote, const char *zTicket, const char *zWikiPage, const char *zComment, const char *zFrom){ form_begin("enctype='multipart/form-data' id='attachadd-legacy-form'", "%R/attachadd"); @
\ @ File to Attach: @
@ Description:
@
if( zForumPost ){ @ \ }else if( zTicket ){ @ \ }else if( zTechNote ){ @ \ }else if( zWikiPage ){ @ \ } @ \ @ \ @ \ @
captcha_generate(0); login_insert_csrf_secret(); @ } /* ** WEBPAGE: attachadd ** Add a new attachment. ** ** tkt=HASH ** page=WIKIPAGE ** technote=HASH ** forumpost=HASH ** from=URL ** ** Adds a POSTed file attachment to the given target. ** ** Or the "version 2" interface: ** ** target=ATTACHMENT_TARGET ** ** Behaves as documented for attachaddV2_page(). */ void attachadd_page(void){ const char *zPage; const char *zForumPost; const char *zTkt; const char *zTechNote; const char *aContent; const char *zName; const char *zComment; const char *zTarget; const char *zFrom; /* Origin page - redirect here after saving */ char *zTo = 0; /* Optionally redirect here after saving */ char *zTargetType = 0; char *zExtraFree = 0; int szContent; int goodCaptcha = 1; int szLimit = 0; if( P("target")!=0 ){ attachaddV2_page(); return; } zPage = P("page"); zForumPost = P("forumpost"); zTkt = P("tkt"); zTechNote = P("technote"); zFrom = P("from"); aContent = P("f"); zName = PD("f:filename","unknown"); zComment = PD("comment", ""); szContent = atoi(PD("f:bytes","0")); if( zFrom==0 ) zFrom = mprintf("%R/home"); if( P("cancel") ) cgi_redirect(zFrom); if( (!!zPage + !!zTkt + !!zTechNote + !!zForumPost)!=1 ){ webpage_error("Requires exactly one one: page=X, tkt=X, forumpost=X," " technote=X, or target=X"); } login_check_credentials(); if( zForumPost ){ int fpid; if( g.perm.AttachForum==0 ){ login_needed(g.anon.AttachForum); return; } fpid = forumpost_head_rid2(zForumPost); if( fpid<=0 ){ webpage_error("Invalid forum post ID: %h", zForumPost); }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){ webpage_error("Only admins can attach files to other users' " "forum posts."); } zTarget = zExtraFree = rid_to_uuid(fpid); zTargetType = mprintf("Forum post %h", zTarget, zForumPost); zTo = zFrom ? 0 : mprintf("%R/forumpost/%S", zTarget); }else if( zPage ){ if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){ login_needed(g.anon.ApndWiki && g.anon.Attach); return; } if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zPage) ){ fossil_redirect_home(); } zTarget = zPage; zTargetType = mprintf("Wiki Page %h", zPage, zPage); zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget); }else if ( zTechNote ){ if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){ login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach); return; } if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'", zTechNote) ){ zTechNote = db_text(0, "SELECT substr(tagname,7) FROM tag" " WHERE tagname GLOB 'event-%q*'", zTechNote); if( zTechNote==0) fossil_redirect_home(); } zTarget = zTechNote; zTargetType = mprintf("Tech Note %S", zTechNote, zTechNote); zTo = zFrom ? 0 : mprintf("%R/technote/%S", zTarget); }else{ assert( zTkt ); if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){ login_needed(g.anon.ApndTkt && g.anon.Attach); return; } if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'", zTkt) ){ zTkt = db_text(0, "SELECT substr(tagname,5) FROM tag" " WHERE tagname GLOB 'tkt-%q*'", zTkt); if( zTkt==0 ) fossil_redirect_home(); } zTarget = zTkt; zTargetType = mprintf("Ticket %S", zTkt, zTkt); zTo = zFrom ? 0 : mprintf("%R/tktview/%S", zTarget); } szLimit = db_get_int("attachment-size-limit", 0); if( szContent<0 || (szLimit && szContent>szLimit) ){ /* This check must be done late so that zTargetType is set up. */ @

Attachment %h(zName) is too large. @ Limit is @ %d(szLimit ? szLimit : 0x7fffffff) bytes

/* Fall through and render form. */ }else if( P("ok") && cgi_csrf_safe(2) && szContent>0 && (goodCaptcha = captcha_is_correct(0)) ){ int needModerator = (zForumPost!=0 && forum_need_moderation()) || (zTkt!=0 && ticket_need_moderation(0)) || (zPage!=0 && wiki_need_moderation(0)); attach_commit(zName, zTarget, aContent, szContent, needModerator, zComment); cgi_redirect(zTo ? zTo : zFrom); } style_set_current_feature("attach"); style_header("Add Attachment"); if( !goodCaptcha ){ @

Error: Incorrect security code.

} @

Add Attachment To %s(zTargetType)

attach_render_legacy_form(zForumPost, zTechNote, zTkt, zPage, zComment, zFrom); builtin_fossil_js_bundle_or("attach", NULL); style_finish_page(); fossil_free(zTargetType); fossil_free(zExtraFree); } /* ** WEBPAGE: attachadd_ajax_post hidden ** ** Used by attachadd V2 to handle attachments via POST requests with: ** ** target=ATTACHMENT_TARGET ** file1..fileN=FILE_OBJECTS ** dryrun=0|1 ** ** Each posted file in the set file1..fileN gets attached to the given ** target, permissions permitting. If dryrun>0 then the change is ** rolled back instead of committed. target=X must refer to a full ** target ID, not a prefix. ** ** Responds with JSON: an empty object on success and ** {error:"message"} on error. The on-success response structure is ** subject to amendment. */ void attachadd_ajax_post(void){ const char *zTarget; char *zExtraFree = 0; int eTgtType = 0; int bNeedsModeration = 0; int goodCaptcha = 1; int bRollback = 0; /* Roll back if true. */ if( ! ajax_route_bootstrap(0, 1) ){ return; }else if( !(goodCaptcha = captcha_is_correct(0)) ){ goto ajax_err_403; }else if( !ajax_check_csrf(2) ){ return; } db_begin_transaction(); zTarget = P("target"); eTgtType = attachment_target_type(zTarget, 1); CX("{"); switch( eTgtType ){ default: case 0: ajax_route_error(400, "Invalid attachment target."); db_rollback_transaction(); return; case CFTYPE_FORUM:{ int fpid; if( g.perm.AttachForum==0 ){ goto ajax_err_403; } fpid = forumpost_head_rid2(zTarget); if( fpid<=0 ){ goto ajax_err_404; }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){ ajax_route_error(403, "Only admins can attach files to " "other users' forum posts."); db_rollback_transaction(); return; } zTarget = zExtraFree = rid_to_uuid(fpid); bNeedsModeration = forum_need_moderation(); break; } case CFTYPE_EVENT:{ if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){ goto ajax_err_403; } if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'", zTarget) ){ zTarget = zExtraFree = db_text(0, "SELECT substr(tagname,7) FROM tag" " WHERE tagname GLOB 'event-%q*'", zTarget); if( zTarget==0){ goto ajax_err_404; } } bNeedsModeration = 0; break; } case CFTYPE_TICKET:{ if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){ goto ajax_err_403; } if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'", zTarget) ){ zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag" " WHERE tagname GLOB 'tkt-%q*'", zTarget); if( zTarget==0 ){ goto ajax_err_404; } } bNeedsModeration = ticket_need_moderation(0); break; } case CFTYPE_WIKI:{ if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){ goto ajax_err_403; } if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zTarget) ){ goto ajax_err_404; } bNeedsModeration = wiki_need_moderation(0); break; } } if( attachments_ajax_from_POST(zTarget, bNeedsModeration)>=0 ){ CX("}"); if( atoi(PD("dryrun","0"))>0 ){ bRollback = 1; } }/*else error response was set up*/ fossil_free(zExtraFree); db_end_transaction(bRollback); return; ajax_err_403: if( db_transaction_nesting_depth()>0 ){ db_rollback_transaction(); } ajax_route_error_forbidden(); return; ajax_err_404: assert( db_transaction_nesting_depth()>0 ); db_rollback_transaction(); ajax_route_error(404, "Target not found."); return; } /* ** A helper for AJAX-style routines which accept file attachments via ** POST. zTarget must be a full attachment target. bNeedsModeration ** must be true if the attachment requires moderation. ** ** It is up to the caller to have validated all security measures ** before calling this. ** ** This looks for POSTed files names "file1".."fileN", stopping when ** it finds no entry. Returns the number of entries attached to the ** target or a negative value on error (in which case the current db ** transaction will be in a rollback state). ** ** The only errors are currently attachment size limit violations: ** attachments must have a non-0 size and if the attachment-size-limit ** setting is >0 then each file's size must be <= that. ** ** If this returns a negative value, it will have populated an error ** response using ajax_route_error(). On success it produces no ** output. ** ** ACHTUNG: if zTarget is a forum post, it "really should" be the ID ** of the first version of that post, as that's where attachments are ** intended to be applied so that they can be found and removed ** consistently. Potential TODO is have this function do that if ** attachment_target_type(zTarget,1)!=0 but it would (for current ** uses) require duplicating work already done in the callers. */ int attachments_ajax_from_POST(const char *zTarget, int bNeedsModeration){ int i; int rc = 0; int n = 0; int szLimit; /* attachment-max-size setting */ char aKeyPrefix[20]; /* Buffer for key "file%d" */ char aKeySize[30]; /* Buffer for key "file%d:bytes" */ char aKeyName[30]; /* Buffer for key "file%d:filename" */ char aKeyDesc[30]; /* Buffer for key "file%d_desc" */ db_begin_transaction(); szLimit = db_get_int("attachment-size-limit", 0); for(i = 1; ; ++i, ++n){ /* Look for P("fileN"), where N=1..n */ const char *zContent; const char *zFilename; int szContent; sqlite3_snprintf(sizeof(aKeyPrefix), aKeyPrefix, "file%d", i); zContent = P(aKeyPrefix); if( !zContent ){ /* End of the list. */ break; } sqlite3_snprintf(sizeof(aKeySize), aKeySize, "%s:bytes", aKeyPrefix); szContent = atoi(PD(aKeySize,"-1")); if( szContent<=0 ){ rc = -ajax_route_error(400,"Invalid file size: %d", szContent); break; }else if( szLimit>0 && szContent>szLimit ){ rc = -ajax_route_error(413, "File size limit is %d bytes.", szLimit); break; }else{ sqlite3_snprintf(sizeof(aKeyName), aKeyName, "%s:filename", aKeyPrefix); sqlite3_snprintf(sizeof(aKeyDesc), aKeyDesc, "%s_desc", aKeyPrefix); if( 0==(zFilename=P(aKeyName)) ){ rc = -ajax_route_error(400, "Missing filename."); break; } attach_commit(zFilename, zTarget, zContent, szContent, bNeedsModeration, P(aKeyDesc)); } } if( rc<0 ){ db_rollback_transaction(); return rc; }else{ db_commit_transaction(); return n; } } /* ** Proxy for /attachadd?target=X ** ** Lists attachments for, and can add them to, a target artifact. ** ** target=TKT_HASH|WIKIPAGE_NAME|TECHNOTE_HASH|FORUMPOST_HASH ** from=ORIGINATING_URL ** ** Works like /attachadd but uses a JS-based interactive attachment ** selector. ** ** from=X tells it where to redirect to when it's done. ** ** This page requires a post-2018-ish JS-capable browser. */ void attachaddV2_page(void){ const char *zFrom = P("from"); const char *zTarget = P("target"); char *zTo = 0; char *zTargetType = 0; char *zExtraFree = 0; int eTgtType = 0; int goodCaptcha = 1; char const * noJsArgs[] = {0,0,0,0}; /* Args for noscript form */ if( P("cancel") ) cgi_redirect(zFrom); if( 0==zTarget ){ webpage_error("Requires target=X"); } login_check_credentials(); eTgtType = attachment_target_type(zTarget, 1); switch( eTgtType ){ default: case 0: webpage_error("Cannot resolve target=%h.", zTarget); break; case CFTYPE_FORUM:{ int fpid; if( g.perm.AttachForum==0 ){ login_needed(g.anon.AttachForum); return; } fpid = forumpost_head_rid2(zTarget); if( fpid<=0 ){ webpage_error("Invalid forum post ID: %h", zTarget); }else if( !g.perm.Admin && !forumpost_is_owner(fpid, 0) ){ webpage_error("Only admins can attach files to other users' " "forum posts."); } zTarget = zExtraFree = rid_to_uuid(fpid); noJsArgs[0] = zTarget; zTargetType = mprintf( "Forum post %.16h", zTarget, zTarget ); zTo = mprintf("%R/forumpost/%S", zTarget); break; } case CFTYPE_EVENT:{ if( g.perm.Write==0 || g.perm.ApndWiki==0 || g.perm.Attach==0 ){ login_needed(g.anon.Write && g.anon.ApndWiki && g.anon.Attach); return; } if( !db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'", zTarget) ){ zTarget = db_text(0, "SELECT substr(tagname,7) FROM tag" " WHERE tagname GLOB 'event-%q*'", zTarget); if( zTarget==0) fossil_redirect_home(); } zTo = zFrom ? 0 : mprintf("%R/technote?name=%T", zTarget); zTargetType = mprintf("Tech-note %S", zTarget, zTarget); noJsArgs[1] = zTarget; break; } case CFTYPE_TICKET:{ if( g.perm.ApndTkt==0 || g.perm.Attach==0 ){ login_needed(g.anon.ApndTkt && g.anon.Attach); return; } if( !db_exists("SELECT 1 FROM tag WHERE tagname='tkt-%q'", zTarget) ){ zTarget = db_text(0, "SELECT substr(tagname,5) FROM tag" " WHERE tagname GLOB 'tkt-%q*'", zTarget); if( zTarget==0 ) fossil_redirect_home(); } zTo = zFrom ? 0 : mprintf("%R/tktview/%t", zTarget); zTargetType = mprintf("Ticket %S", zTarget, zTarget); noJsArgs[2] = zTarget; break; } case CFTYPE_WIKI:{ if( g.perm.ApndWiki==0 || g.perm.Attach==0 ){ login_needed(g.anon.ApndWiki && g.anon.Attach); return; } if( !db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zTarget) ){ fossil_redirect_home(); } zTo = zFrom ? 0 : mprintf("%R/wiki?name=%T", zTarget); zTargetType = mprintf( "Wiki page %h", zTarget, zTarget ); noJsArgs[3] = zTarget; break; } } db_begin_transaction(); style_set_current_feature("attach"); style_header("Add Attachment"); if( !goodCaptcha ){ @

Error: Incorrect security code.

} @

Attachments for %s(zTargetType)

attachment_list(zTarget, NULL, ATTACHLIST_SIZE | ATTACHLIST_HIDE_UNAPPROVED); attach_render_legacy_form( noJsArgs[0], noJsArgs[1], noJsArgs[2], noJsArgs[3], 0, zFrom ? zFrom : (zTo ? zTo : (zTo=mprintf("%R/home"))) ); @ builtin_fossil_js_bundle_or("attach", NULL); db_end_transaction(0); style_finish_page(); fossil_free(zTargetType); fossil_free(zExtraFree); fossil_free(zTo); } /* ** WEBPAGE: ainfo ** URL: /ainfo?name=ARTIFACTID ** ** name=ATTACHMENT_ARTIFACT_UUID ** ** Show the details of an attachment artifact. */ void ainfo_page(void){ int rid; /* RID for the control artifact */ int ridSrc; /* RID for the attached file */ char *zDate; /* Date attached */ const char *zUuid; /* Hash of the control artifact */ Manifest *pAttach; /* Parse of the control artifact */ const char *zTarget; /* Wiki, ticket or tech note attached to */ const char *zSrc; /* Hash of the attached file */ const char *zName; /* Name of the attached file */ const char *zDesc; /* Description of the attached file */ const char *zWikiName = 0; /* Wiki page name when attached to Wiki */ const char *zTNUuid = 0; /* Tech Note ID when attached to tech note */ const char *zTktUuid = 0; /* Ticket ID when attached to a ticket */ const char *zForumPost = 0; /* Forum UID when attached to forum post */ int modPending; /* True if awaiting moderation */ const char *zModAction; /* Moderation action or NULL */ int isModerator; /* TRUE if user is the moderator */ const char *zMime; /* MIME Type */ Blob attach; /* Content of the attachment */ int fShowContent = 0; /* True to emit the content */ int bUserIsOwner = 0; /* True if pAttach->zUser is login_name() */ int showDelMenu = 0; /* True to enable delete option */ const char *zLn = P("ln"); login_check_credentials(); if( !g.perm.RdTkt && !g.perm.RdWiki ){ login_needed(g.anon.RdTkt || g.anon.RdWiki); return; } rid = name_to_rid_www("name"); if( rid==0 ){ fossil_redirect_home(); } zUuid = rid_to_uuid(rid); pAttach = manifest_get(rid, CFTYPE_ATTACHMENT, 0); if( pAttach==0 ) fossil_redirect_home(); bUserIsOwner = 0==fossil_strcmp(pAttach->zUser, login_name()) && login_is_individual(); zTarget = pAttach->zAttachTarget; zSrc = pAttach->zAttachSrc; ridSrc = db_int(0,"SELECT rid FROM blob WHERE uuid='%q'", zSrc); zName = pAttach->zAttachName; zDesc = pAttach->zComment; zMime = mimetype_from_name(zName); fShowContent = zMime ? strncmp(zMime,"text/", 5)==0 : 0; if( db_int(0,"SELECT 1 FROM event WHERE objid=%d and type='f'", rid) ){ if( !g.perm.RdForum ){ login_needed(g.anon.RdForum); return; } showDelMenu = g.perm.Admin || bUserIsOwner; zForumPost = zTarget; }else if( validate16(zTarget, strlen(zTarget)) && db_exists("SELECT 1 FROM ticket WHERE tkt_uuid='%q'", zTarget) ){ if( !g.perm.RdTkt ){ login_needed(g.anon.RdTkt); return; } zTktUuid = zTarget; showDelMenu = g.perm.WrTkt; }else if( db_exists("SELECT 1 FROM tag WHERE tagname='wiki-%q'", zTarget) ){ if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; } zWikiName = zTarget; showDelMenu = g.perm.WrWiki; }else if( db_exists("SELECT 1 FROM tag WHERE tagname='event-%q'", zTarget) ){ if( !g.perm.RdWiki ){ login_needed(g.anon.RdWiki); return; } zTNUuid = zTarget; showDelMenu = g.perm.Write && g.perm.WrWiki; } if( showDelMenu ){ style_submenu_element("Delete", "%R/ainfo/%s?del", zUuid); } zDate = db_text(0, "SELECT datetime(%.12f)", pAttach->rDate); if( P("confirm") && cgi_csrf_safe(2) && ((zForumPost && ((bUserIsOwner && g.perm.AttachForum) || forumpost_may_close())) || (zTktUuid && g.perm.WrTkt) || (zWikiName && g.perm.WrWiki) || (zTNUuid && g.perm.Write && g.perm.WrWiki)) ){ /* Delete attachment. */ int i, n, rid; char *zNewDate; Blob manifest; Blob cksum; const char *zFile = zName; if( !bUserIsOwner ){ if( zForumPost ? !forumpost_may_close() : !g.perm.Admin ){ webpage_error( "Only admins can delete other users' attachments." ); } } db_begin_transaction(); blob_zero(&manifest); for(i=n=0; zFile[i]; i++){ if( zFile[i]=='/' || zFile[i]=='\\' ) n = i; } zFile += n; if( zFile[0]==0 ) zFile = "unknown"; blob_appendf(&manifest, "A %F %F\n", zFile, zTarget); zNewDate = date_in_standard_format("now"); blob_appendf(&manifest, "D %s\n", zNewDate); blob_appendf(&manifest, "U %F\n", login_name()); md5sum_blob(&manifest, &cksum); blob_appendf(&manifest, "Z %b\n", &cksum); rid = content_put(&manifest); manifest_crosslink(rid, &manifest, MC_NONE); db_end_transaction(0); @

The attachment below has been deleted.

fossil_free(zNewDate); } if( P("del") && ((zForumPost && (bUserIsOwner || forumpost_may_close())) || (zTktUuid && g.perm.WrTkt) || (zWikiName && g.perm.WrWiki) || (zTNUuid && g.perm.Write && g.perm.WrWiki)) ){ form_begin(0, "%R/ainfo/%!S", zUuid); @

Confirm you want to delete the attachment shown below. @ login_insert_csrf_secret(); @ } isModerator = g.perm.Admin || (zForumPost && g.perm.ModForum) || (zTktUuid && g.perm.ModTkt) || (zWikiName && g.perm.ModWiki); zModAction = P("modaction"); if( zModAction!=0 && cgi_csrf_safe(2) ){ if( strcmp(zModAction,"delete")==0 ){ if( isModerator || bUserIsOwner ){ moderation_disapprove(rid); } if( zForumPost ){ cgi_redirectf("%R/forumpost/%!S", zForumPost); }else if( zTktUuid ){ cgi_redirectf("%R/tktview/%!S", zTktUuid); }else if( zWikiName ) { cgi_redirectf("%R/wiki?name=%t", zWikiName); } /* zTNUuid is intentionally unhandled. Tech note attachments ** don't go through moderation. */ return; } if( isModerator && strcmp(zModAction,"approve")==0 ){ moderation_approve('a', rid); } } style_set_current_feature("attach"); style_header("Attachment Details"); style_submenu_element("Raw", "%R/artifact/%s", zUuid); if(fShowContent){ style_submenu_element("Line Numbers", "%R/ainfo/%s%s", zUuid, ((zLn&&*zLn) ? "" : "?ln=0")); } @

Overview
@

@ @ @ }else if( zTktUuid ){ @ @ }else if( zTNUuid ){ @ @ }else if( zWikiName ){ @ @ } @ "); @ "); @ @ if( g.perm.Setup ){ @ } @ \ /* FIXME (2026-06-05): Honor the N-card (comment mimetype). */ @ @
Artifact ID:%z(href("%R/artifact/%!S",zUuid))%s(zUuid) if( g.perm.Setup ){ @ (%d(rid)) } modPending = moderation_pending_www(rid); if( zForumPost ){ @
Forum Post:%z(href("%R/forumpost/%s",zForumPost))%h(zForumPost)\ @
Ticket:%z(href("%R/tktview/%s",zTktUuid))%s(zTktUuid)
Tech Note:%z(href("%R/technote/%s",zTNUuid))%s(zTNUuid)
Wiki Page:%z(href("%R/wiki?name=%t",zWikiName))%h(zWikiName)\ @
Date: hyperlink_to_date(zDate, "
User: hyperlink_to_user(pAttach->zUser, zDate, "
Artifact Attached:%z(href("%R/artifact/%s",zSrc))%s(zSrc) if( g.perm.Setup ){ @ (%d(ridSrc)) } @
Filename:%h(zName)
MIME-Type:%h(zMime)
Description:%h(zDesc)
if( modPending && (isModerator || bUserIsOwner) ){ @

Moderation
@
form_begin(0, "%R/ainfo/%s", zUuid); @
if( isModerator ){ #if 0 /* TODO/FIXME (2026-06-03): only allow approval of an attachment ** if its target has been approved. Without this, we can end up ** with stale attachments which refer to rejected targets. We ** need a type-specific RID/UUID here, which requires ** refactoring above to get it. */ const int tgtid = 0; if( moderation_pending(tgtid) ){ @
}else #else { @
} #endif } @ login_insert_csrf_secret(); @ @
} @
Content:
blob_zero(&attach); if( modPending && !moderation_user_could(rid, 1, 0) ){ @

Content is awaiting moderator \ @ approval.

}else{ @
if( fShowContent ){ const char *z; content_get(ridSrc, &attach); blob_to_utf8_no_bom(&attach, 0); z = blob_str(&attach); if( zLn ){ output_text_with_line_numbers(z, blob_size(&attach), zName, zLn, 1); }else{ @
        @ %h(z)
        @ 
} }else if( strncmp(zMime, "image/", 6)==0 ){ int sz = db_int(0, "SELECT size FROM blob WHERE rid=%d", ridSrc); @ (file is %d(sz) bytes of image data)
@ style_submenu_element("Image", "%R/raw/%s?m=%s", zSrc, zMime); }else{ int sz = db_int(0, "SELECT size FROM blob WHERE rid=%d", ridSrc); @ (file is %d(sz) bytes of binary data) } @
} manifest_destroy(pAttach); blob_reset(&attach); style_finish_page(); } #if INTERFACE /* ** Flags for use with attachment_list(). ATTACHLIST_HRULE_ABOVE ** must have a value of 1 for historical call compatibility. */ #define ATTACHLIST_HRULE_ABOVE 0x01 /* Insert
above header */ #define ATTACHLIST_TARGET_BLANK 0x02 /* use target=_blank for links */ #define ATTACHLIST_SIZE 0x04 /* add size */ #define ATTACHLIST_HIDE_UNAPPROVED 0x08 /* Hide pending-moderation files */ #define ATTACHLIST_DETAILS_CLOSED 0x10 /* Wrap in a closed DETAILS element */ #define ATTACHLIST_DETAILS_OPEN 0x20 /* Wrap in an open DETAILS element */ #define ATTACHLIST_HIDE_EMPTY 0x40 /* Skip if size<1 */ #endif /* ** Output HTML to show a list of attachments. */ void attachment_list( const char *zTarget, /* Object that things are attached to */ const char *zHeader, /* Header to display with attachments */ const int flags /* ATTACHLIST_... flags */ ){ int cnt = 0; char szBuf[36] = {0}; /* scratchpad for attachment size value */ const char *zLinkTgt = (ATTACHLIST_TARGET_BLANK & flags) ? " target=\"_blank\"" : ""; const int bUseDetail = flags & (ATTACHLIST_DETAILS_CLOSED | ATTACHLIST_DETAILS_OPEN); Stmt q; db_prepare(&q, "SELECT datetime(mtime,toLocal()), a.filename, a.user," " b1.uuid, a.src, a.target, a.attachid, b2.size\n" " FROM attachment a, blob b1, blob b2\n" " WHERE a.isLatest\n" " AND a.src IS NOT NULL\n" " AND a.target=%Q\n" " AND b1.rid=a.attachid\n" " AND b2.uuid=a.src\n" " ORDER BY mtime DESC", zTarget ); while( db_step(&q)==SQLITE_ROW ){ const char *zDate = db_column_text(&q, 0); const char *zFile = db_column_text(&q, 1); const char *zUser = db_column_text(&q, 2); const char *zUuid = db_column_text(&q, 3); const char *zSrc = db_column_text(&q, 4); const char *zTarget = db_column_text(&q, 5); const char *zDispUser = zUser && zUser[0] ? zUser : "anonymous"; const char *zTypeArg = 0; /* URL arg name for /attachdownload */ const int aid = db_column_int(&q, 6); const int sz = db_column_int(&q, 7); if( (flags & ATTACHLIST_HIDE_UNAPPROVED) && moderation_pending(aid) && !moderation_user_could(aid, 1, 0) ){ continue; } if( sz<1 && (flags & ATTACHLIST_HIDE_EMPTY) ){ /* Deleted or phantom items. */ continue; } if( cnt==0 ){ if( bUseDetail ){ @
}else{ @
} if( flags & ATTACHLIST_HRULE_ABOVE ){ @
} if( bUseDetail ){ @ %s(zHeader) }else{ @ %s(zHeader) } @
    } cnt++; switch( attachment_target_type(zTarget, 1) ){ case CFTYPE_TICKET: zTypeArg = "tkt"; break; case CFTYPE_FORUM: zTypeArg = "forumpost"; break; case CFTYPE_EVENT: zTypeArg = "technote"; break; case CFTYPE_WIKI: default: zTypeArg = "page"; break; } @
  • @ %h(zFile) if( flags & ATTACHLIST_SIZE ){ sqlite3_snprintf(sizeof(szBuf), szBuf, " %d bytes", sz); } @ [download%s(szBuf)] @ added by %h(zDispUser) on hyperlink_to_date(zDate, "."); @ [details] moderation_pending_www(aid); @
  • } if( cnt ){ @
if( bUseDetail ){ @
}else{ @ } } db_finalize(&q); } /* ** COMMAND: attachment* ** ** Usage: %fossil attachment add ?PAGENAME? FILENAME ?OPTIONS? ** ** Add an attachment to an existing wiki page or tech note. ** ** Options: ** -t|--technote DATETIME Specifies the timestamp of ** the technote to which the attachment ** is to be made. The attachment will be ** to the most recently modified tech note ** with the specified timestamp. ** -t|--technote TECHNOTE-ID Specifies the technote to be ** updated by its technote id ** ** One of PAGENAME, DATETIME or TECHNOTE-ID must be specified. ** ** DATETIME may be "now" or "YYYY-MM-DDTHH:MM:SS.SSS". If in ** year-month-day form, it may be truncated, the "T" may be replaced by ** a space, and it may also name a timezone offset from UTC as "-HH:MM" ** (westward) or "+HH:MM" (eastward). Either no timezone suffix or "Z" ** means UTC. */ void attachment_cmd(void){ int n; db_find_and_open_repository(0, 0); if( g.argc<3 ){ goto attachment_cmd_usage; } n = strlen(g.argv[2]); if( n==0 ){ goto attachment_cmd_usage; } if( strncmp(g.argv[2],"add",n)==0 ){ const char *zPageName = 0; /* Name of the wiki page to attach to */ const char *zFile; /* Name of the file to be attached */ const char *zETime; /* The name of the technote to attach to */ Manifest *pWiki = 0; /* Parsed wiki page content */ char *zBody = 0; /* Wiki page content */ int rid; const char *zTarget; /* Target of the attachment */ Blob content; /* The content of the attachment */ zETime = find_option("technote","t",1); if( !zETime ){ if( g.argc!=5 ){ usage("add PAGENAME FILENAME"); } zPageName = g.argv[3]; rid = db_int(0, "SELECT x.rid FROM tag t, tagxref x" " WHERE x.tagid=t.tagid AND t.tagname='wiki-%q'" " ORDER BY x.mtime DESC LIMIT 1", zPageName ); if( (pWiki = manifest_get(rid, CFTYPE_WIKI, 0))!=0 ){ zBody = pWiki->zWiki; } if( zBody==0 ){ fossil_fatal("wiki page [%s] not found",zPageName); } zTarget = zPageName; zFile = g.argv[4]; }else{ if( g.argc!=4 ){ usage("add FILENAME --technote DATETIME|TECHNOTE-ID"); } rid = wiki_technote_to_rid(zETime); if( rid<0 ){ fossil_fatal("ambiguous tech note id: %s", zETime); } if( (pWiki = manifest_get(rid, CFTYPE_EVENT, 0))!=0 ){ zBody = pWiki->zWiki; } if( zBody==0 ){ fossil_fatal("technote [%s] not found",zETime); } zTarget = db_text(0, "SELECT substr(tagname,7) FROM tag " " WHERE tagid=(SELECT tagid FROM event WHERE objid='%d')", rid ); zFile = g.argv[3]; } blob_read_from_file(&content, zFile, ExtFILE); user_select(); attach_commit( zFile, /* The filename of the attachment */ zTarget, /* The artifact hash to attach to */ blob_buffer(&content), /* The content of the attachment */ blob_size(&content), /* The length of the attachment */ 0, /* No need to moderate the attachment */ "" /* Empty attachment comment */ ); if( !zETime ){ fossil_print("Attached %s to wiki page %s.\n", zFile, zPageName); }else{ fossil_print("Attached %s to tech note %s.\n", zFile, zETime); } }else{ goto attachment_cmd_usage; } return; attachment_cmd_usage: usage("add ?PAGENAME? FILENAME [-t|--technote DATETIME ]"); } /* ** COMMAND: test-list-attachments ** ** Usage: %fossil test-list-attachments ?-latest? ?TargetName(s)...? ** ** List attachments for one or more attachment targets. The target ** name arguments are glob prefixes for the attachment.target ** field. If no names are provided then a prefix of [a-zA-Z] is used, ** which will match most wiki page names and some ticket hashes. ** ** Options: ** -latest List only the latest version of a given attachment ** */ void test_list_attachments(void){ Stmt q; int i; const int fLatest = find_option("latest", 0, 0) != 0; db_find_and_open_repository(0, 0); verify_all_options(); db_prepare(&q, "SELECT datetime(mtime,toLocal()), src, target, filename," " comment, user " " FROM attachment" " WHERE target GLOB :tgtname ||'*'" " AND (isLatest OR %d)" " ORDER BY target, isLatest DESC, mtime DESC", !fLatest ); if(g.argc<3){ static char * argv[3] = {0,0,"[a-zA-Z]"}; g.argc = 3; g.argv = argv; } for(i = 2; i < g.argc; ++i){ const char *zPage = g.argv[i]; db_bind_text(&q, ":tgtname", zPage); while(SQLITE_ROW == db_step(&q)){ const char *zTime = db_column_text(&q, 0); const char *zSrc = db_column_text(&q, 1); const char *zTarget = db_column_text(&q, 2); const char *zName = db_column_text(&q, 3); printf("%-20s %s %.12s %s\n", zTarget, zTime, zSrc, zName); } db_reset(&q); } db_finalize(&q); } /* ** Renders the list of attachments for artifact pManifest as JSON to ** blob pOut. If pManifest->type is not one of (CFTYPE_TICKET, ** CFTYPE_FORUM, CFTYPE_EVENT, CFTYPE_WIKI) then it behaves as if the ** result set is empty. ** ** If there are no matching attachments then its behavior depends on ** emptyPolicy: ** ** <0 = emit a JSON NULL ** 0 = emit no output ** >0 = emit an empty JSON array ** ** If bLatestOnly is true then only the most recent entry for a given ** attachment is emitted, else all versions are emitted in descending ** mtime order. ** ** Returns the number of attachments. ** ** Output format: ** ** [{ ** "uuid": attachment artifact hash, ** "src": hash of the attachment blob, ** "target": wiki page name or ticket/event ID, ** "filename": filename of attachment, ** "mtime": ISO-8601 timestamp UTC, ** "isLatest": true if this is the latest version of this file ** else false, ** }, ...once per attachment] ** */ int attachments_to_json(const Manifest *pManifest, Blob *pOut, int bLatestOnly, int emptyPolicy){ int i = 0; Stmt q = empty_Stmt; char *zToFree = 0; const char *zTgt = 0; switch(pManifest->type){ case CFTYPE_FORUM: zTgt = zToFree = rid_to_uuid(pManifest->rid); break; case CFTYPE_WIKI: zTgt = pManifest->zWikiTitle; break; case CFTYPE_EVENT: zTgt = pManifest->zEventId; break; case CFTYPE_TICKET: zTgt = pManifest->zTicketUuid; break; default: goto empty_result; } db_prepare(&q, "SELECT datetime(mtime), a.src, a.target, a.filename, a.isLatest,\n" " b2.size, b1.uuid, a.user, a.comment\n" " FROM attachment a, blob b1, blob b2\n" " WHERE a.target=%Q\n" " AND a.src IS NOT NULL\n" " AND b1.rid=a.attachid\n" " AND b2.uuid=a.src\n" " AND (a.isLatest OR %d)\n" " ORDER BY a.target, a.isLatest DESC, a.mtime DESC\n", zTgt, !bLatestOnly ); while(SQLITE_ROW == db_step(&q)){ const char *zTime = db_column_text(&q, 0); const char *zSrc = db_column_text(&q, 1); const char *zTarget = db_column_text(&q, 2); const char *zName = db_column_text(&q, 3); const int isLatest = db_column_int(&q, 4); const int sz = db_column_int(&q, 5); const char *zUuid = db_column_text(&q, 6); const char *zUser = db_column_text(&q, 7); const char *zComment = db_column_text(&q, 8); if(!i++){ blob_append_char(pOut, '['); }else{ blob_append_char(pOut, ','); } blob_appendf( pOut, "{\"uuid\": %!j, \"src\": %!j, \"target\": %!j, " "\"filename\": %!j, \"size\":%d, \"mtime\": %!j, " "\"isLatest\": %s, \"user\": %!j, \"comment\": ", zUuid, zSrc, zTarget, zName, sz, zTime, isLatest ? "true" : "false", zUser ); if( zComment && zComment[0] ){ blob_appendf(pOut, "%!j", zComment); }else{ blob_append_literal(pOut, "null"); } blob_append_char(pOut, '}'); } fossil_free(zToFree); db_finalize(&q); if(!i){ empty_result: if( emptyPolicy>0 ){ blob_append_literal(pOut, "[]"); }else if( emptyPolicy<0 ){ blob_append_literal(pOut, "null"); } }else{ blob_append_char(pOut, ']'); } return i; } /* ** COMMAND: test-attachment-target ** ** Usage: %fossil test-attachment-target TARGET_ID... */ void test_attachment_target_type_cmd(void){ int i; verify_all_options(); db_find_and_open_repository(0, 0); if( g.argc<3 ){ usage("test-attachment-target TARGET_ID"); return; } for( i = 2; i < g.argc; ++i ){ const char *zTarget = g.argv[i]; const int rid = attachment_target_rid(zTarget, 0); const int type = attachment_target_type(zTarget, 0); const char *zType = ""; switch(type){ case CFTYPE_EVENT: zType = "technote"; break; case CFTYPE_FORUM: zType = "forumpost"; break; case CFTYPE_TICKET: zType = "ticket"; break; case CFTYPE_WIKI: zType = "wiki"; break; } fossil_print("%-20s = %-9s #%d %z\n", zTarget, zType, rid, rid>0 ? rid_to_uuid(rid) : 0); } } /* ** COMMAND: test-attachments-to-json ** ** Usage: %fossil test-attachments-to-json TARGET_ID... ** ** Options: ** --old List all versions of attachments. Default is to ** list only the latest. ** --full Require a full target ID, not a prefix. ** ** Emits a JSON array of attachments for the given attachment targets. ** The given IDs must be wiki page names, ticket hashes, tech-note ** hashes, or forum post hashes. By default it accepts hash prefixes ** but does no detection of ambiguity or cross-type prefix collisions ** so may emit curious results if given short, colliding IDs. */ void test_attachments_to_json_cmd(void){ const int emptyPolicy = 1; const int bLatestOnly = find_option("old",0,0)==0; const int bFullId = find_option("full",0,0)!=0; int i; verify_all_options(); db_find_and_open_repository(0, 0); if( g.argc<3 ){ usage("test-attachments-to-json TARGET_ID"); return; } for( i = 2; i < g.argc; ++i ){ const char *zTarget = g.argv[i]; const int rid = attachment_target_rid(zTarget, bFullId); if( 0==rid ){ fossil_print("** cannot resolve %s\n", zTarget); }else{ Blob b = BLOB_INITIALIZER; Manifest *pManifest = manifest_get(rid, CFTYPE_ANY, NULL); assert( pManifest ); attachments_to_json(pManifest, &b, bLatestOnly, emptyPolicy); fossil_print("Attachments for %s: ", zTarget); if( b.nUsed ){ char *zPretty = db_text(0,"SELECT json_pretty(%B)", &b); fossil_print("%s\n", zPretty); fossil_free(zPretty); }else{ fossil_print("none\n"); } blob_reset(&b); manifest_destroy(pManifest); } } }