From 4f49b16079b27d7ce633619e4cddfdf2bffbc952 Mon Sep 17 00:00:00 2001 From: cashmere Date: Fri, 4 Sep 2026 09:05:24 +0200 Subject: initial commit --- src/Makefile | 3 + src/accordion.js | 89 + src/add.c | 1163 ++++++++ src/ajax.c | 487 ++++ src/alerts.c | 3686 ++++++++++++++++++++++++ src/alerts/bflat2.wav | Bin 0 -> 1620 bytes src/alerts/bflat3.wav | Bin 0 -> 1620 bytes src/alerts/bloop.wav | Bin 0 -> 1620 bytes src/alerts/mkwav.c | 88 + src/alerts/plunk.wav | Bin 0 -> 1650 bytes src/allrepo.c | 546 ++++ src/attach.c | 1859 ++++++++++++ src/backlink.c | 456 +++ src/backoffice.c | 881 ++++++ src/bag.c | 237 ++ src/bisect.c | 753 +++++ src/blob.c | 2047 ++++++++++++++ src/branch.c | 1164 ++++++++ src/browse.c | 1262 +++++++++ src/builtin.c | 949 +++++++ src/bundle.c | 799 ++++++ src/cache.c | 511 ++++ src/capabilities.c | 481 ++++ src/captcha.c | 926 ++++++ src/cgi.c | 3105 +++++++++++++++++++++ src/chat.c | 1478 ++++++++++ src/checkin.c | 3333 ++++++++++++++++++++++ src/checkout.c | 655 +++++ src/ci_edit.js | 34 + src/clearsign.c | 85 + src/clone.c | 437 +++ src/color.c | 542 ++++ src/comformat.c | 855 ++++++ src/config.h | 281 ++ src/configure.c | 1157 ++++++++ src/content.c | 1336 +++++++++ src/cookies.c | 311 +++ src/copybtn.js | 99 + src/cygsup.h | 124 + src/db.c | 5733 ++++++++++++++++++++++++++++++++++++++ src/default.css | 2194 +++++++++++++++ src/delta.c | 729 +++++ src/deltacmd.c | 198 ++ src/deltafunc.c | 530 ++++ src/descendants.c | 725 +++++ src/diff.c | 4054 +++++++++++++++++++++++++++ src/diff.js | 73 + src/diff.tcl | 707 +++++ src/diffcmd.c | 1595 +++++++++++ src/dispatch.c | 1971 +++++++++++++ src/doc.c | 1277 +++++++++ src/encode.c | 934 +++++++ src/etag.c | 288 ++ src/event.c | 620 +++++ src/export.c | 1906 +++++++++++++ src/extcgi.c | 438 +++ src/file.c | 3166 +++++++++++++++++++++ src/fileedit.c | 2070 ++++++++++++++ src/finfo.c | 1015 +++++++ src/foci.c | 278 ++ src/forum.c | 3192 +++++++++++++++++++++ src/forum.js | 19 + src/fossil.attach.js | 663 +++++ src/fossil.bootstrap.js | 327 +++ src/fossil.confirmer.js | 331 +++ src/fossil.copybutton.js | 128 + src/fossil.diff.js | 817 ++++++ src/fossil.dom.js | 1028 +++++++ src/fossil.fetch.js | 317 +++ src/fossil.numbered-lines.js | 120 + src/fossil.page.brlist.js | 74 + src/fossil.page.chat.js | 2894 +++++++++++++++++++ src/fossil.page.fileedit.js | 1413 ++++++++++ src/fossil.page.forumpost.js | 1486 ++++++++++ src/fossil.page.pikchrshow.js | 623 +++++ src/fossil.page.pikchrshowasm.js | 743 +++++ src/fossil.page.ticket.js | 32 + src/fossil.page.whistory.js | 164 ++ src/fossil.page.wikiedit.js | 1659 +++++++++++ src/fossil.pikchr.js | 133 + src/fossil.popupwidget.js | 471 ++++ src/fossil.storage.js | 220 ++ src/fossil.tabs.js | 260 ++ src/fossil.wikiedit-wysiwyg.js | 490 ++++ src/fshell.c | 130 + src/fusefs.c | 367 +++ src/fuzz.c | 171 ++ src/glob.c | 295 ++ src/graph.c | 1084 +++++++ src/graph.js | 824 ++++++ src/gzip.c | 140 + src/hbmenu.js | 259 ++ src/hname.c | 284 ++ src/hook.c | 546 ++++ src/href.js | 67 + src/http.c | 1004 +++++++ src/http_socket.c | 247 ++ src/http_ssl.c | 1187 ++++++++ src/http_transport.c | 545 ++++ src/import.c | 2031 ++++++++++++++ src/info.c | 4557 ++++++++++++++++++++++++++++++ src/interwiki.c | 423 +++ src/json.c | 2445 ++++++++++++++++ src/json_artifact.c | 514 ++++ src/json_branch.c | 386 +++ src/json_config.c | 426 +++ src/json_detail.h | 273 ++ src/json_diff.c | 139 + src/json_dir.c | 294 ++ src/json_finfo.c | 152 + src/json_login.c | 273 ++ src/json_query.c | 96 + src/json_report.c | 263 ++ src/json_status.c | 198 ++ src/json_tag.c | 479 ++++ src/json_timeline.c | 769 +++++ src/json_user.c | 438 +++ src/json_wiki.c | 652 +++++ src/leaf.c | 284 ++ src/loadctrl.c | 89 + src/login.c | 2868 +++++++++++++++++++ src/login.js | 6 + src/lookslike.c | 596 ++++ src/main.c | 3849 +++++++++++++++++++++++++ src/main.mk | 2260 +++++++++++++++ src/manifest.c | 3254 ++++++++++++++++++++++ src/markdown.c | 2905 +++++++++++++++++++ src/markdown.md | 253 ++ src/markdown_html.c | 1013 +++++++ src/match.c | 443 +++ src/md5.c | 466 ++++ src/menu.js | 29 + src/merge.c | 1690 +++++++++++ src/merge.tcl | 642 +++++ src/merge3.c | 1232 ++++++++ src/moderate.c | 356 +++ src/name.c | 2671 ++++++++++++++++++ src/org.c | 664 +++++ src/patch.c | 1206 ++++++++ src/path.c | 756 +++++ src/piechart.c | 335 +++ src/pikchrshow.c | 574 ++++ src/pivot.c | 205 ++ src/popen.c | 230 ++ src/pqueue.c | 232 ++ src/printf.c | 1318 +++++++++ src/publish.c | 118 + src/purge.c | 662 +++++ src/rebuild.c | 1545 ++++++++++ src/regexp.c | 1171 ++++++++ src/repolist.c | 432 +++ src/report.c | 1403 ++++++++++ src/robot.c | 738 +++++ src/rss.c | 417 +++ src/schema.c | 641 +++++ src/scroll.js | 2 + src/search.c | 2905 +++++++++++++++++++ src/security_audit.c | 1105 ++++++++ src/setup.c | 2575 +++++++++++++++++ src/setupuser.c | 1192 ++++++++ src/sha1.c | 549 ++++ src/sha1hard.c | 1596 +++++++++++ src/sha3.c | 678 +++++ src/shun.c | 615 ++++ src/sitemap.c | 360 +++ src/skin.js | 8 + src/skins.c | 1442 ++++++++++ src/smtp.c | 699 +++++ src/sorttable.js | 172 ++ src/sounds/0.wav | Bin 0 -> 6044 bytes src/sounds/1.wav | Bin 0 -> 6044 bytes src/sounds/2.wav | Bin 0 -> 6044 bytes src/sounds/3.wav | Bin 0 -> 6044 bytes src/sounds/4.wav | Bin 0 -> 6044 bytes src/sounds/5.wav | Bin 0 -> 6044 bytes src/sounds/6.wav | Bin 0 -> 6044 bytes src/sounds/7.wav | Bin 0 -> 6044 bytes src/sounds/8.wav | Bin 0 -> 6044 bytes src/sounds/9.wav | Bin 0 -> 6044 bytes src/sounds/README.md | 14 + src/sounds/a.wav | Bin 0 -> 6044 bytes src/sounds/b.wav | Bin 0 -> 6044 bytes src/sounds/c.wav | Bin 0 -> 6044 bytes src/sounds/d.wav | Bin 0 -> 6044 bytes src/sounds/e.wav | Bin 0 -> 6044 bytes src/sounds/f.wav | Bin 0 -> 6044 bytes src/sqlcmd.c | 486 ++++ src/stash.c | 812 ++++++ src/stat.c | 1266 +++++++++ src/statrep.c | 1182 ++++++++ src/style.admin_log.css | 6 + src/style.c | 1898 +++++++++++++ src/style.chat.css | 710 +++++ src/style.fileedit.css | 243 ++ src/style.forum.css | 109 + src/style.pikchrshow.css | 178 ++ src/style.uvlist.css | 7 + src/style.wikiedit.css | 204 ++ src/sync.c | 928 ++++++ src/tag.c | 1072 +++++++ src/tar.c | 1377 +++++++++ src/terminal.c | 180 ++ src/th.c | 3094 ++++++++++++++++++++ src/th.h | 276 ++ src/th_lang.c | 1470 ++++++++++ src/th_main.c | 3323 ++++++++++++++++++++++ src/th_tcl.c | 1287 +++++++++ src/timeline.c | 4261 ++++++++++++++++++++++++++++ src/tkt.c | 1926 +++++++++++++ src/tktsetup.c | 1073 +++++++ src/tree.js | 72 + src/undo.c | 543 ++++ src/unicode.c | 463 +++ src/unversioned.c | 797 ++++++ src/update.c | 1060 +++++++ src/url.c | 861 ++++++ src/user.c | 835 ++++++ src/useredit.js | 38 + src/utf8.c | 411 +++ src/util.c | 1050 +++++++ src/verify.c | 130 + src/vfile.c | 1114 ++++++++ src/wiki.c | 2706 ++++++++++++++++++ src/wiki.wiki | 91 + src/wikiformat.c | 3179 +++++++++++++++++++++ src/winfile.c | 539 ++++ src/winhttp.c | 1447 ++++++++++ src/xfer.c | 3083 ++++++++++++++++++++ src/xfersetup.c | 244 ++ src/xsystem.c | 614 ++++ src/zip.c | 1144 ++++++++ 231 files changed, 201536 insertions(+) create mode 100644 src/Makefile create mode 100644 src/accordion.js create mode 100644 src/add.c create mode 100644 src/ajax.c create mode 100644 src/alerts.c create mode 100644 src/alerts/bflat2.wav create mode 100644 src/alerts/bflat3.wav create mode 100644 src/alerts/bloop.wav create mode 100644 src/alerts/mkwav.c create mode 100644 src/alerts/plunk.wav create mode 100644 src/allrepo.c create mode 100644 src/attach.c create mode 100644 src/backlink.c create mode 100644 src/backoffice.c create mode 100644 src/bag.c create mode 100644 src/bisect.c create mode 100644 src/blob.c create mode 100644 src/branch.c create mode 100644 src/browse.c create mode 100644 src/builtin.c create mode 100644 src/bundle.c create mode 100644 src/cache.c create mode 100644 src/capabilities.c create mode 100644 src/captcha.c create mode 100644 src/cgi.c create mode 100644 src/chat.c create mode 100644 src/checkin.c create mode 100644 src/checkout.c create mode 100644 src/ci_edit.js create mode 100644 src/clearsign.c create mode 100644 src/clone.c create mode 100644 src/color.c create mode 100644 src/comformat.c create mode 100644 src/config.h create mode 100644 src/configure.c create mode 100644 src/content.c create mode 100644 src/cookies.c create mode 100644 src/copybtn.js create mode 100644 src/cygsup.h create mode 100644 src/db.c create mode 100644 src/default.css create mode 100644 src/delta.c create mode 100644 src/deltacmd.c create mode 100644 src/deltafunc.c create mode 100644 src/descendants.c create mode 100644 src/diff.c create mode 100644 src/diff.js create mode 100644 src/diff.tcl create mode 100644 src/diffcmd.c create mode 100644 src/dispatch.c create mode 100644 src/doc.c create mode 100644 src/encode.c create mode 100644 src/etag.c create mode 100644 src/event.c create mode 100644 src/export.c create mode 100644 src/extcgi.c create mode 100644 src/file.c create mode 100644 src/fileedit.c create mode 100644 src/finfo.c create mode 100644 src/foci.c create mode 100644 src/forum.c create mode 100644 src/forum.js create mode 100644 src/fossil.attach.js create mode 100644 src/fossil.bootstrap.js create mode 100644 src/fossil.confirmer.js create mode 100644 src/fossil.copybutton.js create mode 100644 src/fossil.diff.js create mode 100644 src/fossil.dom.js create mode 100644 src/fossil.fetch.js create mode 100644 src/fossil.numbered-lines.js create mode 100644 src/fossil.page.brlist.js create mode 100644 src/fossil.page.chat.js create mode 100644 src/fossil.page.fileedit.js create mode 100644 src/fossil.page.forumpost.js create mode 100644 src/fossil.page.pikchrshow.js create mode 100644 src/fossil.page.pikchrshowasm.js create mode 100644 src/fossil.page.ticket.js create mode 100644 src/fossil.page.whistory.js create mode 100644 src/fossil.page.wikiedit.js create mode 100644 src/fossil.pikchr.js create mode 100644 src/fossil.popupwidget.js create mode 100644 src/fossil.storage.js create mode 100644 src/fossil.tabs.js create mode 100644 src/fossil.wikiedit-wysiwyg.js create mode 100644 src/fshell.c create mode 100644 src/fusefs.c create mode 100644 src/fuzz.c create mode 100644 src/glob.c create mode 100644 src/graph.c create mode 100644 src/graph.js create mode 100644 src/gzip.c create mode 100644 src/hbmenu.js create mode 100644 src/hname.c create mode 100644 src/hook.c create mode 100644 src/href.js create mode 100644 src/http.c create mode 100644 src/http_socket.c create mode 100644 src/http_ssl.c create mode 100644 src/http_transport.c create mode 100644 src/import.c create mode 100644 src/info.c create mode 100644 src/interwiki.c create mode 100644 src/json.c create mode 100644 src/json_artifact.c create mode 100644 src/json_branch.c create mode 100644 src/json_config.c create mode 100644 src/json_detail.h create mode 100644 src/json_diff.c create mode 100644 src/json_dir.c create mode 100644 src/json_finfo.c create mode 100644 src/json_login.c create mode 100644 src/json_query.c create mode 100644 src/json_report.c create mode 100644 src/json_status.c create mode 100644 src/json_tag.c create mode 100644 src/json_timeline.c create mode 100644 src/json_user.c create mode 100644 src/json_wiki.c create mode 100644 src/leaf.c create mode 100644 src/loadctrl.c create mode 100644 src/login.c create mode 100644 src/login.js create mode 100644 src/lookslike.c create mode 100644 src/main.c create mode 100644 src/main.mk create mode 100644 src/manifest.c create mode 100644 src/markdown.c create mode 100644 src/markdown.md create mode 100644 src/markdown_html.c create mode 100644 src/match.c create mode 100644 src/md5.c create mode 100644 src/menu.js create mode 100644 src/merge.c create mode 100644 src/merge.tcl create mode 100644 src/merge3.c create mode 100644 src/moderate.c create mode 100644 src/name.c create mode 100644 src/org.c create mode 100644 src/patch.c create mode 100644 src/path.c create mode 100644 src/piechart.c create mode 100644 src/pikchrshow.c create mode 100644 src/pivot.c create mode 100644 src/popen.c create mode 100644 src/pqueue.c create mode 100644 src/printf.c create mode 100644 src/publish.c create mode 100644 src/purge.c create mode 100644 src/rebuild.c create mode 100644 src/regexp.c create mode 100644 src/repolist.c create mode 100644 src/report.c create mode 100644 src/robot.c create mode 100644 src/rss.c create mode 100644 src/schema.c create mode 100644 src/scroll.js create mode 100644 src/search.c create mode 100644 src/security_audit.c create mode 100644 src/setup.c create mode 100644 src/setupuser.c create mode 100644 src/sha1.c create mode 100644 src/sha1hard.c create mode 100644 src/sha3.c create mode 100644 src/shun.c create mode 100644 src/sitemap.c create mode 100644 src/skin.js create mode 100644 src/skins.c create mode 100644 src/smtp.c create mode 100644 src/sorttable.js create mode 100644 src/sounds/0.wav create mode 100644 src/sounds/1.wav create mode 100644 src/sounds/2.wav create mode 100644 src/sounds/3.wav create mode 100644 src/sounds/4.wav create mode 100644 src/sounds/5.wav create mode 100644 src/sounds/6.wav create mode 100644 src/sounds/7.wav create mode 100644 src/sounds/8.wav create mode 100644 src/sounds/9.wav create mode 100644 src/sounds/README.md create mode 100644 src/sounds/a.wav create mode 100644 src/sounds/b.wav create mode 100644 src/sounds/c.wav create mode 100644 src/sounds/d.wav create mode 100644 src/sounds/e.wav create mode 100644 src/sounds/f.wav create mode 100644 src/sqlcmd.c create mode 100644 src/stash.c create mode 100644 src/stat.c create mode 100644 src/statrep.c create mode 100644 src/style.admin_log.css create mode 100644 src/style.c create mode 100644 src/style.chat.css create mode 100644 src/style.fileedit.css create mode 100644 src/style.forum.css create mode 100644 src/style.pikchrshow.css create mode 100644 src/style.uvlist.css create mode 100644 src/style.wikiedit.css create mode 100644 src/sync.c create mode 100644 src/tag.c create mode 100644 src/tar.c create mode 100644 src/terminal.c create mode 100644 src/th.c create mode 100644 src/th.h create mode 100644 src/th_lang.c create mode 100644 src/th_main.c create mode 100644 src/th_tcl.c create mode 100644 src/timeline.c create mode 100644 src/tkt.c create mode 100644 src/tktsetup.c create mode 100644 src/tree.js create mode 100644 src/undo.c create mode 100644 src/unicode.c create mode 100644 src/unversioned.c create mode 100644 src/update.c create mode 100644 src/url.c create mode 100644 src/user.c create mode 100644 src/useredit.js create mode 100644 src/utf8.c create mode 100644 src/util.c create mode 100644 src/verify.c create mode 100644 src/vfile.c create mode 100644 src/wiki.c create mode 100644 src/wiki.wiki create mode 100644 src/wikiformat.c create mode 100644 src/winfile.c create mode 100644 src/winhttp.c create mode 100644 src/xfer.c create mode 100644 src/xfersetup.c create mode 100644 src/xsystem.c create mode 100644 src/zip.c (limited to 'src') diff --git a/src/Makefile b/src/Makefile new file mode 100644 index 0000000..c1b1434 --- /dev/null +++ b/src/Makefile @@ -0,0 +1,3 @@ +all clean: + $(MAKE) -C .. $(MAKECMDGOALS) + diff --git a/src/accordion.js b/src/accordion.js new file mode 100644 index 0000000..f40d367 --- /dev/null +++ b/src/accordion.js @@ -0,0 +1,89 @@ +/* +** Attach appropriate javascript to each ".accordion" button so that it expands +** and contracts when clicked. +** +** The uncompressed source code for the SVG icons can be found on the wiki page +** "branch/accordion-experiments" in the Fossil repository. +** +** Implementation notes: +** +** The `maxHeight' CSS property is quite restrictive for vertical resizing of +** elements, especially for dynamic-content areas like the diff panels. That's +** why `maxHeight' is set only during animation, to prevent truncated elements. +** (The diff panels may get truncated right after page loading, and other +** elements may get truncated when resizing the browser window to a smaller +** width, causing vertical growth.) +** +** Another problem is that `scrollHeight' used to calculate the expanded height +** while still in the contracted state may return values with small errors on +** some browsers, especially for large elements, presumably due to omitting the +** space required by the vertical scrollbar that may become necessary, causing +** additional horizontal shrinking and consequently more vertical growth than +** calculated. That's why setting `maxHeight' to `scrollHeight' is considered +** "good enough" only during animation, but cleared afterwards. +** +** https://fossil-scm.org/forum/forumpost/66d7075f40 +** https://fossil-scm.org/home/timeline?r=accordion-fix +** +** To work around the missing support for `overflow-y: clip', this script uses a +** fallback and sets `overflow-y: hidden' during the accordion panel animations. +** That's because if `overflow-y: hidden' is set statically from the stylesheet, +** the shadow of the selected or current timeline entries in the context section +** of `/info' pages is still truncated on the right (which is strange, as that's +** not the "y" direction) in all major browsers. Otherwise, the stylesheet might +** define the fallback using the `@supports(…)' at-rule: +** +** .accordion_panel { +** overflow-y: hidden; +** } +** @supports(overflow-y: clip) { +** .accordion_panel { +** overflow-y: clip; +** } +** } +*/ +var acc_svgdata = ["data:image/svg+xml,"+ + "%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E"+ + "%3Cpath style='fill:black;opacity:0' d='M16,16H0V0h16v16z'/%3E"+ + "%3Cpath style='fill:rgb(240,240,240)' d='M14,14H2V2h12v12z'/%3E"+ + "%3Cpath style='fill:rgb(64,64,64)' d='M13,13H3V3h10v10z'/%3E"+ + "%3Cpath style='fill:rgb(248,248,248)' d='M12,12H4V4h8v8z'/%3E"+ + "%3Cpath style='fill:rgb(80,128,208)' d='", "'/%3E%3C/svg%3E", + "M5,7h2v-2h2v2h2v2h-2v2h-2v-2h-2z", "M11,9H5V7h6v6z"]; +var a = document.getElementsByClassName("accordion"); +for(var i=0; i elements that represent + ** section headers). */ + var xClickyHTML = /^(?:A|AREA|BUTTON|INPUT|LABEL|SELECT|TEXTAREA|DETAILS)$/; + if( xClickyHTML.test(evt.target.tagName) ) return; + var x = this.nextElementSibling; + if( this.classList.contains("accordion_closed") ){ + x.style.maxHeight = x.scrollHeight + "px"; + setTimeout(function(){ + x.style.maxHeight = ""; + if( !window.CSS || !window.CSS.supports("overflow: clip") ){ + x.style.overflowY = ""; + } + },250); // default.css: .accordion_panel { transition-duration } + }else{ + x.style.maxHeight = x.scrollHeight + "px"; + if( !window.CSS || !window.CSS.supports("overflow: clip") ){ + x.style.overflowY = "hidden"; + } + setTimeout(function(){ + x.style.maxHeight = "0"; + },1); + } + this.classList.toggle("accordion_closed"); + }); +} diff --git a/src/add.c b/src/add.c new file mode 100644 index 0000000..18bcf58 --- /dev/null +++ b/src/add.c @@ -0,0 +1,1163 @@ +/* +** Copyright (c) 2007 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 used to check-out versions of the project +** from the local repository. +*/ +#include "config.h" +#include "add.h" +#include +#include +#include "cygsup.h" + +/* +** This routine returns the names of files in a working check-out that +** are created by Fossil itself, and hence should not be added, deleted, +** or merge, and should be omitted from "clean" and "extras" lists. +** +** Return the N-th name. The first name has N==0. When all names have +** been used, return 0. +*/ +const char *fossil_reserved_name(int N, int omitRepo){ + /* Possible names of the local per-check-out database file and + ** its associated journals + */ + static const char *const azName[] = { + "_FOSSIL_", + "_FOSSIL_-journal", + "_FOSSIL_-wal", + "_FOSSIL_-shm", + ".fslckout", + ".fslckout-journal", + ".fslckout-wal", + ".fslckout-shm", + + /* The use of ".fos" as the name of the check-out database is + ** deprecated. Use ".fslckout" instead. At some point, the following + ** entries should be removed. 2012-02-04 */ + ".fos", + ".fos-journal", + ".fos-wal", + ".fos-shm", + }; + + /* Possible names of auxiliary files generated when the "manifest" property + ** is used + */ + static const struct { + const char *fname; + int flg; + }aManifestflags[] = { + { "manifest", MFESTFLG_RAW }, + { "manifest.uuid", MFESTFLG_UUID }, + { "manifest.tags", MFESTFLG_TAGS } + }; + static const char *azManifests[3]; + + /* + ** Names of repository files, if they exist in the check-out. + */ + static const char *azRepo[4] = { 0, 0, 0, 0 }; + + /* Cached setting "manifest" */ + static int cachedManifest = -1; + static int numManifests; + + if( cachedManifest == -1 ){ + int i; + Blob repo; + cachedManifest = db_get_manifest_setting(0); + numManifests = 0; + for(i=0; i0 ) blob_append(&x, ",", 1); + blob_appendf(&x, "'%q'", z); + } + zAll = blob_str(&x); + } + return zAll; +} + +/* +** COMMAND: test-reserved-names +** +** Usage: %fossil test-reserved-names [-omitrepo] +** +** Show all reserved filenames for the current check-out. +*/ +void test_reserved_names(void){ + int i; + const char *z; + int omitRepo = find_option("omitrepo",0,0)!=0; + + /* We should be done with options.. */ + verify_all_options(); + + db_must_be_within_tree(); + for(i=0; (z = fossil_reserved_name(i, omitRepo))!=0; i++){ + fossil_print("%3d: %s\n", i, z); + } + fossil_print("ALL: (%s)\n", fossil_all_reserved_names(omitRepo)); +} + +/* +** Add a single file named zName to the VFILE table with vid. +** +** Omit any file whose name is pOmit. +*/ +static int add_one_file( + const char *zPath, /* Tree-name of file to add. */ + int vid /* Add to this VFILE */ +){ + int doSkip = 0; + if( !file_is_simple_pathname(zPath, 1) ){ + fossil_warning("filename contains illegal characters: %s", zPath); + return 0; + } + if( db_exists("SELECT 1 FROM vfile" + " WHERE pathname=%Q %s", zPath, filename_collation()) ){ + db_multi_exec("UPDATE vfile SET deleted=0" + " WHERE pathname=%Q %s AND deleted", + zPath, filename_collation()); + }else{ + char *zFullname = mprintf("%s%s", g.zLocalRoot, zPath); + int isExe = file_isexe(zFullname, RepoFILE); + int isLink = file_islink(0); + if( file_nondir_objects_on_path(g.zLocalRoot, zFullname) ){ + /* Do not add unsafe files to the vfile */ + doSkip = 1; + }else{ + db_multi_exec( + "INSERT INTO vfile(vid,deleted,rid,mrid,pathname,isexe,islink,mhash)" + "VALUES(%d,0,0,0,%Q,%d,%d,NULL)", + vid, zPath, isExe, isLink); + } + fossil_free(zFullname); + } + if( db_changes() && !doSkip ){ + fossil_print("ADDED %s\n", zPath); + return 1; + }else{ + fossil_print("SKIP %s\n", zPath); + return 0; + } +} + +/* +** Add all files in the sfile temp table. +** +** Automatically exclude the repository file and any other files +** with reserved names. Also exclude files that are beneath an +** existing symlink. +*/ +static int add_files_in_sfile(int vid){ + const char *zRepo; /* Name of the repository database file */ + int nAdd = 0; /* Number of files added */ + int i; /* Loop counter */ + const char *zReserved; /* Name of a reserved file */ + Blob repoName; /* Treename of the repository */ + Stmt loop; /* SQL to loop over all files to add */ + int (*xCmp)(const char*,const char*); + + if( !file_tree_name(g.zRepositoryName, &repoName, 0, 0) ){ + blob_zero(&repoName); + zRepo = ""; + }else{ + zRepo = blob_str(&repoName); + } + if( filenames_are_case_sensitive() ){ + xCmp = fossil_strcmp; + }else{ + xCmp = fossil_stricmp; + } + db_prepare(&loop, + "SELECT pathname FROM sfile" + " WHERE pathname NOT IN (" + "SELECT sfile.pathname FROM vfile, sfile" + " WHERE vfile.islink" + " AND NOT vfile.deleted" + " AND sfile.pathname>(vfile.pathname||'/')" + " AND sfile.pathname<(vfile.pathname||'0'))" + " ORDER BY pathname"); + while( db_step(&loop)==SQLITE_ROW ){ + const char *zToAdd = db_column_text(&loop, 0); + if( fossil_strcmp(zToAdd, zRepo)==0 ) continue; + if( strchr(zToAdd,'/') ){ + if( file_is_reserved_name(zToAdd, -1) ) continue; + }else{ + for(i=0; (zReserved = fossil_reserved_name(i, 0))!=0; i++){ + if( xCmp(zToAdd, zReserved)==0 ) break; + } + if( zReserved ) continue; + } + nAdd += add_one_file(zToAdd, vid); + } + db_finalize(&loop); + blob_reset(&repoName); + return nAdd; +} + +/* +** Resets the ADDED/DELETED state of a check-out, such that all +** newly-added (but not yet committed) files are no longer added and +** newly-removed (but not yet committed) files are no longer +** removed. If bIsAdd is true, it operates on the "add" state, else it +** operates on the "rm" state. +** +** If bDryRun is true it outputs what it would have done, but does not +** actually do it. In this case it rolls back the transaction it +** starts (so don't start a transaction before calling this). +** +** If bVerbose is true it outputs the name of each reset entry. +** +** This is intended to be called only in the context of the +** add/rm/addremove commands, after a call to verify_all_options(). +** +** Un-added files are not modified but any un-rm'd files which are +** missing from the check-out are restored from the repo. un-rm'd files +** which exist in the check-out are left as-is, rather than restoring +** them using vfile_to_disk(), to avoid overwriting any local changes +** made to those files. +*/ +static void addremove_reset(int bIsAdd, int bDryRun, int bVerbose){ + int nReset = 0; /* # of entries which get reset */ + Stmt stmt; /* vfile loop query */ + + db_begin_transaction(); + db_prepare(&stmt, "SELECT id, pathname FROM vfile " + "WHERE %s ORDER BY pathname", + bIsAdd==0 ? "deleted<>0" : "rid=0"/*safe-for-%s*/); + while( db_step(&stmt)==SQLITE_ROW ){ + /* This loop exists only so we can restore the contents of un-rm'd + ** files and support verbose mode. All manipulation of vfile's + ** contents happens after the loop. For the ADD case in non-verbose + ** mode we "could" skip this loop entirely. + */ + int const id = db_column_int(&stmt, 0); + char const * zPathname = db_column_text(&stmt, 1); + Blob relName = empty_blob; + if(bIsAdd==0 || bVerbose!=0){ + /* Make filename relative... */ + char *zFullName = mprintf("%s%s", g.zLocalRoot, zPathname); + file_relative_name(zFullName, &relName, 0); + fossil_free(zFullName); + } + if(bIsAdd==0){ + /* Restore contents of missing un-rm'd files. We don't do this + ** unconditionally because we might cause data loss if a file + ** is modified, rm'd, then un-rm'd. + */ + ++nReset; + if(!file_isfile_or_link(blob_str(&relName))){ + if(bDryRun==0){ + vfile_to_disk(0, id, 0, 0); + if(bVerbose){ + fossil_print("Restored missing file: %b\n", &relName); + } + }else{ + fossil_print("Dry-run: not restoring missing file: %b\n", &relName); + } + } + if(bVerbose){ + fossil_print("Un-removed: %b\n", &relName); + } + }else{ + /* un-add... */ + ++nReset; + if(bVerbose){ + fossil_print("Un-added: %b\n", &relName); + } + } + blob_reset(&relName); + } + db_finalize(&stmt); + if(nReset>0){ + if(bIsAdd==0){ + if(bDryRun==0){ + db_exec_sql("UPDATE vfile SET deleted=0 WHERE deleted<>0"); + } + fossil_print("Un-removed %d file(s).\n", nReset); + }else{ + if(bDryRun==0){ + db_exec_sql("DELETE FROM vfile WHERE rid=0"); + } + fossil_print("Un-added %d file(s).\n", nReset); + } + } + db_end_transaction(bDryRun ? 1 : 0); +} + + +/* +** COMMAND: add +** +** Usage: %fossil add ?OPTIONS? FILE1 ?FILE2 ...? +** +** Make arrangements to add one or more files or directories to the +** current check-out at the next [[commit]]. +** +** When adding files or directories recursively, filenames that begin +** with "." are excluded by default. To include such files, add +** the "--dotfiles" option to the command-line. +** +** The --ignore and --clean options are comma-separated lists of glob patterns +** for files to be excluded. Example: '*.o,*.obj,*.exe' If the --ignore +** option does not appear on the command line then the "ignore-glob" setting +** is used. If the --clean option does not appear on the command line then +** the "clean-glob" setting is used. +** +** When attempting to explicitly add files on the command line, and if those +** match "ignore-glob", a confirmation is asked first. This can be prevented +** using the -f|--force option. +** +** The --case-sensitive option determines whether or not filenames should +** be treated case sensitive or not. If the option is not given, the default +** depends on the global setting, or the operating system default, if not set. +** +** Options: +** --case-sensitive BOOL Override the case-sensitive setting +** --dotfiles Include files beginning with a dot (".") +** -f|--force Add files without prompting +** --ignore CSG Ignore unmanaged files matching patterns from +** the Comma Separated Glob (CSG) pattern list +** --clean CSG Also ignore files matching patterns from +** the Comma Separated Glob (CSG) list +** --reset Reset the ADDED state of a check-out, such +** that all newly-added (but not yet committed) +** files are no longer added. No flags other +** than --verbose and --dry-run may be used +** with --reset. +** --allow-reserved Permit filenames which are reserved on +** Windows platforms. Such files cannot be +** checked out on Windows, so use with care. +** +** The following options are only valid with --reset: +** -v|--verbose Output information about each --reset file +** -n|--dry-run Display instead of run actions +** +** See also: [[addremove]], [[rm]] +*/ +void add_cmd(void){ + int i; /* Loop counter */ + int vid; /* Currently checked-out version */ + int nRoot; /* Full path characters in g.zLocalRoot */ + const char *zCleanFlag; /* The --clean option or clean-glob setting */ + const char *zIgnoreFlag; /* The --ignore option or ignore-glob setting */ + Glob *pIgnore, *pClean; /* Ignore everything matching the glob patterns */ + unsigned scanFlags = 0; /* Flags passed to vfile_scan() */ + int forceFlag; + int allowReservedFlag = 0; /* --allow-reserved flag */ + + if(0!=find_option("reset",0,0)){ + int const verboseFlag = find_option("verbose","v",0)!=0; + int const dryRunFlag = find_option("dry-run","n",0)!=0; + db_must_be_within_tree(); + verify_all_options(); + addremove_reset(1, dryRunFlag, verboseFlag); + return; + } + + zCleanFlag = find_option("clean",0,1); + zIgnoreFlag = find_option("ignore",0,1); + forceFlag = find_option("force","f",0)!=0; + if( find_option("dotfiles",0,0)!=0 ) scanFlags |= SCAN_ALL; + allowReservedFlag = find_option("allow-reserved",0,0)!=0; + + /* We should be done with options.. */ + verify_all_options(); + + db_must_be_within_tree(); + if( zCleanFlag==0 ){ + zCleanFlag = db_get("clean-glob", 0); + } + if( zIgnoreFlag==0 ){ + zIgnoreFlag = db_get("ignore-glob", 0); + } + if( db_get_boolean("dotfiles", 0) ) scanFlags |= SCAN_ALL; + vid = db_lget_int("checkout",0); + db_begin_transaction(); + db_multi_exec("CREATE TEMP TABLE sfile(pathname TEXT PRIMARY KEY %s)", + filename_collation()); + pClean = glob_create(zCleanFlag); + pIgnore = glob_create(zIgnoreFlag); + nRoot = strlen(g.zLocalRoot); + + /* Load the names of all files that are to be added into sfile temp table */ + for(i=2; i'%q/' %s AND pathname<'%q0' %s))" + " AND NOT deleted", + zTreeName, filename_collation(), zTreeName, + filename_collation(), zTreeName, filename_collation() + ); + blob_reset(&treeName); + } + + db_prepare(&loop, "SELECT pathname FROM sfile"); + while( db_step(&loop)==SQLITE_ROW ){ + fossil_print("DELETED %s\n", db_column_text(&loop, 0)); + if( removeFiles ) add_file_to_remove(db_column_text(&loop, 0)); + } + db_finalize(&loop); + if( !dryRunFlag ){ + db_multi_exec( + "UPDATE vfile SET deleted=1 WHERE pathname IN sfile;" + "DELETE FROM vfile WHERE rid=0 AND deleted;" + ); + } + db_end_transaction(0); + if( removeFiles ) process_files_to_remove(dryRunFlag); +} + +/* +** Capture the command-line --case-sensitive option. +*/ +static const char *zCaseSensitive = 0; +void capture_case_sensitive_option(void){ + if( zCaseSensitive==0 ){ + zCaseSensitive = find_option("case-sensitive",0,1); + } +} + +/* +** This routine determines if files should be case-sensitive or not. +** In other words, this routine determines if two filenames that +** differ only in case should be considered the same name or not. +** +** The case-sensitive setting determines the default value. If +** the case-sensitive setting is undefined, then case sensitivity +** defaults off for Cygwin, Mac and Windows and on for all other unix. +** If case-sensitivity is enabled in the windows kernel, the Cygwin port +** of fossil.exe can detect that, and modifies the default to 'on'. +** +** The "--case-sensitive BOOL" command-line option overrides any +** setting. +*/ +int filenames_are_case_sensitive(void){ + static int caseSensitive; + static int once = 1; + + if( once ){ + once = 0; + if( zCaseSensitive ){ + caseSensitive = is_truth(zCaseSensitive); + }else{ +#if defined(_WIN32) || defined(__DARWIN__) || defined(__APPLE__) + caseSensitive = 0; /* Mac and Windows */ +#elif defined(__CYGWIN__) + /* Cygwin can be configured to be case-sensitive, check this. */ + void *hKey; + int value = 1, length = sizeof(int); + caseSensitive = 0; /* Cygwin default */ + if( (RegOpenKeyExW((void *)0x80000002, L"SYSTEM\\CurrentControlSet\\" + "Control\\Session Manager\\kernel", 0, 1, (void *)&hKey) + == 0) && (RegQueryValueExW(hKey, L"obcaseinsensitive", + 0, NULL, (void *)&value, (void *)&length) == 0) && !value ){ + caseSensitive = 1; + } +#else + caseSensitive = 1; /* Unix */ +#endif + caseSensitive = db_get_boolean("case-sensitive",caseSensitive); + } + if( !caseSensitive && g.localOpen ){ + db_multi_exec( + "CREATE INDEX IF NOT EXISTS localdb.vfile_nocase" + " ON vfile(pathname COLLATE nocase)" + ); + } + } + return caseSensitive; +} + +/* +** Return one of two things: +** +** "" (empty string) if filenames are case sensitive +** +** "COLLATE nocase" if filenames are not case sensitive. +*/ +const char *filename_collation(void){ + return filenames_are_case_sensitive() ? "" : "COLLATE nocase"; +} + +/* +** COMMAND: addremove +** +** Usage: %fossil addremove ?OPTIONS? +** +** Do all necessary "[[add]]" and "[[rm]]" commands to synchronize the +** repository with the content of the working check-out: +** +** * All files in the check-out but not in the repository (that is, +** all files displayed using the "extras" command) are added as +** if by the "[[add]]" command. +** +** * All files in the repository but missing from the check-out (that is, +** all files that show as MISSING with the "status" command) are +** removed as if by the "[[rm]]" command. +** +** Note that this command does not "commit", as that is a separate step. +** +** Files and directories whose names begin with "." are ignored unless +** the --dotfiles option is used. +** +** The --ignore option overrides the "ignore-glob" setting, as do the +** --case-sensitive option with the "case-sensitive" setting and the +** --clean option with the "clean-glob" setting. See the documentation +** on the "settings" command for further information. +** +** The -n|--dry-run option shows what would happen without actually doing +** anything. +** +** This command can be used to track third party software. +** +** Options: +** --case-sensitive BOOL Override the case-sensitive setting +** --dotfiles Include files beginning with a dot (".") +** --ignore CSG Ignore unmanaged files matching patterns from +** the Comma Separated Glob (CSG) list +** --clean CSG Also ignore files matching patterns from +** the Comma Separated Glob (CSG) list +** -n|--dry-run If given, display instead of run actions +** --reset Reset the ADDED/DELETED state of a check-out, +** such that all newly-added (but not yet committed) +** files are no longer added and all newly-removed +** (but not yet committed) files are no longer +** removed. No flags other than --verbose and +** --dry-run may be used with --reset. +** -v|--verbose Outputs information about each --reset file. +** Only usable with --reset. +** +** See also: [[add]], [[rm]] +*/ +void addremove_cmd(void){ + Blob path; + const char *zCleanFlag; + const char *zIgnoreFlag; + unsigned scanFlags; + int dryRunFlag = find_option("dry-run","n",0)!=0; + int n; + Stmt q; + int vid; + int nAdd = 0; + int nDelete = 0; + Glob *pIgnore, *pClean; + + if( !dryRunFlag ){ + dryRunFlag = find_option("test",0,0)!=0; /* deprecated */ + } + + if(0!=find_option("reset",0,0)){ + int const verboseFlag = find_option("verbose","v",0)!=0; + db_must_be_within_tree(); + verify_all_options(); + addremove_reset(0, dryRunFlag, verboseFlag); + addremove_reset(1, dryRunFlag, verboseFlag); + return; + } + + zCleanFlag = find_option("clean",0,1); + zIgnoreFlag = find_option("ignore",0,1); + scanFlags = find_option("dotfiles",0,0)!=0 ? SCAN_ALL : 0; + + /* We should be done with options.. */ + verify_all_options(); + + /* Fail if unprocessed arguments are present, in case user expect the + ** addremove command to accept a list of file or directory. + */ + if( g.argc>2 ){ + fossil_fatal( + "%s: Can only work on the entire check-out, no arguments supported.", + g.argv[1]); + } + db_must_be_within_tree(); + if( zCleanFlag==0 ){ + zCleanFlag = db_get("clean-glob", 0); + } + if( zIgnoreFlag==0 ){ + zIgnoreFlag = db_get("ignore-glob", 0); + } + if( db_get_boolean("dotfiles", 0) ) scanFlags |= SCAN_ALL; + vid = db_lget_int("checkout",0); + db_begin_transaction(); + + /* step 1: + ** Populate the temp table "sfile" with the names of all unmanaged + ** files currently in the check-out, except for files that match the + ** --ignore or ignore-glob patterns and dot-files. Then add all of + ** the files in the sfile temp table to the set of managed files. + */ + db_multi_exec("CREATE TEMP TABLE sfile(pathname TEXT PRIMARY KEY %s)", + filename_collation()); + n = strlen(g.zLocalRoot); + blob_init(&path, g.zLocalRoot, n-1); + /* now we read the complete file structure into a temp table */ + pClean = glob_create(zCleanFlag); + pIgnore = glob_create(zIgnoreFlag); + vfile_scan(&path, blob_size(&path), scanFlags, pClean, pIgnore, RepoFILE); + glob_free(pIgnore); + glob_free(pClean); + nAdd = add_files_in_sfile(vid); + + /* step 2: search for missing files */ + db_prepare(&q, + "SELECT pathname, %Q || pathname, deleted FROM vfile" + " WHERE NOT deleted" + " ORDER BY 1", + g.zLocalRoot + ); + while( db_step(&q)==SQLITE_ROW ){ + const char *zFile; + const char *zPath; + + zFile = db_column_text(&q, 0); + zPath = db_column_text(&q, 1); + if( !file_isfile_or_link(zPath) ){ + if( !dryRunFlag ){ + db_multi_exec("UPDATE vfile SET deleted=1 WHERE pathname=%Q", zFile); + } + fossil_print("DELETED %s\n", zFile); + nDelete++; + } + } + db_finalize(&q); + /* show command summary */ + fossil_print("added %d files, deleted %d files\n", nAdd, nDelete); + if(dryRunFlag!=0){ + fossil_print("Dry-run mode: no changes were made.\n"); + } + db_end_transaction(dryRunFlag); +} + +/* +** Rename a single file. +** +** The original name of the file is zOrig. The new filename is zNew. +*/ +static void mv_one_file( + int vid, + const char *zOrig, + const char *zNew, + int dryRunFlag, + int moveFiles +){ + int x = db_int(-1, "SELECT deleted FROM vfile WHERE pathname=%Q %s", + zNew, filename_collation()); + if( x>=0 ){ + if( x==0 ){ + if( !filenames_are_case_sensitive() && fossil_stricmp(zOrig,zNew)==0 ){ + /* Case change only */ + }else{ + fossil_fatal("cannot rename '%s' to '%s' since another file named '%s'" + " is currently under management", zOrig, zNew, zNew); + } + }else{ + fossil_fatal("cannot rename '%s' to '%s' since the delete of '%s' has " + "not yet been committed", zOrig, zNew, zNew); + } + } + if( moveFiles ){ + if( file_size(zNew, ExtFILE) != -1 ){ + fossil_fatal("cannot rename '%s' to '%s' on disk since another file" + " named '%s' already exists", zOrig, zNew, zNew); + } + } + fossil_print("RENAME %s %s\n", zOrig, zNew); + if( !dryRunFlag ){ + db_multi_exec( + "UPDATE vfile SET pathname='%q' WHERE pathname='%q' %s AND vid=%d", + zNew, zOrig, filename_collation(), vid + ); + } +} + +/* +** This function adds a file to list of files to move on disk after the +** other actions required for the parent operation have completed +** successfully. The first time it is called for the current process, +** it creates a temporary table named "fmove", to keep track of these +** files. +*/ +static void add_file_to_move( + const char *zOldName, /* The old name of the file on disk. */ + const char *zNewName /* The new name of the file on disk. */ +){ + static int tableCreated = 0; + Blob fullOldName; + Blob fullNewName; + char *zOld, *zNew; + if( !tableCreated ){ + db_multi_exec("CREATE TEMP TABLE fmove(x TEXT PRIMARY KEY %s, y TEXT %s)", + filename_collation(), filename_collation()); + tableCreated = 1; + } + file_tree_name(zOldName, &fullOldName, 1, 1); + zOld = blob_str(&fullOldName); + file_tree_name(zNewName, &fullNewName, 1, 1); + zNew = blob_str(&fullNewName); + if( filenames_are_case_sensitive() || fossil_stricmp(zOld,zNew)!=0 ){ + db_multi_exec("INSERT INTO fmove VALUES('%q','%q');", zOld, zNew); + } + blob_reset(&fullNewName); + blob_reset(&fullOldName); +} + +/* +** This function moves files within the check-out, using the file names +** contained in the temporary table "fmove". The temporary table is +** created on demand by the add_file_to_move() function. +** +** If dryRunFlag is non-zero, no files will be moved; however, their +** names will still be output. +** +** The temporary table "fmove" is dropped after being processed. +*/ +static void process_files_to_move( + int dryRunFlag /* Zero to actually operate on the file-system. */ +){ + Stmt move; + if( db_table_exists("temp", "fmove") ){ + db_prepare(&move, "SELECT x, y FROM fmove ORDER BY x;"); + while( db_step(&move)==SQLITE_ROW ){ + const char *zOldName = db_column_text(&move, 0); + const char *zNewName = db_column_text(&move, 1); + if( !dryRunFlag ){ + int isOldDir = file_isdir(zOldName, RepoFILE); + if( isOldDir==1 ){ + int isNewDir = file_isdir(zNewName, RepoFILE); + if( isNewDir==0 ){ + file_rename(zOldName, zNewName, isOldDir, isNewDir); + } + }else{ + if( file_islink(zOldName) ){ + symlink_copy(zOldName, zNewName); + }else{ + file_copy(zOldName, zNewName); + } + file_delete(zOldName); + } + } + fossil_print("MOVED_FILE %s\n", zOldName); + } + db_finalize(&move); + db_multi_exec("DROP TABLE fmove;"); + } +} + +/* +** COMMAND: mv +** COMMAND: rename* +** +** Usage: %fossil mv|rename ?OPTIONS? OLDNAME NEWNAME +** or: %fossil mv|rename ?OPTIONS? OLDNAME... DIR +** +** Move or rename one or more files or directories within the repository tree. +** You can either rename a file or directory or move it to another subdirectory. +** +** The 'mv' command does NOT normally rename or move the files on disk. +** This command merely records the fact that file names have changed so +** that appropriate notations can be made at the next [[commit]]. +** However, the default behavior of this command may be overridden via +** command line options listed below and/or the 'mv-rm-files' setting. +** +** The 'rename' command never renames or moves files on disk, even when the +** command line options and/or the 'mv-rm-files' setting would otherwise +** require it to do so. +** +** WARNING: If the "--hard" option is specified -OR- the "mv-rm-files" +** setting is non-zero, files WILL BE renamed or moved on disk +** as well. This does NOT apply to the 'rename' command. +** +** Options: +** --soft Skip moving files within the check-out. +** This supersedes the --hard option. +** --hard Move files within the check-out +** --case-sensitive BOOL Override the case-sensitive setting +** -n|--dry-run If given, display instead of run actions +** +** See also: [[changes]], [[status]] +*/ +void mv_cmd(void){ + int i; + int vid; + int moveFiles; + int dryRunFlag; + int softFlag; + int hardFlag; + int origType; + int destType; + char *zDest; + Blob dest; + Stmt q; + + db_must_be_within_tree(); + dryRunFlag = find_option("dry-run","n",0)!=0; + softFlag = find_option("soft",0,0)!=0; + hardFlag = find_option("hard",0,0)!=0; + + /* We should be done with options.. */ + verify_all_options(); + + vid = db_lget_int("checkout", 0); + if( vid==0 ){ + fossil_fatal("no check-out in which to rename files"); + } + if( g.argc<4 ){ + usage("OLDNAME NEWNAME"); + } + zDest = file_case_preferred_name(".",g.argv[g.argc-1]); + db_begin_transaction(); + if( g.argv[1][0]=='r' ){ /* i.e. "rename" */ + moveFiles = 0; + }else if( softFlag ){ + moveFiles = 0; + }else if( hardFlag ){ + moveFiles = 1; + }else{ + moveFiles = db_get_boolean("mv-rm-files",0); + } + file_tree_name(zDest, &dest, 0, 1); + db_multi_exec( + "UPDATE vfile SET origname=pathname WHERE origname IS NULL;" + ); + db_multi_exec( + "CREATE TEMP TABLE mv(f TEXT UNIQUE ON CONFLICT IGNORE, t TEXT);" + ); + if( g.argc!=4 ){ + origType = -1; + }else{ + origType = (file_isdir(g.argv[2], RepoFILE) == 1); + } + destType = file_isdir(zDest, RepoFILE); + if( origType==-1 && destType!=1 ){ + usage("OLDNAME NEWNAME"); + }else if( origType==1 && destType==2 ){ + fossil_fatal("cannot rename '%s' to '%s' since another file named" + " '%s' exists", g.argv[2], zDest, zDest); + }else if( origType==0 && destType!=1 ){ + Blob orig; + file_tree_name(g.argv[2], &orig, 0, 1); + db_multi_exec( + "INSERT INTO mv VALUES(%B,%B)", &orig, &dest + ); + }else{ + if( blob_eq(&dest, ".") ){ + blob_reset(&dest); + }else{ + blob_append(&dest, "/", 1); + } + for(i=2; i'%q/' %s AND pathname<'%q0' %s))" + " ORDER BY 1", + vid, zOrig, filename_collation(), zOrig, filename_collation(), + zOrig, filename_collation() + ); + while( db_step(&q)==SQLITE_ROW ){ + const char *zPath = db_column_text(&q, 0); + int nPath = db_column_bytes(&q, 0); + const char *zTail; + if( nPath==nOrig ){ + zTail = file_tail(zPath); + }else if( origType!=0 && destType==1 ){ + zTail = &zPath[nOrig-strlen(file_tail(zOrig))]; + }else{ + zTail = &zPath[nOrig+1]; + } + db_multi_exec( + "INSERT INTO mv VALUES('%q','%q%q')", + zPath, blob_str(&dest), zTail + ); + } + db_finalize(&q); + } + } + db_prepare(&q, "SELECT f, t FROM mv ORDER BY f"); + while( db_step(&q)==SQLITE_ROW ){ + const char *zFrom = db_column_text(&q, 0); + const char *zTo = db_column_text(&q, 1); + mv_one_file(vid, zFrom, zTo, dryRunFlag, moveFiles); + if( moveFiles ) add_file_to_move(zFrom, zTo); + } + db_finalize(&q); + undo_reset(); + db_end_transaction(0); + if( moveFiles ) process_files_to_move(dryRunFlag); + fossil_free(zDest); +} + +/* +** Function for stash_apply to be able to restore a file and indicate +** newly ADDED state. +*/ +int stash_add_files_in_sfile(int vid){ + return add_files_in_sfile(vid); +} diff --git a/src/ajax.c b/src/ajax.c new file mode 100644 index 0000000..97dab94 --- /dev/null +++ b/src/ajax.c @@ -0,0 +1,487 @@ +/* +** Copyright (c) 2020 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 shared Ajax-related code for /fileedit, the wiki/forum +** editors, and friends. +*/ +#include "config.h" +#include "ajax.h" +#include +#include + +#if INTERFACE +/* enum ajax_render_preview_flags: */ +#define AJAX_PREVIEW_LINE_NUMBERS 1 +/* enum ajax_render_modes: */ +#define AJAX_RENDER_GUESS 0 /* Guess rendering mode based on mimetype. */ +/* GUESS must be 0. All others have unspecified values. */ +#define AJAX_RENDER_PLAIN_TEXT 1 /* Render as plain text. */ +#define AJAX_RENDER_HTML_IFRAME 2 /* Render as HTML inside an IFRAME. */ +#define AJAX_RENDER_HTML_INLINE 3 /* Render as HTML without an IFRAME. */ +#define AJAX_RENDER_WIKI 4 /* Render as wiki/markdown. */ +#endif + +/* +** Emits JS code which initializes the +** fossil.page.previewModes object to a map of AJAX_RENDER_xxx values +** and symbolic names for use by client-side scripts. +** +** If addScriptTag is true then the output is wrapped in a SCRIPT tag +** with the current nonce, else no SCRIPT tag is emitted. +** +** Requires that builtin_emit_script_fossil_bootstrap() has already been +** called in order to initialize the window.fossil.page object. +*/ +void ajax_emit_js_preview_modes(int addScriptTag){ + if(addScriptTag){ + style_script_begin(__FILE__,__LINE__); + } + CX("fossil.page.previewModes={" + "guess: %d, %d: 'guess', wiki: %d, %d: 'wiki'," + "htmlIframe: %d, %d: 'htmlIframe', " + "htmlInline: %d, %d: 'htmlInline', " + "text: %d, %d: 'text'" + "};\n", + AJAX_RENDER_GUESS, AJAX_RENDER_GUESS, + AJAX_RENDER_WIKI, AJAX_RENDER_WIKI, + AJAX_RENDER_HTML_IFRAME, AJAX_RENDER_HTML_IFRAME, + AJAX_RENDER_HTML_INLINE, AJAX_RENDER_HTML_INLINE, + AJAX_RENDER_PLAIN_TEXT, AJAX_RENDER_PLAIN_TEXT); + if(addScriptTag){ + style_script_end(); + } +} + +/* +** Returns a value from the ajax_render_modes enum, based on the +** given mimetype string (which may be NULL), defaulting to +** AJAX_RENDER_PLAIN_TEXT. + */ +int ajax_render_mode_for_mimetype(const char * zMimetype){ + int rc = AJAX_RENDER_PLAIN_TEXT; + if( zMimetype ){ + if( fossil_strcmp(zMimetype, "text/html")==0 ){ + rc = AJAX_RENDER_HTML_IFRAME; + }else if( fossil_strcmp(zMimetype, "text/x-fossil-wiki")==0 + || fossil_strcmp(zMimetype, "text/x-markdown")==0 + || fossil_strcmp(zMimetype, "text/x-org")==0 ){ + rc = AJAX_RENDER_WIKI; + } + } + return rc; +} + +/* +** Renders text/wiki content preview for various /ajax routes. +** +** pContent is text/wiki content to preview. zName is the name of the +** content, for purposes of determining the mimetype based on the +** extension (if NULL, mimetype text/plain is assumed). flags may be a +** bitmask of values from the ajax_render_preview_flags +** enum. *renderMode must specify the render mode to use. If +** *renderMode==AJAX_RENDER_GUESS then *renderMode gets set to the +** mode which is guessed at for the rendering (based on the mimetype). +** +** nIframeHeightEm is only used for the AJAX_RENDER_HTML_IFRAME +** renderMode, and specifies the height, in EM's, of the resulting +** iframe. If passed 0, it defaults to "some sane value." +*/ +void ajax_render_preview(Blob * pContent, const char *zName, + int flags, int * renderMode, + int nIframeHeightEm){ + const char * zMime; + + zMime = zName ? mimetype_from_name(zName) : "text/plain"; + if(AJAX_RENDER_GUESS==*renderMode){ + *renderMode = ajax_render_mode_for_mimetype(zMime); + } + switch(*renderMode){ + case AJAX_RENDER_HTML_IFRAME:{ + char * z64 = encode64(blob_str(pContent), blob_size(pContent)); + CX("", + nIframeHeightEm ? nIframeHeightEm : 40, + z64); + break; + } + case AJAX_RENDER_HTML_INLINE:{ + CX("%b",pContent); + break; + } + case AJAX_RENDER_WIKI: + safe_html_context(DOCSRC_FILE); + wiki_render_by_mimetype(pContent, zMime); + break; + default:{ + const char *zContent = blob_str(pContent); + if(AJAX_PREVIEW_LINE_NUMBERS & flags){ + output_text_with_line_numbers(zContent, blob_size(pContent), + zName, "on", 0); + }else{ + const char *zExt = strrchr(zName,'.'); + if(zExt && zExt[1]){ + CX("
%h
", + zExt+1, zContent); + }else{ + CX("
%h
", zContent); + } + } + break; + } + } +} + +/* +** Renders diffs for ajax routes. pOrig is the "original" (v1) content +** and pContent is the locally-edited (v2) content. diffFlags is any +** set of flags suitable for passing to text_diff(). +** +** zOrigHash, if not NULL, must be the SCM-side hash of pOrig's +** contents. If set, additional information may be built into +** the diff output to enable dynamic loading of additional +** diff context. +*/ +void ajax_render_diff(Blob * pOrig, const char * zOrigHash, + Blob *pContent, u64 diffFlags){ + Blob out = empty_blob; + DiffConfig DCfg; + + diff_config_init(&DCfg, diffFlags); + DCfg.zLeftHash = zOrigHash; + text_diff(pOrig, pContent, &out, &DCfg); + if(blob_size(&out)==0){ + /* nothing to do */ + }else{ + CX("%b",&out); + } + blob_reset(&out); +} + +/* +** Uses P(zKey) to fetch a CGI environment variable. If that var is +** NULL or starts with '0' or 'f' then this function returns false, +** else it returns true. +*/ +int ajax_p_bool(char const *zKey){ + const char * zVal = P(zKey); + return (!zVal || '0'==*zVal || 'f'==*zVal) ? 0 : 1; +} + +/* +** Helper for /ajax routes. Clears the CGI content buffer, sets an +** HTTP error status code, and queues up a JSON response in the form +** of an object: +** +** {error: formatted message} +** +** If httpCode<=0 then it defaults to 500. +** +** After calling this, the caller should immediately return. +** +** Returns the resulting http code. +*/ +int ajax_route_error(int httpCode, const char * zFmt, ...){ + Blob msg = empty_blob; + Blob content = empty_blob; + va_list vargs; + + if( httpCode<=0 ) httpCode=500; + va_start(vargs,zFmt); + blob_vappendf(&msg, zFmt, vargs); + va_end(vargs); + blob_appendf(&content,"{\"error\":%!j}", blob_str(&msg)); + blob_reset(&msg); + cgi_set_content(&content); + cgi_set_status(httpCode, "Error"); + cgi_set_content_type("application/json"); + return httpCode; +} + +void ajax_route_error_forbidden(){ + ajax_route_error(403, "Permission denied."); +} + +void ajax_route_error_captcha(){ + ajax_route_error(400, "Invalid captcha response."); +} + +void ajax_route_error_csrf(){ + ajax_route_error(403, "Invalid CSRF signature."); +} + +void ajax_route_error_404(const char *zMsg){ + ajax_route_error(404, "%s", zMsg ? zMsg : "Resource not found."); +} + +int ajax_check_csrf(int level){ + if( 0==cgi_csrf_safe(level) ){ + ajax_route_error_csrf(); + return 0; + } + return 1; +} + +/* +** Performs bootstrapping common to the /ajax/xyz AJAX routes, such as +** logging in the user. +** +** Returns false (0) if bootstrapping fails, in which case it has +** reported the error and the route should immediately return. Returns +** true on success. +** +** If requireWrite is true then write permissions are required. +** If requirePost is true then the request is assumed to be using +** POST'ed data and CSRF validation is performed. +** +*/ +int ajax_route_bootstrap(int requireWrite, int requirePost){ + login_check_credentials(); + if( requireWrite!=0 && g.perm.Write==0 ){ + ajax_route_error(403,"Write permissions required."); + return 0; + }else if(requirePost && 0==cgi_csrf_safe(requirePost)){ + ajax_route_error(403, + "CSRF violation (make sure sending of HTTP " + "Referer headers is enabled for XHR " + "connections)."); + return 0; + } + cgi_set_content_type("application/json"); + return 1; +} + +/* +** Helper for collecting filename/check-in request parameters. +** +** If zFn is not NULL, it is assigned the value of the first one of +** the "filename" or "fn" CGI parameters which is set. +** +** If zCi is not NULL, it is assigned the value of the first one of +** the "checkin" or "ci" CGI parameters which is set. +** +** If a parameter is not NULL, it will be assigned NULL if the +** corresponding parameter is not set. +** +** Returns the number of non-NULL values it assigns to arguments. Thus +** if passed (&x, NULL), it returns 1 if it assigns non-NULL to *x and +** 0 if it assigns NULL to *x. +*/ +int ajax_get_fnci_args( const char **zFn, const char **zCi ){ + int rc = 0; + if(zCi!=0){ + *zCi = PD("checkin",P("ci")); + if( *zCi ) ++rc; + } + if(zFn!=0){ + *zFn = PD("filename",P("fn")); + if (*zFn) ++rc; + } + return rc; +} + +/* +** AJAX route /ajax/preview-text +** +** Required query parameters: +** +** filename=name of content, for use in determining the +** mimetype/render mode. +** +** content=text +** +** Optional query parameters: +** +** render_mode=integer (AJAX_RENDER_xxx) (default=AJAX_RENDER_GUESS) +** +** ln=0 or 1 to disable/enable line number mode in +** AJAX_RENDER_PLAIN_TEXT mode. +** +** iframe_height=integer (default=40) Height, in EMs of HTML preview +** iframe. +** +** Responds with the HTML content of the preview. On error it produces +** a JSON response as documented for ajax_route_error(). +** +** Extra response headers: +** +** x-ajax-render-mode: string representing the rendering mode +** which was really used (which will differ from the requested mode +** only if mode 0 (guess) was requested). The names are documented +** below in code and match those in the emitted JS object +** fossil.page.previewModes. +*/ +void ajax_route_preview_text(void){ + const char * zFilename = 0; + const char * zContent = P("content"); + int renderMode = atoi(PD("render_mode","0")); + int ln = atoi(PD("ln","0")); + int iframeHeight = atoi(PD("iframe_height","40")); + Blob content = empty_blob; + const char * zRenderMode = 0; + + ajax_get_fnci_args( &zFilename, 0 ); + + if(!ajax_route_bootstrap(0,1)){ + return; + } + if(zFilename==0){ + /* The filename is only used for mimetype determination, + ** so we can default it... */ + zFilename = "foo.txt"; + } + cgi_set_content_type("text/html"); + blob_init(&content, zContent, -1); + ajax_render_preview(&content, zFilename, + ln ? AJAX_PREVIEW_LINE_NUMBERS : 0, + &renderMode, iframeHeight); + /* + ** Now tell the caller if we did indeed use AJAX_RENDER_WIKI, so that + ** they can re-set the to an appropriate value (which + ** requires knowing the content's current check-in version, which we + ** don't have here). + */ + switch(renderMode){ + /* The strings used here MUST correspond to those used in the JS-side + ** fossil.page.previewModes map. + */ + case AJAX_RENDER_WIKI: zRenderMode = "wiki"; break; + case AJAX_RENDER_HTML_INLINE: zRenderMode = "htmlInline"; break; + case AJAX_RENDER_HTML_IFRAME: zRenderMode = "htmlIframe"; break; + case AJAX_RENDER_PLAIN_TEXT: zRenderMode = "text"; break; + case AJAX_RENDER_GUESS: + assert(!"cannot happen"); + } + if(zRenderMode!=0){ + cgi_printf_header("x-ajax-render-mode: %s\r\n", zRenderMode); + } +} + +/* +** AJAX route /ajax/artifact.json. +** URL arguments: +** +** uuid=ARTIFACT_ID REQUIRED +** +** and emits either: +** +** { error: "..." } +** +** with a non-200 response code or the artifact's manifest in JSON +** form with a 200 response code. +*/ +void ajax_route_artifact_json(void){ + const char *zUuid = P("uuid"); + Blob json = BLOB_INITIALIZER; + login_check_credentials(); + if( ! g.perm.Read ){ + ajax_route_error_forbidden(); + }else if( artifact_to_json_by_name(zUuid, &json) ){ + @ %b(&json) + }else{ + ajax_route_error_404("Cannot resolve artifact ID."); + } + blob_reset(&json); +} + +#if INTERFACE +/* +** Internal mapping of ajax sub-route names to various metadata. +*/ +struct AjaxRoute { + const char *zName; /* Name part of the route after "ajax/" */ + void (*xCallback)(); /* Impl function for the route. */ + int bWriteMode; /* True if requires write mode */ + int bPost; /* True if requires POST (i.e. CSRF + ** verification). Value is passed to + ** cgi_csrf_safe(). */ +}; +typedef struct AjaxRoute AjaxRoute; +#endif /*INTERFACE*/ + +/* +** Comparison function for bsearch() for searching an AjaxRoute +** list for a matching name. +*/ +int cmp_ajax_route_name(const void *a, const void *b){ + const AjaxRoute * rA = (const AjaxRoute*)a; + const AjaxRoute * rB = (const AjaxRoute*)b; + return fossil_strcmp(rA->zName, rB->zName); +} + +/* +** WEBPAGE: ajax hidden +** +** The main dispatcher for shared ajax-served routes. Requires the +** 'name' parameter be the main route's name (as defined in a list in +** this function), noting that fossil automatically assigns all path +** parts after "ajax" to "name", e.g. /ajax/foo/bar assigns +** name=foo/bar. +** +** This "page" is only intended to be used by higher-level pages which +** have certain Ajax-driven features in common. It is not intended to +** be used by clients and NONE of its HTTP interfaces are considered +** documented/stable/supported - they may change on any given build of +** fossil. +** +** The exact response type depends on the route which gets called. In +** the case of an initialization error it emits a JSON-format response +** as documented for ajax_route_error(). Individual routes may emit +** errors in different formats, e.g. HTML. +*/ +void ajax_route_dispatcher(void){ + const char * zName = P("name"); + AjaxRoute routeName = {0,0,0,0}; + const AjaxRoute * pRoute = 0; + const AjaxRoute routes[] = { + /* Keep these sorted by zName (for bsearch()) */ + {"artifact.json", ajax_route_artifact_json, 0, 0}, + {"preview-text", ajax_route_preview_text, 0, 1 + /* Preview does not require write permissions in the repo. It + ** should arguably require write permissions simply to limit abuse + ** but doing means that /chat does not work without check-in + ** permissions: + ** + ** https://fossil-scm.org/forum/forumpost/ed4a762b3a557898 + ** + ** This particular route is used by /fileedit and /chat, whereas + ** /wikiedit uses a simpler wiki-specific route. + */ + /* TODO (2026-06-09): preview.txt, preview.md, preview.wiki as + ** shorthand for preview-text?filename=X.(txt|md|wiki), noting that + ** the filename is only used for mimetype determination. */ + } + }; + + if(zName==0 || zName[0]==0){ + ajax_route_error(400,"Missing required [route] 'name' parameter."); + return; + } + routeName.zName = zName; + pRoute = (const AjaxRoute *)bsearch(&routeName, routes, + count(routes), sizeof routes[0], + cmp_ajax_route_name); + if(pRoute==0){ + ajax_route_error(404,"Ajax route not found."); + return; + }else if(0==ajax_route_bootstrap(pRoute->bWriteMode, pRoute->bPost)){ + return; + } + pRoute->xCallback(); +} diff --git a/src/alerts.c b/src/alerts.c new file mode 100644 index 0000000..d0ab174 --- /dev/null +++ b/src/alerts.c @@ -0,0 +1,3686 @@ +/* +** Copyright (c) 2018 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/ +** +******************************************************************************* +** +** Logic for email notification, also known as "alerts" or "subscriptions". +** +** Are you looking for the code that reads and writes the internet +** email protocol? That is not here. See the "smtp.c" file instead. +** Yes, the choice of source code filenames is not the greatest, but +** it is not so bad that changing them seems justified. +*/ +#include "config.h" +#include "alerts.h" +#include +#include + +/* +** Maximum size of the subscriberCode blob, in bytes +*/ +#define SUBSCRIBER_CODE_SZ 32 + +/* +** SQL code to implement the tables needed by the email notification +** system. +*/ +static const char zAlertInit[] = +@ DROP TABLE IF EXISTS repository.subscriber; +@ -- Subscribers are distinct from users. A person can have a log-in in +@ -- the USER table without being a subscriber. Or a person can be a +@ -- subscriber without having a USER table entry. Or they can have both. +@ -- In the last case the suname column points from the subscriber entry +@ -- to the USER entry. +@ -- +@ -- The ssub field is a string where each character indicates a particular +@ -- type of event to subscribe to. Choices: +@ -- a - Announcements +@ -- c - Check-ins +@ -- f - Forum posts +@ -- k - ** Special: Unsubscribed using /oneclickunsub +@ -- n - New forum threads +@ -- r - Replies to my own forum posts +@ -- t - Ticket changes +@ -- u - Changes of users' permissions (admins only) +@ -- w - Wiki changes +@ -- x - Edits to forum posts +@ -- Probably different codes will be added in the future. In the future +@ -- we might also add a separate table that allows subscribing to email +@ -- notifications for specific branches or tags or tickets. +@ -- +@ CREATE TABLE repository.subscriber( +@ subscriberId INTEGER PRIMARY KEY, -- numeric subscriber ID. Internal use +@ subscriberCode BLOB DEFAULT (randomblob(32)) UNIQUE, -- UUID for subscriber +@ semail TEXT UNIQUE COLLATE nocase,-- email address +@ suname TEXT, -- corresponding USER entry +@ sverified BOOLEAN DEFAULT true, -- email address verified +@ sdonotcall BOOLEAN, -- true for Do Not Call +@ sdigest BOOLEAN, -- true for daily digests only +@ ssub TEXT, -- baseline subscriptions +@ sctime INTDATE, -- When this entry was created. unixtime +@ mtime INTDATE, -- Last change. unixtime +@ smip TEXT, -- IP address of last change +@ lastContact INT -- Last contact. days since 1970 +@ ); +@ CREATE INDEX repository.subscriberUname +@ ON subscriber(suname) WHERE suname IS NOT NULL; +@ +@ DROP TABLE IF EXISTS repository.pending_alert; +@ -- Email notifications that need to be sent. +@ -- +@ -- The first character of the eventid determines the event type. +@ -- Remaining characters determine the specific event. For example, +@ -- 'c4413' means check-in with rid=4413. +@ -- +@ CREATE TABLE repository.pending_alert( +@ eventid TEXT PRIMARY KEY, -- Object that changed +@ sentSep BOOLEAN DEFAULT false, -- individual alert sent +@ sentDigest BOOLEAN DEFAULT false, -- digest alert sent +@ sentMod BOOLEAN DEFAULT false -- pending moderation alert sent +@ ) WITHOUT ROWID; +@ +@ -- Obsolete table. No longer used. +@ DROP TABLE IF EXISTS repository.alert_bounce; +; + +/* +** Return true if the email notification tables exist. +*/ +int alert_tables_exist(void){ + return db_table_exists("repository", "subscriber"); +} + +/* +** Record the fact that user zUser has made contact with the repository. +** This resets the subscription timeout on that user. +*/ +void alert_user_contact(const char *zUser){ + if( db_table_has_column("repository","subscriber","lastContact") ){ + db_unprotect(PROTECT_READONLY); + db_multi_exec( + "UPDATE subscriber SET lastContact=now()/86400 WHERE suname=%Q", + zUser + ); + db_protect_pop(); + } +} + +/* +** Make sure the table needed for email notification exist in the repository. +** +** If the bOnlyIfEnabled option is true, then tables are only created +** if the email-send-method is something other than "off". +*/ +void alert_schema(int bOnlyIfEnabled){ + if( !alert_tables_exist() ){ + if( bOnlyIfEnabled + && fossil_strcmp(db_get("email-send-method",0),"off")==0 + ){ + return; /* Don't create table for disabled email */ + } + db_exec_sql(zAlertInit); + return; + } + if( db_table_has_column("repository","subscriber","lastContact") ){ + return; + } + db_unprotect(PROTECT_READONLY); + db_multi_exec( + "DROP TABLE IF EXISTS repository.alert_bounce;\n" + "ALTER TABLE repository.subscriber ADD COLUMN lastContact INT;\n" + "UPDATE subscriber SET lastContact=mtime/86400;" + ); + db_protect_pop(); + if( db_table_has_column("repository","pending_alert","sentMod") ){ + return; + } + db_multi_exec( + "ALTER TABLE repository.pending_alert" + " ADD COLUMN sentMod BOOLEAN DEFAULT false;" + ); +} + +/* +** Process deferred alert events. Return the number of errors. +*/ +static int alert_process_deferred_triggers(void){ + if( db_table_exists("temp","deferred_chat_events") + && db_table_exists("repository","chat") + ){ + const char *zChatUser = db_get("chat-timeline-user", 0); + if( zChatUser && zChatUser[0] ){ + chat_create_tables(); /* Make sure TEMP TRIGGERs for FTS exist */ + db_multi_exec( + "INSERT INTO chat(mtime,lmtime,xfrom,xmsg)" + " SELECT julianday(), " + " strftime('%%Y-%%m-%%dT%%H:%%M:%%S','now','localtime')," + " %Q," + " chat_msg_from_event(type, objid, user, comment)\n" + " FROM deferred_chat_events;\n", + zChatUser + ); + } + } + return 0; +} + +/* +** Enable triggers that automatically populate the pending_alert +** table. (Later:) Also add triggers that automatically relay timeline +** events to chat, if chat is configured for that. +*/ +void alert_create_trigger(void){ + if( db_table_exists("repository","pending_alert") ){ + db_multi_exec( + "DROP TRIGGER IF EXISTS repository.alert_trigger1;\n" /* Purge legacy */ + "CREATE TRIGGER temp.alert_trigger1\n" + "AFTER INSERT ON repository.event BEGIN\n" + " INSERT INTO pending_alert(eventid)\n" + " SELECT printf('%%.1c%%d',new.type,new.objid) WHERE true\n" + " ON CONFLICT(eventId) DO NOTHING;\n" + "END;" + ); + } + if( db_table_exists("repository","chat") + && db_get("chat-timeline-user", "")[0]!=0 + ){ + /* Record events that will be relayed to chat, but do not relay + ** them immediately, as the chat_msg_from_event() function requires + ** that TAGXREF be up-to-date, and that has not happened yet when + ** the insert into the EVENT table occurs. Make arrangements to + ** invoke alert_process_deferred_triggers() when the transaction + ** commits. The TAGXREF table will be ready by then. */ + db_multi_exec( + "CREATE TABLE temp.deferred_chat_events(\n" + " type TEXT,\n" + " objid INT,\n" + " user TEXT,\n" + " comment TEXT\n" + ");\n" + "CREATE TRIGGER temp.chat_trigger1\n" + "AFTER INSERT ON repository.event BEGIN\n" + " INSERT INTO deferred_chat_events" + " VALUES(new.type,new.objid,new.user,new.comment);\n" + "END;\n" + ); + db_commit_hook(alert_process_deferred_triggers, 1); + } +} + +/* +** Disable triggers the event_pending and chat triggers. +** +** This must be called before rebuilding the EVENT table, for example +** via the "fossil rebuild" command. +*/ +void alert_drop_trigger(void){ + db_multi_exec( + "DROP TRIGGER IF EXISTS temp.alert_trigger1;\n" + "DROP TRIGGER IF EXISTS repository.alert_trigger1;\n" /* Purge legacy */ + "DROP TRIGGER IF EXISTS temp.chat_trigger1;\n" + ); +} + +/* +** Return true if email alerts are active. +*/ +int alert_enabled(void){ + if( !alert_tables_exist() ) return 0; + if( fossil_strcmp(db_get("email-send-method",0),"off")==0 ) return 0; + return 1; +} + +/* +** If alerts are enabled, removes the pending_alert entry which +** matches (eventType || rid). Note that pending_alert entries are +** added via the manifest crosslinking process, so this has no effect +** if called before crosslinking is performed. Because alerts are sent +** asynchronously, unqueuing needs to be performed as part of the +** transaction in which crosslinking is performed in order to avoid a +** race condition. +*/ +void alert_unqueue(char eventType, int rid){ + if( alert_enabled() ){ + db_multi_exec("DELETE FROM pending_alert WHERE eventid='%c%d'", + eventType, rid); + } +} + +/* +** If the subscriber table does not exist, then paint an error message +** web page and return true. +** +** If the subscriber table does exist, return 0 without doing anything. +*/ +static int alert_webpages_disabled(void){ + if( alert_tables_exist() ) return 0; + style_set_current_feature("alerts"); + style_header("Email Alerts Are Disabled"); + @

Email alerts are disabled on this server

+ style_finish_page(); + return 1; +} + +/* +** Insert a "Subscriber List" submenu link if the current user +** is an administrator. +*/ +void alert_submenu_common(void){ + if( g.perm.Admin ){ + if( fossil_strcmp(g.zPath,"subscribers") ){ + style_submenu_element("Subscribers","%R/subscribers"); + } + if( fossil_strcmp(g.zPath,"subscribe") ){ + style_submenu_element("Add New Subscriber","%R/subscribe"); + } + if( fossil_strcmp(g.zPath,"setup_notification") ){ + style_submenu_element("Notification Setup","%R/setup_notification"); + } + } +} + + +/* +** WEBPAGE: setup_notification +** +** Administrative page for configuring and controlling email notification. +** Normally accessible via the /Admin/Notification menu. +*/ +void setup_notification(void){ + static const char *const azSendMethods[] = { + "off", "Disabled", + "relay", "SMTP relay", + "db", "Store in a database", + "dir", "Store in a directory", + "pipe", "Pipe to a command", + }; + login_check_credentials(); + if( !g.perm.Setup ){ + login_needed(0); + return; + } + db_begin_transaction(); + + alert_submenu_common(); + style_submenu_element("Send Announcement","%R/announce"); + style_set_current_feature("alerts"); + style_header("Email Notification Setup"); + @
+ @

Status  

+ @ + @ + if( alert_enabled() ){ + stats_for_email(); + }else{ + @ + } + @
Disabled
+ @
+ @
+ @

Configuration

+ @

+ @
+ login_insert_csrf_secret(); + + entry_attribute("Canonical Server URL", 40, "email-url", + "eurl", "", 0); + @

Required. + @ This URL is used as the basename for hyperlinks included in + @ email alert text. Omit the trailing "/". + @ Suggested value: "%h(g.zBaseURL)" + @ (Property: "email-url")

+ @
+ + entry_attribute("Administrator email address", 40, "email-admin", + "eadmin", "", 0); + @

This is the email for the human administrator for the system. + @ Abuse and trouble reports and password reset requests are send here. + @ (Property: "email-admin")

+ @
+ + entry_attribute("\"Return-Path\" email address", 20, "email-self", + "eself", "", 0); + @

Required. + @ This is the email to which email notification bounces should be sent. + @ In cases where the email notification does not align with a specific + @ Fossil login account (for example, digest messages), this is also + @ the "From:" address of the email notification. + @ The system administrator should arrange for emails sent to this address + @ to be handed off to the "fossil email incoming" command so that Fossil + @ can handle bounces. (Property: "email-self")

+ @
+ + entry_attribute("List-ID", 40, "email-listid", + "elistid", "", 0); + @

+ @ If this is not an empty string, then it becomes the argument to + @ a "List-ID:" header on all out-bound notification emails. A list ID + @ is required for the generation of unsubscribe links in notifications. + @ (Property: "email-listid")

+ @
+ + entry_attribute("Repository Nickname", 16, "email-subname", + "enn", "", 0); + @

Required. + @ This is short name used to identifies the repository in the + @ Subject: line of email alerts. Traditionally this name is + @ included in square brackets. Examples: "[fossil-src]", "[sqlite-src]". + @ (Property: "email-subname")

+ @
+ + entry_attribute("Subscription Renewal Interval In Days", 8, + "email-renew-interval", "eri", "", 0); + @

+ @ If this value is an integer N greater than or equal to 14, then email + @ notification subscriptions will be suspended N days after the last known + @ interaction with the user. This prevents sending notifications + @ to abandoned accounts. If a subscription comes within 7 days of expiring, + @ a separate email goes out with the daily digest that prompts the + @ subscriber to click on a link to the "/renew" webpage in order to + @ extend their subscription. Subscriptions never expire if this setting + @ is less than 14 or is an empty string. + @ (Property: "email-renew-interval")

+ @
+ + multiple_choice_attribute("Email Send Method", "email-send-method", "esm", + "off", count(azSendMethods)/2, azSendMethods); + @

How to send email. Requires auxiliary information from the fields + @ that follow. Hint: Use the /announce page + @ to send test message to debug this setting. + @ (Property: "email-send-method")

+ alert_schema(1); + entry_attribute("SMTP Relay Host", 60, "email-send-relayhost", + "esrh", "localhost", 0); + @

When the send method is "SMTP relay", each email message is + @ transmitted via the SMTP protocol (rfc5321) to a "Mail Submission + @ Agent" or "MSA" (rfc4409) at the hostname shown here. Optionally + @ append a colon and TCP port number (ex: smtp.example.com:587). + @ The default TCP port number is 25. + @ Usage Hint: If Fossil is running inside of a chroot jail, then it might + @ not be able to resolve hostnames. Work around this by using a raw IP + @ address or create a "/etc/hosts" file inside the chroot jail. + @ (Property: "email-send-relayhost")

+ @ + entry_attribute("Store Emails In This Database", 60, "email-send-db", + "esdb", "", 0); + @

When the send method is "store in a database", each email message is + @ stored in an SQLite database file with the name given here. + @ (Property: "email-send-db")

+ entry_attribute("Pipe Email Text Into This Command", 60, "email-send-command", + "ecmd", "sendmail -ti", 0); + @

When the send method is "pipe to a command", this is the command + @ that is run. Email messages are piped into the standard input of this + @ command. The command is expected to extract the sender address, + @ recipient addresses, and subject from the header of the piped email + @ text. (Property: "email-send-command")

+ entry_attribute("Store Emails In This Directory", 60, "email-send-dir", + "esdir", "", 0); + @

When the send method is "store in a directory", each email message is + @ stored as a separate file in the directory shown here. + @ (Property: "email-send-dir")

+ + @
+ + @

+ @
+ db_end_transaction(0); + style_finish_page(); +} + +#if 0 +/* +** Encode pMsg as MIME base64 and append it to pOut +*/ +static void append_base64(Blob *pOut, Blob *pMsg){ + int n, i, k; + char zBuf[100]; + n = blob_size(pMsg); + for(i=0; i='!' && c<='~' && c!='=' && c!=':') + || (c==' ' && zIn[0]!='\r' && zIn[0]!='\n') + ){ + blob_append_char(pOut, c); + iCol++; + if( iCol>=70 ){ + blob_append(pOut, "=\r\n", 3); + iCol = 0; + } + }else if( c=='\r' && zIn[0]=='\n' ){ + zIn++; + blob_append(pOut, "\r\n", 2); + iCol = 0; + }else if( c=='\n' ){ + blob_append(pOut, "\r\n", 2); + iCol = 0; + }else{ + char x[3]; + x[0] = '='; + x[1] = "0123456789ABCDEF"[(c>>4)&0xf]; + x[2] = "0123456789ABCDEF"[c&0xf]; + blob_append(pOut, x, 3); + iCol += 3; + } + } +} + +#if INTERFACE +/* +** An instance of the following object is used to send emails. +*/ +struct AlertSender { + sqlite3 *db; /* Database emails are sent to */ + sqlite3_stmt *pStmt; /* Stmt to insert into the database */ + const char *zDest; /* How to send email. */ + const char *zDb; /* Name of database file */ + const char *zDir; /* Directory in which to store as email files */ + const char *zCmd; /* Command to run for each email */ + const char *zFrom; /* Emails come from here */ + const char *zListId; /* Argument to List-ID header */ + SmtpSession *pSmtp; /* SMTP relay connection */ + Blob out; /* For zDest=="blob" */ + char *zErr; /* Error message */ + u32 mFlags; /* Flags */ + int bImmediateFail; /* On any error, call fossil_fatal() */ +}; + +/* Allowed values for mFlags to alert_sender_new(). +*/ +#define ALERT_IMMEDIATE_FAIL 0x0001 /* Call fossil_fatal() on any error */ +#define ALERT_TRACE 0x0002 /* Log sending process on console */ + +#endif /* INTERFACE */ + +/* +** Shutdown an emailer. Clear all information other than the error message. +*/ +static void emailerShutdown(AlertSender *p){ + sqlite3_finalize(p->pStmt); + p->pStmt = 0; + sqlite3_close(p->db); + p->db = 0; + p->zDb = 0; + p->zDir = 0; + p->zCmd = 0; + p->zListId = 0; + if( p->pSmtp ){ + smtp_client_quit(p->pSmtp); + smtp_session_free(p->pSmtp); + p->pSmtp = 0; + } + blob_reset(&p->out); +} + +/* +** Put the AlertSender into an error state. +*/ +static void emailerError(AlertSender *p, const char *zFormat, ...){ + va_list ap; + fossil_free(p->zErr); + va_start(ap, zFormat); + p->zErr = vmprintf(zFormat, ap); + va_end(ap); + emailerShutdown(p); + if( p->mFlags & ALERT_IMMEDIATE_FAIL ){ + fossil_fatal("%s", p->zErr); + } +} + +/* +** Free an email sender object +*/ +void alert_sender_free(AlertSender *p){ + if( p ){ + emailerShutdown(p); + fossil_free(p->zErr); + fossil_free(p); + } +} + +/* +** Get an email setting value. Report an error if not configured. +** Return 0 on success and one if there is an error. +*/ +static int emailerGetSetting( + AlertSender *p, /* Where to report the error */ + const char **pzVal, /* Write the setting value here */ + const char *zName /* Name of the setting */ +){ + const char *z = db_get(zName, 0); + int rc = 0; + if( z==0 || z[0]==0 ){ + emailerError(p, "missing \"%s\" setting", zName); + rc = 1; + }else{ + *pzVal = z; + } + return rc; +} + +/* +** Create a new AlertSender object. +** +** The method used for sending email is determined by various email-* +** settings, and especially email-send-method. The repository +** email-send-method can be overridden by the zAltDest argument to +** cause a different sending mechanism to be used. Pass "stdout" to +** zAltDest to cause all emails to be printed to the console for +** debugging purposes. +** +** The AlertSender object returned must be freed using alert_sender_free(). +*/ +AlertSender *alert_sender_new(const char *zAltDest, u32 mFlags){ + AlertSender *p; + + p = fossil_malloc(sizeof(*p)); + memset(p, 0, sizeof(*p)); + blob_init(&p->out, 0, 0); + p->mFlags = mFlags; + if( zAltDest ){ + p->zDest = zAltDest; + }else{ + p->zDest = db_get("email-send-method",0); + } + if( fossil_strcmp(p->zDest,"off")==0 ) return p; + if( emailerGetSetting(p, &p->zFrom, "email-self") ) return p; + p->zListId = db_get("email-listid", 0); + if( fossil_strcmp(p->zDest,"db")==0 ){ + char *zErr; + int rc; + if( emailerGetSetting(p, &p->zDb, "email-send-db") ) return p; + rc = sqlite3_open(p->zDb, &p->db); + if( rc ){ + emailerError(p, "unable to open output database file \"%s\": %s", + p->zDb, sqlite3_errmsg(p->db)); + return p; + } + rc = sqlite3_exec(p->db, "CREATE TABLE IF NOT EXISTS email(\n" + " emailid INTEGER PRIMARY KEY,\n" + " msg TEXT\n);", 0, 0, &zErr); + if( zErr ){ + emailerError(p, "CREATE TABLE failed with \"%s\"", zErr); + sqlite3_free(zErr); + return p; + } + rc = sqlite3_prepare_v2(p->db, "INSERT INTO email(msg) VALUES(?1)", -1, + &p->pStmt, 0); + if( rc ){ + emailerError(p, "cannot prepare INSERT statement: %s", + sqlite3_errmsg(p->db)); + return p; + } + }else if( fossil_strcmp(p->zDest, "pipe")==0 ){ + emailerGetSetting(p, &p->zCmd, "email-send-command"); + }else if( fossil_strcmp(p->zDest, "dir")==0 ){ + emailerGetSetting(p, &p->zDir, "email-send-dir"); + }else if( fossil_strcmp(p->zDest, "blob")==0 ){ + blob_init(&p->out, 0, 0); + }else if( fossil_strcmp(p->zDest, "relay")==0 + || fossil_strcmp(p->zDest, "debug-relay")==0 + ){ + const char *zRelay = 0; + emailerGetSetting(p, &zRelay, "email-send-relayhost"); + if( zRelay ){ + u32 smtpFlags = SMTP_DIRECT; + if( mFlags & ALERT_TRACE ) smtpFlags |= SMTP_TRACE_STDOUT; + blob_init(&p->out, 0, 0); + p->pSmtp = smtp_session_new(domain_of_addr(p->zFrom), zRelay, + smtpFlags, 0); + if( p->pSmtp==0 || p->pSmtp->zErr ){ + emailerError(p, "Could not start SMTP session: %s", + p->pSmtp ? p->pSmtp->zErr : "reason unknown"); + }else if( p->zDest[0]=='d' ){ + smtp_session_config(p->pSmtp, SMTP_TRACE_BLOB, &p->out); + } + } + } + return p; +} + +/* +** Scan the header of the email message in pMsg looking for the +** (first) occurrence of zField. Fill pValue with the content of +** that field. +** +** This routine initializes pValue. Any prior content of pValue is +** discarded (leaked). +** +** Return non-zero on success. Return 0 if no instance of the header +** is found. +*/ +int email_header_value(Blob *pMsg, const char *zField, Blob *pValue){ + int nField = (int)strlen(zField); + Blob line; + blob_rewind(pMsg); + blob_init(pValue,0,0); + while( blob_line(pMsg, &line) ){ + int n, i; + char *z; + blob_trim(&line); + n = blob_size(&line); + if( n==0 ) return 0; + if( n64 ) return 0; /* Local part too big */ + nAt = 1; + nDot = 0; + if( i==0 ) return 0; /* Disallow empty local part */ + if( z[i-1]=='.' ) return 0; /* Last char of local cannot be "." */ + if( z[i+1]=='.' || z[i+1]=='-' ){ + return 0; /* Domain cannot begin with "." or "-" */ + } + }else if( c=='-' ){ + if( z[i+1]==cTerm ) return 0; /* Last character cannot be "-" */ + }else if( c=='.' ){ + if( z[i+1]=='.' ) return 0; /* Do not allow ".." */ + if( z[i+1]==cTerm ) return 0; /* Domain may not end with . */ + nDot++; + }else if( (c=='_' || c=='+') && nAt==0 ){ + /* _ and + are ok in the local part */ + }else{ + return 0; /* Anything else is an error */ + } + } + if( c!=cTerm ) return 0; /* Missing terminator */ + if( nAt==0 ) return 0; /* No "@" found anywhere */ + if( nDot==0 ) return 0; /* No "." in the domain */ + return i; +} + +/* +** Make a copy of the input string up to but not including the +** first cTerm character. +** +** Verify that the string to be copied really is a valid +** email address. If it is not, then return NULL. +** +** This routine is more restrictive than necessary. It does not +** allow comments, IP address, quoted strings, or certain uncommon +** characters. The only non-alphanumerics allowed in the local +** part are "_", "+", "-" and "+". +*/ +char *email_copy_addr(const char *z, char cTerm ){ + int i = email_address_is_valid(z, cTerm); + return i==0 ? 0 : mprintf("%.*s", i, z); +} + +/* +** Scan the input string for a valid email address that may be +** enclosed in <...>, or delimited by ',' or ':' or '=' or ' '. +** If the string contains one or more email addresses, extract the first +** one into memory obtained from mprintf() and return a pointer to it. +** If no valid email address can be found, return NULL. +*/ +char *alert_find_emailaddr(const char *zIn){ + char *zOut = 0; + do{ + zOut = email_copy_addr(zIn, zIn[strcspn(zIn, ">,:= ")]); + if( zOut!=0 ) break; + zIn = (const char *)strpbrk(zIn, "<,:= "); + if( zIn==0 ) break; + zIn++; + }while( zIn!=0 ); + return zOut; +} + +/* +** SQL function: find_emailaddr(X) +** +** Return the first valid email address of the form <...> in input string +** X. Or return NULL if not found. +*/ +void alert_find_emailaddr_func( + sqlite3_context *context, + int argc, + sqlite3_value **argv +){ + const char *zIn = (const char*)sqlite3_value_text(argv[0]); + char *zOut = alert_find_emailaddr(zIn); + if( zOut ){ + sqlite3_result_text(context, zOut, -1, fossil_free); + } +} + +/* +** SQL function: display_name(X) +** +** If X is a string, search for a user name at the beginning of that +** string. The user name must be followed by an email address. If +** found, return the user name. If not found, return NULL. +** +** This routine is used to extract the display name from the USER.INFO +** field. +*/ +void alert_display_name_func( + sqlite3_context *context, + int argc, + sqlite3_value **argv +){ + const char *zIn = (const char*)sqlite3_value_text(argv[0]); + int i; + if( zIn==0 ) return; + while( fossil_isspace(zIn[0]) ) zIn++; + for(i=0; zIn[i] && zIn[i]!='<' && zIn[i]!='\n'; i++){} + if( zIn[i]=='<' ){ + while( i>0 && fossil_isspace(zIn[i-1]) ){ i--; } + if( i>0 ){ + sqlite3_result_text(context, zIn, i, SQLITE_TRANSIENT); + } + } +} + +/* +** Return the hostname portion of an email address - the part following +** the @ +*/ +const char *alert_hostname(const char *zAddr){ + const char *z = strchr(zAddr, '@'); + if( z ){ + z++; + }else{ + z = zAddr; + } + return z; +} + +/* +** Return a pointer to a fake email mailbox name that corresponds +** to human-readable name zFromName. The fake mailbox name is based +** on a hash. No huge problems arise if there is a hash collision, +** but it is still better if collisions can be avoided. +** +** The returned string is held in a static buffer and is overwritten +** by each subsequent call to this routine. +*/ +static char *alert_mailbox_name(const char *zFromName){ + static char zHash[20]; + unsigned int x = 0; + int n = 0; + while( zFromName[0] ){ + n++; + x = x*1103515245 + 12345 + ((unsigned char*)zFromName)[0]; + zFromName++; + } + sqlite3_snprintf(sizeof(zHash), zHash, + "noreply%x%08x", n, x); + return zHash; +} + +/* +** COMMAND: test-mailbox-hashname +** +** Usage: %fossil test-mailbox-hashname HUMAN-NAME ... +** +** Return the mailbox hash name corresponding to each human-readable +** name on the command line. This is a test interface for the +** alert_mailbox_name() function. +*/ +void alert_test_mailbox_hashname(void){ + int i; + for(i=2; i'))!=0 ){ + azTo = fossil_realloc(azTo, sizeof(azTo[0])*(nTo+1) ); + azTo[nTo++] = zAddr; + } + } + *pnTo = nTo; + *pazTo = azTo; +} + +/* +** Free a list of To addresses obtained from a prior call to +** email_header_to() +*/ +void email_header_to_free(int nTo, char **azTo){ + int i; + for(i=0; imFlags & ALERT_TRACE ){ + fossil_print("Sending email\n"); + } + if( fossil_strcmp(p->zDest, "off")==0 ){ + return; + } + blob_init(&all, 0, 0); + if( fossil_strcmp(p->zDest, "blob")==0 ){ + pOut = &p->out; + if( blob_size(pOut) ){ + blob_appendf(pOut, "%.72c\n", '='); + } + }else{ + pOut = &all; + } + blob_append(pOut, blob_buffer(pHdr), blob_size(pHdr)); + if( p->zFrom==0 || p->zFrom[0]==0 ){ + return; /* email-self is not set. Error will be reported separately */ + }else if( zFromName ){ + blob_appendf(pOut, "From: %s <%s@%s>\r\n", + zFromName, alert_mailbox_name(zFromName), alert_hostname(p->zFrom)); + blob_appendf(pOut, "Sender: <%s>\r\n", p->zFrom); + }else{ + blob_appendf(pOut, "From: <%s>\r\n", p->zFrom); + } + blob_appendf(pOut, "Date: %z\r\n", cgi_rfc822_datestamp(time(0))); + if( strstr(blob_str(pHdr), "\r\nMessage-Id:")==0 ){ + /* Message-id format: "<$(date)x$(random)@$(from-host)>" where $(date) is + ** the current unix-time in hex, $(random) is a 64-bit random number, + ** and $(from) is the domain part of the email-self setting. */ + sqlite3_randomness(sizeof(r1), &r1); + r2 = time(0); + blob_appendf(pOut, "Message-Id: <%llxx%016llx@%s>\r\n", + r2, r1, alert_hostname(p->zFrom)); + } + blob_add_final_newline(pBody); + blob_appendf(pOut, "MIME-Version: 1.0\r\n"); + blob_appendf(pOut, "Content-Type: text/plain; charset=\"UTF-8\"\r\n"); +#if 0 + blob_appendf(pOut, "Content-Transfer-Encoding: base64\r\n\r\n"); + append_base64(pOut, pBody); +#else + blob_appendf(pOut, "Content-Transfer-Encoding: quoted-printable\r\n\r\n"); + append_quoted(pOut, pBody); +#endif + if( p->pStmt ){ + int i, rc; + sqlite3_bind_text(p->pStmt, 1, blob_str(&all), -1, SQLITE_TRANSIENT); + for(i=0; i<100 && sqlite3_step(p->pStmt)==SQLITE_BUSY; i++){ + sqlite3_sleep(10); + } + rc = sqlite3_reset(p->pStmt); + if( rc!=SQLITE_OK ){ + emailerError(p, "Failed to insert email message into output queue.\n" + "%s", sqlite3_errmsg(p->db)); + } + }else if( p->zCmd ){ + FILE *out = popen(p->zCmd, "w"); + if( out ){ + fwrite(blob_buffer(&all), 1, blob_size(&all), out); + pclose(out); + }else{ + emailerError(p, "Could not open output pipe \"%s\"", p->zCmd); + } + }else if( p->zDir ){ + char *zFile = file_time_tempname(p->zDir, ".email"); + blob_write_to_file(&all, zFile); + fossil_free(zFile); + }else if( p->pSmtp ){ + char **azTo = 0; + int nTo = 0; + SmtpSession *pSmtp = p->pSmtp; + email_header_to(pHdr, &nTo, &azTo); + if( nTo>0 && !pSmtp->bFatal ){ + smtp_send_msg(pSmtp,p->zFrom,nTo,(const char**)azTo,blob_str(&all)); + if( pSmtp->zErr && !pSmtp->bFatal ){ + smtp_send_msg(pSmtp,p->zFrom,nTo,(const char**)azTo,blob_str(&all)); + } + if( pSmtp->zErr ){ + fossil_errorlog("SMTP: (%s) %s", pSmtp->bFatal ? "fatal" : "retry", + pSmtp->zErr); + } + email_header_to_free(nTo, azTo); + } + }else if( strcmp(p->zDest, "stdout")==0 ){ + char **azTo = 0; + int nTo = 0; + int i; + email_header_to(pHdr, &nTo, &azTo); + for(i=0; i=3 ? g.argv[2] : "x"; + nCmd = (int)strlen(zCmd); + if( strncmp(zCmd, "pending", nCmd)==0 ){ + Stmt q; + verify_all_options(); + if( g.argc!=3 ) usage("pending"); + db_prepare(&q,"SELECT eventid, sentSep, sentDigest, sentMod" + " FROM pending_alert"); + while( db_step(&q)==SQLITE_ROW ){ + fossil_print("%10s %7s %10s %7s\n", + db_column_text(&q,0), + db_column_int(&q,1) ? "sentSep" : "", + db_column_int(&q,2) ? "sentDigest" : "", + db_column_int(&q,3) ? "sentMod" : ""); + } + db_finalize(&q); + }else + if( strncmp(zCmd, "reset", nCmd)==0 ){ + int c; + int bForce = find_option("force","f",0)!=0; + verify_all_options(); + if( bForce ){ + c = 'y'; + }else{ + Blob yn; + fossil_print( + "This will erase all content in the repository tables, thus\n" + "deleting all subscriber information. The information will be\n" + "unrecoverable.\n"); + prompt_user("Continue? (y/N) ", &yn); + c = blob_str(&yn)[0]; + blob_reset(&yn); + } + if( c=='y' ){ + alert_drop_trigger(); + db_multi_exec( + "DROP TABLE IF EXISTS subscriber;\n" + "DROP TABLE IF EXISTS pending_alert;\n" + "DROP TABLE IF EXISTS alert_bounce;\n" + /* Legacy */ + "DROP TABLE IF EXISTS alert_pending;\n" + "DROP TABLE IF EXISTS subscription;\n" + ); + alert_schema(0); + } + }else + if( strncmp(zCmd, "send", nCmd)==0 ){ + u32 eFlags = 0; + if( find_option("digest",0,0)!=0 ) eFlags |= SENDALERT_DIGEST; + if( find_option("renewal",0,0)!=0 ) eFlags |= SENDALERT_RENEWAL; + if( find_option("test",0,0)!=0 ){ + eFlags |= SENDALERT_PRESERVE|SENDALERT_STDOUT; + } + verify_all_options(); + alert_send_alerts(eFlags); + }else + if( strncmp(zCmd, "settings", nCmd)==0 ){ + int isGlobal = find_option("global",0,0)!=0; + int nSetting; + const Setting *pSetting = setting_info(&nSetting); + db_open_config(1, 0); + verify_all_options(); + if( g.argc!=3 && g.argc!=5 ) usage("setting [NAME VALUE]"); + if( g.argc==5 ){ + const char *zLabel = g.argv[3]; + if( strncmp(zLabel, "email-", 6)!=0 + || (pSetting = db_find_setting(zLabel, 1))==0 ){ + fossil_fatal("not a valid email setting: \"%s\"", zLabel); + } + db_set(pSetting->name/*works-like:""*/, g.argv[4], isGlobal); + g.argc = 3; + } + pSetting = setting_info(&nSetting); + for(; nSetting>0; nSetting--, pSetting++ ){ + if( strncmp(pSetting->name,"email-",6)!=0 ) continue; + print_setting(pSetting, 0, 0); + } + }else + if( strncmp(zCmd, "status", nCmd)==0 ){ + Stmt q; + int iCutoff; + int nSetting, n; + static const char *zFmt = "%-29s %d\n"; + const Setting *pSetting = setting_info(&nSetting); + db_open_config(1, 0); + verify_all_options(); + if( g.argc!=3 ) usage("status"); + pSetting = setting_info(&nSetting); + for(; nSetting>0; nSetting--, pSetting++ ){ + if( strncmp(pSetting->name,"email-",6)!=0 ) continue; + print_setting(pSetting, 0, 0); + } + n = db_int(0,"SELECT count(*) FROM pending_alert WHERE NOT sentSep"); + fossil_print(zFmt/*works-like:"%s%d"*/, "pending-alerts", n); + n = db_int(0,"SELECT count(*) FROM pending_alert WHERE NOT sentDigest"); + fossil_print(zFmt/*works-like:"%s%d"*/, "pending-digest-alerts", n); + db_prepare(&q, + "SELECT" + " name," + " value," + " now()/86400-value," + " date(value*86400,'unixepoch')" + " FROM repository.config" + " WHERE name in ('email-renew-warning','email-renew-cutoff');"); + while( db_step(&q)==SQLITE_ROW ){ + fossil_print("%-29s %-6d (%d days ago on %s)\n", + db_column_text(&q, 0), + db_column_int(&q, 1), + db_column_int(&q, 2), + db_column_text(&q, 3)); + } + db_finalize(&q); + n = db_int(0,"SELECT count(*) FROM subscriber"); + fossil_print(zFmt/*works-like:"%s%d"*/, "total-subscribers", n); + iCutoff = db_get_int("email-renew-cutoff", 0); + n = db_int(0, "SELECT count(*) FROM subscriber WHERE sverified" + " AND NOT sdonotcall AND length(ssub)>1" + " AND lastContact>=%d", iCutoff); + fossil_print(zFmt/*works-like:"%s%d"*/, "active-subscribers", n); + }else + if( strncmp(zCmd, "subscribers", nCmd)==0 ){ + Stmt q; + verify_all_options(); + if( g.argc!=3 && g.argc!=4 ) usage("subscribers [PATTERN]"); + if( g.argc==4 ){ + char *zPattern = g.argv[3]; + db_prepare(&q, + "SELECT semail FROM subscriber" + " WHERE semail LIKE '%%%q%%' OR suname LIKE '%%%q%%'" + " OR semail GLOB '*%q*' or suname GLOB '*%q*'" + " ORDER BY semail", + zPattern, zPattern, zPattern, zPattern); + }else{ + db_prepare(&q, + "SELECT semail FROM subscriber" + " ORDER BY semail"); + } + while( db_step(&q)==SQLITE_ROW ){ + fossil_print("%s\n", db_column_text(&q, 0)); + } + db_finalize(&q); + }else + if( strncmp(zCmd, "test-message", nCmd)==0 ){ + Blob prompt, body, hdr; + const char *zDest = find_option("stdout",0,0)!=0 ? "stdout" : 0; + int i; + u32 mFlags = ALERT_IMMEDIATE_FAIL; + const char *zSubject = find_option("subject", "S", 1); + const char *zSource = find_option("body", 0, 1); + AlertSender *pSender; + if( find_option("smtp-trace",0,0)!=0 ) mFlags |= ALERT_TRACE; + verify_all_options(); + blob_init(&prompt, 0, 0); + blob_init(&body, 0, 0); + blob_init(&hdr, 0, 0); + blob_appendf(&hdr,"To: "); + for(i=3; i3 ) blob_append(&hdr, ", ", 2); + blob_appendf(&hdr, "<%s>", g.argv[i]); + } + blob_append(&hdr,"\r\n",2); + if( zSubject==0 ) zSubject = "fossil alerts test-message"; + blob_appendf(&hdr, "Subject: %s\r\n", zSubject); + if( zSource ){ + blob_read_from_file(&body, zSource, ExtFILE); + }else{ + prompt_for_user_comment(&body, &prompt); + } + blob_add_final_newline(&body); + pSender = alert_sender_new(zDest, mFlags); + alert_send(pSender, &hdr, &body, 0); + alert_sender_free(pSender); + blob_reset(&hdr); + blob_reset(&body); + blob_reset(&prompt); + }else + if( strncmp(zCmd, "unsubscribe", nCmd)==0 ){ + verify_all_options(); + if( g.argc!=4 ) usage("unsubscribe EMAIL"); + db_multi_exec( + "DELETE FROM subscriber WHERE semail=%Q", g.argv[3]); + }else + { + usage("pending|reset|send|setting|status|" + "subscribers|test-message|unsubscribe"); + } +} + +/* +** Do error checking on a submitted subscription form. Return TRUE +** if the submission is valid. Return false if any problems are seen. +*/ +static int subscribe_error_check( + int *peErr, /* Type of error */ + char **pzErr, /* Error message text */ + int needCaptcha /* True if captcha check needed */ +){ + const char *zEAddr; + int i, j, n; + char c; + + *peErr = 0; + *pzErr = 0; + + /* Verify the captcha first */ + if( needCaptcha ){ + if( !captcha_is_correct(1) ){ + *peErr = 2; + *pzErr = mprintf("incorrect security code"); + return 0; + } + } + + /* Check the validity of the email address. + ** + ** (1) Exactly one '@' character. + ** (2) No other characters besides [a-zA-Z0-9._+-] + ** + ** The local part is currently more restrictive than RFC 5322 allows: + ** https://stackoverflow.com/a/2049510/142454 We will expand this as + ** necessary. + */ + zEAddr = P("e"); + if( zEAddr==0 ){ + *peErr = 1; + *pzErr = mprintf("required"); + return 0; + } + for(i=j=n=0; (c = zEAddr[i])!=0; i++){ + if( c=='@' ){ + n = i; + j++; + continue; + } + if( !fossil_isalnum(c) && c!='.' && c!='_' && c!='-' && c!='+' ){ + *peErr = 1; + *pzErr = mprintf("illegal character in email address: 0x%x '%c'", + c, c); + return 0; + } + } + if( j!=1 ){ + *peErr = 1; + *pzErr = mprintf("email address should contain exactly one '@'"); + return 0; + } + if( n<1 ){ + *peErr = 1; + *pzErr = mprintf("name missing before '@' in email address"); + return 0; + } + if( n>i-5 ){ + *peErr = 1; + *pzErr = mprintf("email domain too short"); + return 0; + } + + if( authorized_subscription_email(zEAddr)==0 ){ + *peErr = 1; + *pzErr = mprintf("not an authorized email address"); + return 0; + } + + /* Check to make sure the email address is available for reuse */ + if( db_exists("SELECT 1 FROM subscriber WHERE semail=%Q", zEAddr) ){ + *peErr = 1; + *pzErr = mprintf("this email address is used by someone else"); + return 0; + } + + /* If we reach this point, all is well */ + return 1; +} + +/* +** Text of email message sent in order to confirm a subscription. +*/ +static const char zConfirmMsg[] = +@ Someone has signed you up for email alerts on the Fossil repository +@ at %s. +@ +@ To confirm your subscription and begin receiving alerts, click on +@ the following hyperlink: +@ +@ %s/alerts/%s +@ +@ Save the hyperlink above! You can reuse this same hyperlink to +@ unsubscribe or to change the kinds of alerts you receive. +@ +@ If you do not want to subscribe, you can simply ignore this message. +@ You will not be contacted again. +@ +; + +/* +** Append the text of an email confirmation message to the given +** Blob. The security code is in zCode. +*/ +void alert_append_confirmation_message(Blob *pMsg, const char *zCode){ + blob_appendf(pMsg, zConfirmMsg/*works-like:"%s%s%s"*/, + g.zBaseURL, g.zBaseURL, zCode); +} + +/* +** WEBPAGE: subscribe +** +** Allow users to subscribe to email notifications. +** +** This page is usually run by users who are not logged in. +** A logged-in user can add email notifications on the /alerts page. +** Access to this page by a logged in user (other than an +** administrator) results in a redirect to the /alerts page. +** +** Administrators can visit this page in order to sign up other +** users. +** +** The Alerts permission ("7") is required to access this +** page. To allow anonymous passers-by to sign up for email +** notification, set Email-Alerts on user "nobody" or "anonymous". +*/ +void subscribe_page(void){ + int needCaptcha; + unsigned int uSeed = 0; + const char *zDecoded; + char *zCaptcha = 0; + char *zErr = 0; + int eErr = 0; + int di; + + if( alert_webpages_disabled() ) return; + login_check_credentials(); + if( !g.perm.EmailAlert ){ + login_needed(g.anon.EmailAlert); + return; + } + if( login_is_individual() + && db_exists("SELECT 1 FROM subscriber WHERE suname=%Q",g.zLogin) + ){ + /* This person is already signed up for email alerts. Jump + ** to the screen that lets them edit their alert preferences. + ** Except, administrators can create subscriptions for others so + ** do not jump for them. + */ + if( g.perm.Admin ){ + /* Admins get a link to admin their own account, but they + ** stay on this page so that they can create subscriptions + ** for other people. */ + style_submenu_element("My Subscription","%R/alerts"); + }else{ + /* Everybody else jumps to the page to administer their own + ** account only. */ + cgi_redirectf("%R/alerts"); + return; + } + } + if( !g.perm.Admin && !db_get_boolean("anon-subscribe",1) ){ + register_page(); + return; + } + style_set_current_feature("alerts"); + alert_submenu_common(); + needCaptcha = !login_is_individual(); + if( P("submit") + && cgi_csrf_safe(2) + && subscribe_error_check(&eErr,&zErr,needCaptcha) + ){ + /* A validated request for a new subscription has been received. */ + char ssub[20]; + const char *zEAddr = P("e"); + const char *zCode; /* New subscriber code (in hex) */ + int nsub = 0; + const char *suname = PT("suname"); + if( suname==0 && needCaptcha==0 && !g.perm.Admin ) suname = g.zLogin; + if( suname && suname[0]==0 ) suname = 0; + if( PB("sa") ) ssub[nsub++] = 'a'; + if( g.perm.Read && PB("sc") ) ssub[nsub++] = 'c'; + if( g.perm.RdForum && PB("sf") ) ssub[nsub++] = 'f'; + if( g.perm.RdForum && PB("sn") ) ssub[nsub++] = 'n'; + if( g.perm.RdForum && PB("sr") ) ssub[nsub++] = 'r'; + if( g.perm.RdTkt && PB("st") ) ssub[nsub++] = 't'; + if( g.perm.Admin && PB("su") ) ssub[nsub++] = 'u'; + if( g.perm.RdWiki && PB("sw") ) ssub[nsub++] = 'w'; + if( g.perm.RdForum && PB("sx") ) ssub[nsub++] = 'x'; + ssub[nsub] = 0; + zCode = db_text(0, + "INSERT INTO subscriber(semail,suname," + " sverified,sdonotcall,sdigest,ssub,sctime,mtime,smip,lastContact)" + "VALUES(%Q,%Q,%d,0,%d,%Q,now(),now(),%Q,now()/86400)" + "RETURNING hex(subscriberCode);", + /* semail */ zEAddr, + /* suname */ suname, + /* sverified */ needCaptcha==0, + /* sdigest */ PB("di"), + /* ssub */ ssub, + /* smip */ g.zIpAddr + ); + if( !needCaptcha ){ + /* The new subscription has been added on behalf of a logged-in user. + ** No verification is required. Jump immediately to /alerts page. + */ + if( g.perm.Admin ){ + cgi_redirectf("%R/alerts/%.32s", zCode); + }else{ + cgi_redirectf("%R/alerts"); + } + return; + }else{ + /* We need to send a verification email */ + Blob hdr, body; + AlertSender *pSender = alert_sender_new(0,0); + blob_init(&hdr,0,0); + blob_init(&body,0,0); + blob_appendf(&hdr, "To: <%s>\n", zEAddr); + blob_appendf(&hdr, "Subject: Subscription verification\n"); + alert_append_confirmation_message(&body, zCode); + alert_send(pSender, &hdr, &body, 0); + style_header("Email Alert Verification"); + if( pSender->zErr ){ + @

Internal Error

+ @

The following internal error was encountered while trying + @ to send the confirmation email: + @

+        @ %h(pSender->zErr)
+        @ 
+ }else{ + @

An email has been sent to "%h(zEAddr)". That email contains a + @ hyperlink that you must click to activate your + @ subscription.

+ } + alert_sender_free(pSender); + style_finish_page(); + } + return; + } + style_header("Signup For Email Alerts"); + if( P("submit")==0 ){ + /* If this is the first visit to this page (if this HTTP request did not + ** come from a prior Submit of the form) then default all of the + ** subscription options to "on" */ + cgi_set_parameter_nocopy("sa","1",1); + if( g.perm.Read ) cgi_set_parameter_nocopy("sc","1",1); + if( g.perm.RdForum ) cgi_set_parameter_nocopy("sf","1",1); + if( g.perm.RdForum ) cgi_set_parameter_nocopy("sn","1",1); + if( g.perm.RdForum ) cgi_set_parameter_nocopy("sr","1",1); + if( g.perm.RdTkt ) cgi_set_parameter_nocopy("st","1",1); + if( g.perm.Admin ) cgi_set_parameter_nocopy("su","1",1); + if( g.perm.RdWiki ) cgi_set_parameter_nocopy("sw","1",1); + } + @

To receive email notifications for changes to this + @ repository, fill out the form below and press the "Submit" button.

+ form_begin(0, "%R/subscribe"); + @ + @ + @ + @ + @ + if( eErr==1 ){ + @ + } + @ + if( needCaptcha ){ + const char *zInit = ""; + if( P("captchaseed")!=0 && eErr!=2 ){ + uSeed = strtoul(P("captchaseed"),0,10); + zInit = P("captcha"); + }else{ + uSeed = captcha_seed(); + } + zDecoded = captcha_decode(uSeed, 0); + zCaptcha = captcha_render(zDecoded); + @ + @ + @ + @ + if( eErr==2 ){ + @ + } + @ + } + if( g.perm.Admin ){ + @ + @ + @ + @ + if( eErr==3 ){ + @ + } + @ + } + @ + @ + @ + @ + @ + @ + @ + if( g.perm.Admin ){ + @ + @ + } + @ + @ + if( needCaptcha && !alert_enabled() ){ + @ + }else{ + @ + } + @ + @ + if( needCaptcha ){ + @
+    @ %h(zCaptcha)
+    @ 
+ @ Enter the 8 characters above in the "Security Code" box
+ @
+ } + @ + fossil_free(zErr); + style_finish_page(); +} + +/* +** Either shutdown or completely delete a subscription entry given +** by the hex value zName. Then paint a webpage that explains that +** the entry has been removed. +*/ +static void alert_unsubscribe(int sid, int bTotal){ + const char *zEmail = 0; + const char *zLogin = 0; + int uid = 0; + Stmt q; + db_prepare(&q, "SELECT semail, suname FROM subscriber" + " WHERE subscriberId=%d", sid); + if( db_step(&q)==SQLITE_ROW ){ + zEmail = db_column_text(&q, 0); + zLogin = db_column_text(&q, 1); + uid = db_int(0, "SELECT uid FROM user WHERE login=%Q", zLogin); + } + style_set_current_feature("alerts"); + if( zEmail==0 ){ + style_header("Unsubscribe Fail"); + @

Unable to locate a subscriber with the requested key

+ }else{ + db_unprotect(PROTECT_READONLY); + if( bTotal ){ + /* Completely delete the subscriber */ + db_multi_exec( + "DELETE FROM subscriber WHERE subscriberId=%d", sid + ); + }else{ + /* Keep the subscriber, but turn off all notifications */ + db_multi_exec( + "UPDATE subscriber SET ssub='k', mtime=now() WHERE subscriberId=%d", + sid + ); + } + db_protect_pop(); + style_header("Unsubscribed"); + @

The "%h(zEmail)" email address has been unsubscribed from all + @ notifications. All subscription records for "%h(zEmail)" have + @ been purged. No further emails will be sent to "%h(zEmail)".

+ if( uid && g.perm.Admin ){ + @

You may also want to + @ edit or delete + @ the corresponding user "%h(zLogin)"

+ } + } + db_finalize(&q); + style_finish_page(); + return; +} + +/* +** WEBPAGE: alerts +** +** Edit email alert and notification settings. +** +** The subscriber is identified in several ways: +** +** * The name= query parameter contains the complete subscriberCode. +** This only happens when the user receives a verification +** email and clicks on the link in the email. When a +** compilete subscriberCode is seen on the name= query parameter, +** that constitutes verification of the email address. +** +** * The sid= query parameter contains an integer subscriberId. +** This only works for the administrator. It allows the +** administrator to edit any subscription. +** +** * The user is logged into an account other than "nobody" or +** "anonymous". In that case the notification settings +** associated with that account can be edited without needing +** to know the subscriber code. +** +** * The name= query parameter contains a 32-digit prefix of +** subscriber code. (Subscriber codes are normally 64 hex digits +** in length.) This uniquely identifies the subscriber without +** revealing the complete subscriber code, and hence without +** verifying the email address. +*/ +void alert_page(void){ + const char *zName = 0; /* Value of the name= query parameter */ + Stmt q; /* For querying the database */ + int sa, sc, sf, st, su, sw, sx; /* Types of notifications requested */ + int sn, sr; + int sdigest = 0, sdonotcall = 0, sverified = 0; /* Other fields */ + int isLogin; /* True if logged in as an individual */ + const char *ssub = 0; /* Subscription flags */ + const char *semail = 0; /* Email address */ + const char *smip; /* */ + const char *suname = 0; /* Corresponding user.login value */ + const char *mtime; /* */ + const char *sctime; /* Time subscription created */ + int eErr = 0; /* Type of error */ + char *zErr = 0; /* Error message text */ + int sid = 0; /* Subscriber ID */ + int nName; /* Length of zName in bytes */ + char *zHalfCode; /* prefix of subscriberCode */ + int keepAlive = 0; /* True to update the last contact time */ + + db_begin_transaction(); + if( alert_webpages_disabled() ){ + db_commit_transaction(); + return; + } + login_check_credentials(); + isLogin = login_is_individual(); + zName = P("name"); + nName = zName ? (int)strlen(zName) : 0; + if( g.perm.Admin && P("sid")!=0 ){ + sid = atoi(P("sid")); + } + if( sid==0 && nName>=32 ){ + sid = db_int(0, + "SELECT CASE WHEN hex(subscriberCode) LIKE (%Q||'%%')" + " THEN subscriberId ELSE 0 END" + " FROM subscriber WHERE subscriberCode>=hextoblob(%Q)" + " LIMIT 1", zName, zName); + if( sid ) keepAlive = 1; + } + if( sid==0 && isLogin && g.perm.EmailAlert ){ + sid = db_int(0, "SELECT subscriberId FROM subscriber" + " WHERE suname=%Q", g.zLogin); + } + if( sid==0 ){ + db_commit_transaction(); + cgi_redirect("subscribe"); + /*NOTREACHED*/ + } + alert_submenu_common(); + if( P("submit")!=0 && cgi_csrf_safe(2) ){ + char newSsub[10]; + int nsub = 0; + Blob update; + + sdonotcall = PB("sdonotcall"); + sdigest = PB("sdigest"); + semail = P("semail"); + if( PB("sa") ) newSsub[nsub++] = 'a'; + if( g.perm.Read && PB("sc") ) newSsub[nsub++] = 'c'; + if( g.perm.RdForum && PB("sf") ) newSsub[nsub++] = 'f'; + if( g.perm.RdForum && PB("sn") ) newSsub[nsub++] = 'n'; + if( g.perm.RdForum && PB("sr") ) newSsub[nsub++] = 'r'; + if( g.perm.RdTkt && PB("st") ) newSsub[nsub++] = 't'; + if( g.perm.Admin && PB("su") ) newSsub[nsub++] = 'u'; + if( g.perm.RdWiki && PB("sw") ) newSsub[nsub++] = 'w'; + if( g.perm.RdForum && PB("sx") ) newSsub[nsub++] = 'x'; + newSsub[nsub] = 0; + ssub = newSsub; + blob_init(&update, "UPDATE subscriber SET", -1); + blob_append_sql(&update, + " sdonotcall=%d," + " sdigest=%d," + " ssub=%Q," + " mtime=now()," + " lastContact=now()/86400," + " smip=%Q", + sdonotcall, + sdigest, + ssub, + g.zIpAddr + ); + if( g.perm.Admin ){ + suname = PT("suname"); + sverified = PB("sverified"); + if( suname && suname[0]==0 ) suname = 0; + blob_append_sql(&update, + ", suname=%Q," + " sverified=%d", + suname, + sverified + ); + } + if( isLogin ){ + if( semail==0 || email_address_is_valid(semail,0)==0 ){ + eErr = 8; + } + blob_append_sql(&update, ", semail=%Q", semail); + } + blob_append_sql(&update," WHERE subscriberId=%d", sid); + if( eErr==0 ){ + db_exec_sql(blob_str(&update)); + ssub = 0; + } + blob_reset(&update); + }else if( keepAlive ){ + db_unprotect(PROTECT_READONLY); + db_multi_exec( + "UPDATE subscriber SET lastContact=now()/86400" + " WHERE subscriberId=%d", sid + ); + db_protect_pop(); + } + if( P("delete")!=0 && cgi_csrf_safe(2) ){ + if( !PB("dodelete") ){ + eErr = 9; + zErr = mprintf("Select this checkbox and press \"Unsubscribe\" again to" + " unsubscribe"); + }else{ + alert_unsubscribe(sid, 1); + db_commit_transaction(); + return; + } + } + style_set_current_feature("alerts"); + style_header("Update Subscription"); + db_prepare(&q, + "SELECT" + " semail," /* 0 */ + " sverified," /* 1 */ + " sdonotcall," /* 2 */ + " sdigest," /* 3 */ + " ssub," /* 4 */ + " smip," /* 5 */ + " suname," /* 6 */ + " datetime(mtime,'unixepoch')," /* 7 */ + " datetime(sctime,'unixepoch')," /* 8 */ + " hex(subscriberCode)," /* 9 */ + " date(coalesce(lastContact*86400,mtime),'unixepoch')," /* 10 */ + " now()/86400 - coalesce(lastContact,mtime/86400)" /* 11 */ + " FROM subscriber WHERE subscriberId=%d", sid); + if( db_step(&q)!=SQLITE_ROW ){ + db_finalize(&q); + db_commit_transaction(); + cgi_redirect("subscribe"); + /*NOTREACHED*/ + } + if( ssub==0 ){ + semail = db_column_text(&q, 0); + sdonotcall = db_column_int(&q, 2); + sdigest = db_column_int(&q, 3); + ssub = db_column_text(&q, 4); + } + if( suname==0 ){ + suname = db_column_text(&q, 6); + sverified = db_column_int(&q, 1); + } + sa = strchr(ssub,'a')!=0; + sc = strchr(ssub,'c')!=0; + sf = strchr(ssub,'f')!=0; + sn = strchr(ssub,'n')!=0; + sr = strchr(ssub,'r')!=0; + st = strchr(ssub,'t')!=0; + su = strchr(ssub,'u')!=0; + sw = strchr(ssub,'w')!=0; + sx = strchr(ssub,'x')!=0; + smip = db_column_text(&q, 5); + mtime = db_column_text(&q, 7); + sctime = db_column_text(&q, 8); + if( !g.perm.Admin && !sverified ){ + if( nName==64 ){ + db_unprotect(PROTECT_READONLY); + db_multi_exec( + "UPDATE subscriber SET sverified=1" + " WHERE subscriberCode=hextoblob(%Q)", + zName); + db_protect_pop(); + if( db_get_boolean("selfreg-verify",0) ){ + char *zNewCap = db_get("default-perms","u"); + db_unprotect(PROTECT_USER); + db_multi_exec( + "UPDATE user" + " SET cap=%Q" + " WHERE cap='7' AND login=(" + " SELECT suname FROM subscriber" + " WHERE subscriberCode=hextoblob(%Q))", + zNewCap, zName + ); + db_protect_pop(); + login_set_capabilities(zNewCap, 0); + } + @

Your email alert subscription has been verified!

+ @

Use the form below to update your subscription information.

+ @

Hint: Bookmark this page so that you can more easily update + @ your subscription information in the future

+ }else{ + @

Your email address is unverified

+ @

You should have received an email message containing a link + @ that you must visit to verify your account. No email notifications + @ will be sent until your email address has been verified.

+ } + }else{ + @

Make changes to the email subscription shown below and + @ press "Submit".

+ } + form_begin(0, "%R/alerts"); + zHalfCode = db_text("x","SELECT hex(substr(subscriberCode,1,16))" + " FROM subscriber WHERE subscriberId=%d", sid); + @ + @ + @ + @ + if( isLogin ){ + @ + }else{ + @ + } + @ + if( g.perm.Admin ){ + int uid; + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + } + @ + @ + @ + if( strchr(ssub,'k')!=0 ){ + @ + } + @ + @ + @ + @ + if( g.perm.Admin ){ + @ + @ + } + if( eErr==9 ){ + @ + @ + } + @ + @ + @ + @ + @ + fossil_free(zErr); + db_finalize(&q); + style_finish_page(); + db_commit_transaction(); + return; +} + +/* +** WEBPAGE: renew +** +** Users visit this page to update the last-contact date on their +** subscription. The last-contact date is the day that the subscriber +** last interacted with the repository. If the name= query parameter +** (or POST parameter) contains a valid subscriber code, then the last-contact +** subscription associated with that subscriber code is updated to be the +** current date. +*/ +void renewal_page(void){ + const char *zName = P("name"); + int iInterval = db_get_int("email-renew-interval", 0); + Stmt s; + int rc; + + style_header("Subscription Renewal"); + if( zName==0 || strlen(zName)<4 ){ + @

No subscription specified

+ style_finish_page(); + return; + } + + if( !db_table_has_column("repository","subscriber","lastContact") + || iInterval<1 + ){ + @

This repository does not expire email notification subscriptions. + @ No renewals are necessary.

+ style_finish_page(); + return; + } + + db_unprotect(PROTECT_READONLY); + db_prepare(&s, + "UPDATE subscriber" + " SET lastContact=now()/86400" + " WHERE subscriberCode=hextoblob(%Q)" + " RETURNING semail, date('now','+%d days');", + zName, iInterval+1 + ); + rc = db_step(&s); + if( rc==SQLITE_ROW ){ + @

The email notification subscription for %h(db_column_text(&s,0)) + @ has been extended until %h(db_column_text(&s,1)) UTC. + }else{ + @

No such subscriber-id: %h(zName)

+ } + db_finalize(&s); + db_protect_pop(); + style_finish_page(); +} + + +/* This is the message that gets sent to describe how to change +** or modify a subscription +*/ +static const char zUnsubMsg[] = +@ To changes your subscription settings at %s visit this link: +@ +@ %s/alerts/%s +@ +@ To completely unsubscribe from %s, visit the following link: +@ +@ %s/unsubscribe/%s +; + +/* +** WEBPAGE: unsubscribe +** WEBPAGE: oneclickunsub +** +** Users visit this page to be delisted from email alerts. +** +** If a valid subscriber code is supplied in the name= query parameter, +** then that subscriber is delisted. +** +** Otherwise, if the users are logged in, then they are redirected +** to the /alerts page where they have an unsubscribe button. +** +** Non-logged-in users with no name= query parameter are invited to enter +** an email address to which will be sent the unsubscribe link that +** contains the correct subscriber code. +** +** The /unsubscribe page requires comfirmation. The /oneclickunsub +** page unsubscribes immediately without any need to confirm. +*/ +void unsubscribe_page(void){ + const char *zName = P("name"); + char *zErr = 0; + int eErr = 0; + unsigned int uSeed = 0; + const char *zDecoded; + char *zCaptcha = 0; + int dx; + int bSubmit; + const char *zEAddr; + char *zCode = 0; + int sid = 0; + + if( zName==0 ) zName = P("scode"); + + /* If a valid subscriber code is supplied, then either present the user + ** with a confirmation, or if already confirmed, unsubscribe immediately. + */ + if( zName + && (sid = db_int(0, "SELECT subscriberId FROM subscriber" + " WHERE subscriberCode=hextoblob(%Q)", zName))!=0 + ){ + char *zUnsubName = mprintf("confirm%04x", sid); + if( P(zUnsubName)!=0 ){ + alert_unsubscribe(sid, 1); + }else if( sqlite3_strglob("*oneclick*",g.zPath)==0 ){ + alert_unsubscribe(sid, 0); + }else if( P("manage")!=0 ){ + cgi_redirectf("%R/alerts/%s", zName); + }else{ + style_header("Unsubscribe"); + form_begin(0, "%R/unsubscribe"); + @ + @ + @ + @ + @ + @ + @
+ @ + @ ←Cancel your subscription to %h(g.zBaseURL) notifications + @
+ @ + @ ←Make other changes to your subscription preferences + @
+ @ + style_finish_page(); + } + return; + } + + /* Logged in users are redirected to the /alerts page */ + login_check_credentials(); + if( login_is_individual() ){ + cgi_redirectf("%R/alerts"); + return; + } + + style_set_current_feature("alerts"); + + zEAddr = PD("e",""); + dx = atoi(PD("dx","0")); + bSubmit = P("submit")!=0 && P("e")!=0 && cgi_csrf_safe(2); + if( bSubmit ){ + if( !captcha_is_correct(1) ){ + eErr = 2; + zErr = mprintf("enter the security code shown below"); + bSubmit = 0; + } + } + if( bSubmit ){ + zCode = db_text(0,"SELECT hex(subscriberCode) FROM subscriber" + " WHERE semail=%Q", zEAddr); + if( zCode==0 ){ + eErr = 1; + zErr = mprintf("not a valid email address"); + bSubmit = 0; + } + } + if( bSubmit ){ + /* If we get this far, it means that a valid unsubscribe request has + ** been submitted. Send the appropriate email. */ + Blob hdr, body; + AlertSender *pSender = alert_sender_new(0,0); + blob_init(&hdr,0,0); + blob_init(&body,0,0); + blob_appendf(&hdr, "To: <%s>\r\n", zEAddr); + blob_appendf(&hdr, "Subject: Unsubscribe Instructions\r\n"); + blob_appendf(&body, zUnsubMsg/*works-like:"%s%s%s%s%s%s"*/, + g.zBaseURL, g.zBaseURL, zCode, g.zBaseURL, g.zBaseURL, zCode); + alert_send(pSender, &hdr, &body, 0); + style_header("Unsubscribe Instructions Sent"); + if( pSender->zErr ){ + @

Internal Error

+ @

The following error was encountered while trying to send an + @ email to %h(zEAddr): + @

+      @ %h(pSender->zErr)
+      @ 
+ }else{ + @

An email has been sent to "%h(zEAddr)" that explains how to + @ unsubscribe and/or modify your subscription settings

+ } + alert_sender_free(pSender); + style_finish_page(); + return; + } + + /* Non-logged-in users have to enter an email address to which is + ** sent a message containing the unsubscribe link. + */ + style_header("Unsubscribe Request"); + @

Fill out the form below to request an email message that will + @ explain how to unsubscribe and/or change your subscription settings.

+ @ + form_begin(0, "%R/unsubscribe"); + @ + @ + @ + @ + if( eErr==1 ){ + @ + } + @ + uSeed = captcha_seed(); + zDecoded = captcha_decode(uSeed, 0); + zCaptcha = captcha_render(zDecoded); + @ + @ + @ + if( eErr==2 ){ + @ + } + @ + @ + @ + @ + @ + @ + @ + @ + @
+  @ %h(zCaptcha)
+  @ 
+ @ Enter the 8 characters above in the "Security Code" box
+ @
+ @ + fossil_free(zErr); + style_finish_page(); +} + +/* +** WEBPAGE: subscribers +** +** This page, accessible to administrators only, +** shows a list of subscriber email addresses. +** Clicking on an email takes one to the /alerts page +** for that email where the delivery settings can be +** modified. +*/ +void subscriber_list_page(void){ + Blob sql; + Stmt q; + sqlite3_int64 iNow; + int nTotal; + int nPending; + int nDel = 0; + int iCutoff = db_get_int("email-renew-cutoff",0); + int iWarning = db_get_int("email-renew-warning",0); + char zCutoffClr[8]; + char zWarnClr[8]; + if( alert_webpages_disabled() ) return; + login_check_credentials(); + if( !g.perm.Admin ){ + login_needed(0); + return; + } + alert_submenu_common(); + style_submenu_element("Users","setup_ulist"); + style_set_current_feature("alerts"); + style_header("Subscriber List"); + nTotal = db_int(0, "SELECT count(*) FROM subscriber"); + nPending = db_int(0, "SELECT count(*) FROM subscriber WHERE NOT sverified"); + if( nPending>0 && P("purge") && cgi_csrf_safe(0) ){ + int nNewPending; + db_multi_exec( + "DELETE FROM subscriber" + " WHERE NOT sverified AND mtime0 ){ + @

%,d(nTotal) Subscribers, %,d(nPending) Pending

+ if( nDel==0 && 0%,d(nTotal) Subscribers + } + if( nDel>0 ){ + @

*** %d(nDel) pending subscriptions deleted ***

+ } + blob_init(&sql, 0, 0); + blob_append_sql(&sql, + "SELECT subscriberId," /* 0 */ + " semail," /* 1 */ + " ssub," /* 2 */ + " suname," /* 3 */ + " sverified," /* 4 */ + " sdigest," /* 5 */ + " mtime," /* 6 */ + " date(sctime,'unixepoch')," /* 7 */ + " (SELECT uid FROM user WHERE login=subscriber.suname)," /* 8 */ + " coalesce(lastContact,mtime/86400)" /* 9 */ + " FROM subscriber" + ); + if( P("only")!=0 ){ + blob_append_sql(&sql, " WHERE ssub LIKE '%%%q%%'", P("only")); + style_submenu_element("Show All","%R/subscribers"); + } + blob_append_sql(&sql," ORDER BY mtime DESC"); + db_prepare_blob(&q, &sql); + iNow = time(0); + memcpy(zCutoffClr, hash_color("A"), sizeof(zCutoffClr)); + memcpy(zWarnClr, hash_color("HIJ"), sizeof(zWarnClr)); + @ + @ + @ + @ + @ + while( db_step(&q)==SQLITE_ROW ){ + sqlite3_int64 iMtime = db_column_int64(&q, 6); + double rAge = (iNow - iMtime)/86400.0; + int uid = db_column_int(&q, 8); + const char *zUname = db_column_text(&q, 3); + sqlite3_int64 iContact = db_column_int64(&q, 9); + double rContact = (iNow/86400.0) - iContact; + @ + @ + @ + @ + if( uid ){ + @ + } + @ + @ + @ + @ + @ + } + @
Email + @ Events + @ Digest-Only? + @ User + @ Verified? + @ Last change + @ Last contact + @ Created + @
\ + @ %h(db_column_text(&q,1))%h(db_column_text(&q,2))%s(db_column_int(&q,5)?"digest":"")%h(zUname) + }else{ + @ %h(zUname)%s(db_column_int(&q,4)?"yes":"pending")%z(human_readable_age(rAge))\ + if( iContact>iWarning ){ + @ \ + }else if( iContact>iCutoff ){ + @ \ + }else{ + @ \ + } + @ %z(human_readable_age(rContact))%h(db_column_text(&q,7))
+ db_finalize(&q); + style_table_sorter(); + style_finish_page(); +} + +#if LOCAL_INTERFACE +/* +** A single event that might appear in an alert is recorded as an +** instance of the following object. +** +** type values: +** +** c A new check-in +** f An original forum post +** n New forum threads +** r Replies to my forum posts +** x An edit to a prior forum post +** t A new ticket or a change to an existing ticket +** u A user was added or received new permissions +** w A change to a wiki page +** x Edits to forum posts +*/ +struct EmailEvent { + int type; /* 'c', 'f', 'n', 'r', 't', 'u', 'w', 'x' */ + int needMod; /* Pending moderator approval */ + Blob hdr; /* Header content, for forum entries */ + Blob txt; /* Text description to appear in an alert */ + char *zFromName; /* Human name of the sender */ + char *zPriors; /* Upthread sender IDs for forum posts */ + EmailEvent *pNext; /* Next in chronological order */ +}; +#endif + +/* +** Free a linked list of EmailEvent objects +*/ +void alert_free_eventlist(EmailEvent *p){ + while( p ){ + EmailEvent *pNext = p->pNext; + blob_reset(&p->txt); + blob_reset(&p->hdr); + fossil_free(p->zFromName); + fossil_free(p->zPriors); + fossil_free(p); + p = pNext; + } +} + +/* +** Compute a string that is appropriate for the EmailEvent.zPriors field +** for a particular forum post. +** +** This string is an encode list of sender names and rids for all ancestors +** of the fpid post - the post that fpid answers, the post that parent +** post answers, and so forth back up to the root post. Duplicates sender +** names are omitted. +** +** The EmailEvent.zPriors field is used to screen events for people who +** only want to see replies to their own posts or to specific posts. +*/ +static char *alert_compute_priors(int fpid){ + return db_text(0, + "WITH priors(rid,who) AS (" + " SELECT firt, coalesce(euser,user)" + " FROM forumpost LEFT JOIN event ON fpid=objid" + " WHERE fpid=%d" + " UNION ALL" + " SELECT firt, coalesce(euser,user)" + " FROM priors, forumpost LEFT JOIN event ON fpid=objid" + " WHERE fpid=rid" + ")" + "SELECT ','||group_concat(DISTINCT 'u'||who)||" + "','||group_concat(rid) FROM priors;", + fpid + ); +} + +/* +** Compute and return a linked list of EmailEvent objects +** corresponding to the current content of the temp.wantalert +** table which should be defined as follows: +** +** CREATE TEMP TABLE wantalert(eventId TEXT, needMod BOOLEAN); +*/ +EmailEvent *alert_compute_event_text(int *pnEvent, int doDigest){ + Stmt q; + EmailEvent *p; + EmailEvent anchor; + EmailEvent *pLast; + const char *zUrl = db_get("email-url","http://localhost:8080"); + const char *zFrom; + const char *zSub; + + + /* First do non-forum post events */ + db_prepare(&q, + "SELECT" + " CASE WHEN event.type='t'" + " THEN (SELECT substr(tagname,5) FROM tag" + " WHERE tagid=event.tagid AND tagname LIKE 'tkt-%%')" + " ELSE blob.uuid END," /* 0 */ + " datetime(event.mtime)," /* 1 */ + " coalesce(ecomment,comment)" + " || ' (user: ' || coalesce(euser,user,'?')" + " || (SELECT case when length(x)>0 then ' tags: ' || x else '' end" + " FROM (SELECT group_concat(substr(tagname,5), ', ') AS x" + " FROM tag, tagxref" + " WHERE tagname GLOB 'sym-*' AND tag.tagid=tagxref.tagid" + " AND tagxref.rid=blob.rid AND tagxref.tagtype>0))" + " || ')' as comment," /* 2 */ + " wantalert.eventId," /* 3 */ + " wantalert.needMod" /* 4 */ + " FROM temp.wantalert, event, blob" + " WHERE blob.rid=event.objid" + " AND event.objid=substr(wantalert.eventId,2)+0" + " AND (%d OR eventId NOT GLOB 'f*')" + " ORDER BY event.mtime", + doDigest + ); + memset(&anchor, 0, sizeof(anchor)); + pLast = &anchor; + *pnEvent = 0; + while( db_step(&q)==SQLITE_ROW ){ + const char *zType = ""; + const char *zComment = db_column_text(&q, 2); + p = fossil_malloc_zero( sizeof(EmailEvent) ); + pLast->pNext = p; + pLast = p; + p->type = db_column_text(&q, 3)[0]; + p->needMod = db_column_int(&q, 4); + p->zFromName = 0; + p->pNext = 0; + switch( p->type ){ + case 'c': zType = "Check-In"; break; + /* case 'f': -- forum posts omitted from this loop. See below */ + case 't': zType = "Ticket Change"; break; + case 'w': { + zType = "Wiki Edit"; + switch( zComment ? *zComment : 0 ){ + case ':': ++zComment; break; + case '+': zType = "Wiki Added"; ++zComment; break; + case '-': zType = "Wiki Removed"; ++zComment; break; + } + break; + } + } + blob_init(&p->hdr, 0, 0); + blob_init(&p->txt, 0, 0); + blob_appendf(&p->txt,"== %s %s ==\n%s\n%s/info/%.20s\n", + db_column_text(&q,1), + zType, + zComment, + zUrl, + db_column_text(&q,0) + ); + if( p->needMod ){ + blob_appendf(&p->txt, + "** Pending moderator approval (%s/modreq) **\n", + zUrl + ); + } + (*pnEvent)++; + } + db_finalize(&q); + + /* Early-out if forumpost is not a table in this repository */ + if( !db_table_exists("repository","forumpost") ){ + return anchor.pNext; + } + + /* For digests, the previous loop also handled forumposts already */ + if( doDigest ){ + return anchor.pNext; + } + + /* If we reach this point, it means that forumposts exist and this + ** is a normal email alert. Construct full-text forum post alerts + ** using a format that enables them to be sent as separate emails. + */ + db_prepare(&q, + "SELECT" + " forumpost.fpid," /* 0: fpid */ + " (SELECT uuid FROM blob WHERE rid=forumpost.fpid)," /* 1: hash */ + " datetime(event.mtime)," /* 2: date/time */ + " substr(comment,instr(comment,':')+2)," /* 3: comment */ + " (WITH thread(fpid,fprev) AS (" + " SELECT fpid,fprev FROM forumpost AS tx" + " WHERE tx.froot=forumpost.froot)," + " basepid(fpid,bpid) AS (" + " SELECT fpid, fpid FROM thread WHERE fprev IS NULL" + " UNION ALL" + " SELECT thread.fpid, basepid.bpid FROM basepid, thread" + " WHERE basepid.fpid=thread.fprev)" + " SELECT uuid FROM blob, basepid" + " WHERE basepid.fpid=forumpost.firt" + " AND blob.rid=basepid.bpid)," /* 4: in-reply-to */ + " wantalert.needMod," /* 5: moderated */ + " coalesce(display_name(info),euser,user)," /* 6: user */ + " forumpost.fprev IS NULL" /* 7: is an edit */ + " FROM temp.wantalert, event, forumpost" + " LEFT JOIN user ON (login=coalesce(euser,user))" + " WHERE event.objid=substr(wantalert.eventId,2)+0" + " AND eventId GLOB 'f*'" + " AND forumpost.fpid=event.objid" + " ORDER BY event.mtime" + ); + zFrom = db_get("email-self",0); + zSub = db_get("email-subname",""); + while( db_step(&q)==SQLITE_ROW ){ + int fpid = db_column_int(&q,0); + Manifest *pPost = manifest_get(fpid, CFTYPE_FORUM, 0); + const char *zIrt; + const char *zUuid; + const char *zTitle; + const char *z; + if( pPost==0 ) continue; + p = fossil_malloc( sizeof(EmailEvent) ); + pLast->pNext = p; + pLast = p; + p->type = db_column_int(&q,7) ? 'f' : 'x'; + p->needMod = db_column_int(&q, 5); + z = db_column_text(&q,6); + p->zFromName = z && z[0] ? fossil_strdup(z) : 0; + p->zPriors = alert_compute_priors(fpid); + p->pNext = 0; + blob_init(&p->hdr, 0, 0); + zUuid = db_column_text(&q, 1); + zTitle = db_column_text(&q, 3); + if( p->needMod ){ + blob_appendf(&p->hdr, "Subject: %s Pending Moderation: %s\r\n", + zSub, zTitle); + }else{ + blob_appendf(&p->hdr, "Subject: %s %s\r\n", zSub, zTitle); + blob_appendf(&p->hdr, "Message-Id: <%.32s@%s>\r\n", + zUuid, alert_hostname(zFrom)); + zIrt = db_column_text(&q, 4); + if( zIrt && zIrt[0] ){ + blob_appendf(&p->hdr, "In-Reply-To: <%.32s@%s>\r\n", + zIrt, alert_hostname(zFrom)); + } + } + blob_init(&p->txt, 0, 0); + if( p->needMod ){ + blob_appendf(&p->txt, + "** Pending moderator approval (%s/modreq) **\n", + zUrl + ); + } + blob_appendf(&p->txt, + "Forum post by %s on %s\n", + pPost->zUser, db_column_text(&q, 2)); + blob_appendf(&p->txt, "%s/forumpost/%S\n\n", zUrl, zUuid); + blob_append(&p->txt, pPost->zWiki, -1); + manifest_destroy(pPost); + (*pnEvent)++; + } + db_finalize(&q); + + return anchor.pNext; +} + +/* +** Put a header on an alert email +*/ +void email_header(Blob *pOut){ + blob_appendf(pOut, + "This is an automated email reporting changes " + "on Fossil repository %s (%s/timeline)\n", + db_get("email-subname","(unknown)"), + db_get("email-url","http://localhost:8080")); +} + +/* +** COMMAND: test-alert +** +** Usage: %fossil test-alert EVENTID ... +** +** Generate the text of an email alert for all of the EVENTIDs +** listed on the command-line. Or if no events are listed on the +** command line, generate text for all events named in the +** pending_alert table. The text of the email alerts appears on +** standard output. +** +** This command is intended for testing and debugging Fossil itself, +** for example when enhancing the email alert system or fixing bugs +** in the email alert system. If you are not making changes to the +** Fossil source code, this command is probably not useful to you. +** +** EVENTIDs are text. The first character is 'c', 'f', 't', or 'w' +** for check-in, forum, ticket, or wiki. The remaining text is a +** integer that references the EVENT.OBJID value for the event. +** Run /timeline?showid to see these OBJID values. +** +** Options: +** --digest Generate digest alert text +** --needmod Assume all events are pending moderator approval +*/ +void test_alert_cmd(void){ + Blob out; + int nEvent; + int needMod; + int doDigest; + EmailEvent *pEvent, *p; + + doDigest = find_option("digest",0,0)!=0; + needMod = find_option("needmod",0,0)!=0; + db_find_and_open_repository(0, 0); + verify_all_options(); + db_begin_transaction(); + alert_schema(0); + db_multi_exec("CREATE TEMP TABLE wantalert(eventid TEXT, needMod BOOLEAN)"); + if( g.argc==2 ){ + db_multi_exec( + "INSERT INTO wantalert(eventId,needMod)" + " SELECT eventid, %d FROM pending_alert", needMod); + }else{ + int i; + for(i=2; ipNext){ + blob_append(&out, "\n", 1); + if( blob_size(&p->hdr) ){ + blob_append(&out, blob_buffer(&p->hdr), blob_size(&p->hdr)); + blob_append(&out, "\n", 1); + } + blob_append(&out, blob_buffer(&p->txt), blob_size(&p->txt)); + } + alert_free_eventlist(pEvent); + fossil_print("%s", blob_str(&out)); + blob_reset(&out); + db_end_transaction(0); +} + +/* +** COMMAND: test-add-alerts +** +** Usage: %fossil test-add-alerts [OPTIONS] EVENTID ... +** +** Add one or more events to the pending_alert queue. Use this +** command during testing to force email notifications for specific +** events. +** +** EVENTIDs are text. The first character is 'c', 'f', 't', or 'w' +** for check-in, forum, ticket, or wiki. The remaining text is a +** integer that references the EVENT.OBJID value for the event. +** Run /timeline?showid to see these OBJID values. +** +** Options: +** --backoffice Run alert_backoffice() after all alerts have +** been added. This will cause the alerts to be +** sent out with the SENDALERT_TRACE option. +** --debug Like --backoffice, but add the SENDALERT_STDOUT +** so that emails are printed to standard output +** rather than being sent. +** --digest Process emails using SENDALERT_DIGEST +*/ +void test_add_alert_cmd(void){ + int i; + int doAuto = find_option("backoffice",0,0)!=0; + unsigned mFlags = 0; + if( find_option("debug",0,0)!=0 ){ + doAuto = 1; + mFlags = SENDALERT_STDOUT; + } + if( find_option("digest",0,0)!=0 ){ + mFlags |= SENDALERT_DIGEST; + } + db_find_and_open_repository(0, 0); + verify_all_options(); + db_begin_write(); + alert_schema(0); + for(i=2; i\r\n", zEAddr); + blob_appendf(pHdr,"Subject: %s Subscription to %s expires soon\r\n", + zRepoName, zUrl); + blob_appendf(pBody, + "\nTo renew your subscription, click the following link:\n" + "\n %s/renew/%s\n\n", + zUrl, zCode + ); + blob_appendf(pBody, + "You are currently receiving email notification for the following events\n" + "on the %s Fossil repository at %s:\n\n", + zRepoName, zUrl + ); + if( strchr(zSub, 'a') ) blob_appendf(pBody, " * Announcements\n"); + if( strchr(zSub, 'c') ) blob_appendf(pBody, " * Check-ins\n"); + if( strchr(zSub, 'f') ) blob_appendf(pBody, " * Forum posts\n"); + if( strchr(zSub, 't') ) blob_appendf(pBody, " * Ticket changes\n"); + if( strchr(zSub, 'u') ) blob_appendf(pBody, " * User permission elevation\n"); + if( strchr(zSub, 'w') ) blob_appendf(pBody, " * Wiki changes\n"); + blob_appendf(pBody, "\n" + "If you take no action, your subscription will expire and you will be\n" + "unsubscribed in about %d days. To make other changes or to unsubscribe\n" + "immediately, visit the following webpage:\n\n" + " %s/alerts/%s\n\n", + ALERT_RENEWAL_MSG_FREQUENCY, zUrl, zCode + ); +} + +/* +** If zUser is a sender of one of the ancestors of a forum post +** (if zUser appears in zPriors) then return true. +*/ +static int alert_in_priors(const char *zUser, const char *zPriors){ + int n = (int)strlen(zUser); + char zBuf[200]; + if( n>195 ) return 0; + if( zPriors==0 || zPriors[0]==0 ) return 0; + zBuf[0] = ','; + zBuf[1] = 'u'; + memcpy(zBuf+2, zUser, n+1); + return strstr(zPriors, zBuf)!=0; +} + +#if INTERFACE +/* +** Flags for alert_send_alerts() +*/ +#define SENDALERT_DIGEST 0x0001 /* Send a digest */ +#define SENDALERT_PRESERVE 0x0002 /* Do not mark the task as done */ +#define SENDALERT_STDOUT 0x0004 /* Print emails instead of sending */ +#define SENDALERT_TRACE 0x0008 /* Trace operation for debugging */ +#define SENDALERT_RENEWAL 0x0010 /* Send renewal notices */ + +#endif /* INTERFACE */ + +/* +** Send alert emails to subscribers. +** +** This procedure is run by either the backoffice, or in response to the +** "fossil alerts send" command. Details of operation are controlled by +** the flags parameter. +** +** Here is a summary of what happens: +** +** (1) Create a TEMP table wantalert(eventId,needMod) and fill it with +** all the events that we want to send alerts about. The needMod +** flags is set if and only if the event is still awaiting +** moderator approval. Events with the needMod flag are only +** shown to users that have moderator privileges. +** +** (2) Call alert_compute_event_text() to compute a list of EmailEvent +** objects that describe all events about which we want to send +** alerts. +** +** (3) Loop over all subscribers. Compose and send one or more email +** messages to each subscriber that describe the events for +** which the subscriber has expressed interest and has +** appropriate privileges. +** +** (4) Update the pending_alerts table to indicate that alerts have been +** sent. +** +** Update 2018-08-09: Do step (3) before step (4). Update the +** pending_alerts table *before* the emails are sent. That way, if +** the process malfunctions or crashes, some notifications may never +** be sent. But that is better than some recurring bug causing +** subscribers to be flooded with repeated notifications every 60 +** seconds! +*/ +int alert_send_alerts(u32 flags){ + EmailEvent *pEvents, *p; + int nEvent = 0; + int nSent = 0; + Stmt q; + const char *zDigest = "false"; + Blob hdr, body; + const char *zUrl; + const char *zRepoName; + const char *zFrom; + const char *zDest = (flags & SENDALERT_STDOUT) ? "stdout" : 0; + AlertSender *pSender = 0; + u32 senderFlags = 0; + int iInterval = 0; /* Subscription renewal interval */ + + if( g.fSqlTrace ) fossil_trace("-- BEGIN alert_send_alerts(%u)\n", flags); + alert_schema(0); + if( !alert_enabled() && (flags & SENDALERT_STDOUT)==0 ) goto send_alert_done; + zUrl = db_get("email-url",0); + if( zUrl==0 ) goto send_alert_done; + zRepoName = db_get("email-subname",0); + if( zRepoName==0 ) goto send_alert_done; + zFrom = db_get("email-self",0); + if( zFrom==0 ) goto send_alert_done; + if( flags & SENDALERT_TRACE ){ + senderFlags |= ALERT_TRACE; + } + pSender = alert_sender_new(zDest, senderFlags); + + /* Step (1): Compute the alerts that need sending + */ + db_multi_exec( + "DROP TABLE IF EXISTS temp.wantalert;" + "CREATE TEMP TABLE wantalert(eventId TEXT, needMod BOOLEAN, sentMod);" + ); + if( flags & SENDALERT_DIGEST ){ + /* Unmoderated changes are never sent as part of a digest */ + db_multi_exec( + "INSERT INTO wantalert(eventId,needMod)" + " SELECT eventid, 0" + " FROM pending_alert" + " WHERE sentDigest IS FALSE" + " AND NOT EXISTS(SELECT 1 FROM private WHERE rid=substr(eventid,2));" + ); + zDigest = "true"; + }else{ + /* Immediate alerts might include events that are subject to + ** moderator approval */ + db_multi_exec( + "INSERT INTO wantalert(eventId,needMod,sentMod)" + " SELECT eventid," + " EXISTS(SELECT 1 FROM private WHERE rid=substr(eventid,2))," + " sentMod" + " FROM pending_alert" + " WHERE sentSep IS FALSE;" + "DELETE FROM wantalert WHERE needMod AND sentMod;" + ); + } + if( g.fSqlTrace ){ + fossil_trace("-- wantalert contains %d rows\n", + db_int(0, "SELECT count(*) FROM wantalert") + ); + } + + /* Step 2: compute EmailEvent objects for every notification that + ** needs sending. + */ + pEvents = alert_compute_event_text(&nEvent, (flags & SENDALERT_DIGEST)!=0); + if( nEvent==0 ) goto send_alert_expiration_warnings; + + /* Step 4a: Update the pending_alerts table to designate the + ** alerts as having all been sent. This is done *before* step (3) + ** so that a crash will not cause alerts to be sent multiple times. + ** Better a missed alert than being spammed with hundreds of alerts + ** due to a bug. + */ + if( (flags & SENDALERT_PRESERVE)==0 ){ + if( flags & SENDALERT_DIGEST ){ + db_multi_exec( + "UPDATE pending_alert SET sentDigest=true" + " WHERE eventid IN (SELECT eventid FROM wantalert);" + ); + }else{ + db_multi_exec( + "UPDATE pending_alert SET sentSep=true" + " WHERE eventid IN (SELECT eventid FROM wantalert WHERE NOT needMod);" + "UPDATE pending_alert SET sentMod=true" + " WHERE eventid IN (SELECT eventid FROM wantalert WHERE needMod);" + ); + } + } + + /* Step 3: Loop over subscribers. Send alerts + */ + blob_init(&hdr, 0, 0); + blob_init(&body, 0, 0); + db_prepare(&q, + "SELECT" + " hex(subscriberCode)," /* 0 */ + " semail," /* 1 */ + " ssub," /* 2 */ + " fullcap(user.cap)," /* 3 */ + " suname" /* 4 */ + " FROM subscriber LEFT JOIN user ON (login=suname)" + " WHERE sverified" + " AND NOT sdonotcall" + " AND sdigest IS %s" + " AND coalesce(subscriber.lastContact*86400,subscriber.mtime)>=%d", + zDigest/*safe-for-%s*/, + db_get_int("email-renew-cutoff",0) + ); + while( db_step(&q)==SQLITE_ROW ){ + const char *zCode = db_column_text(&q, 0); + const char *zSub = db_column_text(&q, 2); + const char *zEmail = db_column_text(&q, 1); + const char *zCap = db_column_text(&q, 3); + const char *zUser = db_column_text(&q, 4); + int nHit = 0; + for(p=pEvents; p; p=p->pNext){ + if( strchr(zSub,p->type)==0 ){ + if( p->type!='f' ) continue; + if( strchr(zSub,'n')!=0 && (p->zPriors==0 || p->zPriors[0]==0) ){ + /* New post: accepted */ + }else if( strchr(zSub,'r')!=0 && zUser!=0 + && alert_in_priors(zUser, p->zPriors) ){ + /* A follow-up to a post written by the user: accept */ + }else{ + continue; + } + } + if( p->needMod ){ + /* For events that require moderator approval, only send an alert + ** if the recipient is a moderator for that type of event. Setup + ** and Admin users always get notified. */ + char xType = '*'; + if( strpbrk(zCap,"as")==0 ){ + switch( p->type ){ + case 'x': case 'f': + case 'n': case 'r': xType = '5'; break; + case 't': xType = 'q'; break; + case 'w': xType = 'l'; break; + /* Note: case 'u' is not handled here */ + } + if( strchr(zCap,xType)==0 ) continue; + } + }else if( strchr(zCap,'s')!=0 || strchr(zCap,'a')!=0 ){ + /* Setup and admin users can get any notification that does not + ** require moderation */ + }else{ + /* Other users only see the alert if they have sufficient + ** privilege to view the event itself */ + char xType = '*'; + switch( p->type ){ + case 'c': xType = 'o'; break; + case 'x': case 'f': + case 'n': case 'r': xType = '2'; break; + case 't': xType = 'r'; break; + case 'w': xType = 'j'; break; + /* Note: case 'u' is not handled here */ + } + if( strchr(zCap,xType)==0 ) continue; + } + if( blob_size(&p->hdr)>0 ){ + /* This alert should be sent as a separate email */ + Blob fhdr, fbody; + blob_init(&fhdr, 0, 0); + blob_appendf(&fhdr, "To: <%s>\r\n", zEmail); + blob_append(&fhdr, blob_buffer(&p->hdr), blob_size(&p->hdr)); + blob_init(&fbody, blob_buffer(&p->txt), blob_size(&p->txt)); + if( pSender->zListId && pSender->zListId[0] ){ + blob_appendf(&fhdr, "List-Id: %s\r\n", pSender->zListId); + blob_appendf(&fhdr, "List-Unsubscribe: <%s/oneclickunsub/%s>\r\n", + zUrl, zCode); + blob_appendf(&fhdr, + "List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n"); + blob_appendf(&fbody, "\n-- \nUnsubscribe: %s/unsubscribe/%s\n", + zUrl, zCode); + /* blob_appendf(&fbody, "Subscription settings: %s/alerts/%s\n", + ** zUrl, zCode); */ + } + alert_send(pSender,&fhdr,&fbody,p->zFromName); + nSent++; + blob_reset(&fhdr); + blob_reset(&fbody); + }else{ + /* Events other than forum posts are gathered together into + ** a single email message */ + if( nHit==0 ){ + blob_appendf(&hdr,"To: <%s>\r\n", zEmail); + blob_appendf(&hdr,"Subject: %s activity alert\r\n", zRepoName); + blob_appendf(&body, + "This is an automated email sent by the Fossil repository " + "at %s to report changes.\n", + zUrl + ); + } + nHit++; + blob_append(&body, "\n", 1); + blob_append(&body, blob_buffer(&p->txt), blob_size(&p->txt)); + } + } + if( nHit==0 ) continue; + if( pSender->zListId && pSender->zListId[0] ){ + blob_appendf(&hdr, "List-Id: %s\r\n", pSender->zListId); + blob_appendf(&hdr, "List-Unsubscribe: <%s/oneclickunsub/%s>\r\n", + zUrl, zCode); + blob_appendf(&hdr, + "List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n"); + blob_appendf(&body,"\n-- \nSubscription info: %s/alerts/%s\n", + zUrl, zCode); + } + alert_send(pSender,&hdr,&body,0); + nSent++; + blob_truncate(&hdr, 0); + blob_truncate(&body, 0); + } + blob_reset(&hdr); + blob_reset(&body); + db_finalize(&q); + alert_free_eventlist(pEvents); + + /* Step 4b: Update the pending_alerts table to remove all of the + ** alerts that have been completely sent. + */ + db_multi_exec("DELETE FROM pending_alert WHERE sentDigest AND sentSep;"); + + /* Send renewal messages to subscribers whose subscriptions are about + ** to expire. Only do this if: + ** + ** (1) email-renew-interval is 14 or greater (or in other words if + ** subscription expiration is enabled). + ** + ** (2) The SENDALERT_RENEWAL flag is set + */ +send_alert_expiration_warnings: + if( (flags & SENDALERT_RENEWAL)!=0 + && (iInterval = db_get_int("email-renew-interval",0))>=14 + ){ + int iNow = (int)(time(0)/86400); + int iOldWarn = db_get_int("email-renew-warning",0); + int iNewWarn = iNow - iInterval + ALERT_RENEWAL_MSG_FREQUENCY; + if( iNewWarn >= iOldWarn + ALERT_RENEWAL_MSG_FREQUENCY ){ + db_prepare(&q, + "SELECT" + " hex(subscriberCode)," /* 0 */ + " lastContact," /* 1 */ + " semail," /* 2 */ + " ssub" /* 3 */ + " FROM subscriber" + " WHERE lastContact<=%d AND lastContact>%d" + " AND NOT sdonotcall" + " AND length(sdigest)>0", + iNewWarn, iOldWarn + ); + while( db_step(&q)==SQLITE_ROW ){ + Blob hdr, body; + const char *zCode = db_column_text(&q,0); + blob_init(&hdr, 0, 0); + blob_init(&body, 0, 0); + alert_renewal_msg(&hdr, &body, + zCode, + db_column_int(&q,1), + db_column_text(&q,2), + db_column_text(&q,3), + zRepoName, zUrl); + if( pSender->zListId && pSender->zListId[0] ){ + blob_appendf(&hdr, "List-Id: %s\r\n", pSender->zListId); + blob_appendf(&hdr, "List-Unsubscribe: <%s/oneclickunsub/%s>\r\n", + zUrl, zCode); + blob_appendf(&hdr, + "List-Unsubscribe-Post: List-Unsubscribe=One-Click\r\n"); + blob_appendf(&body, "\n-- \nUnsubscribe: %s/unsubscribe/%s\n", + zUrl, zCode); + } + alert_send(pSender,&hdr,&body,0); + blob_reset(&hdr); + blob_reset(&body); + } + db_finalize(&q); + if( (flags & SENDALERT_PRESERVE)==0 ){ + if( iOldWarn>0 ){ + db_set_int("email-renew-cutoff", iOldWarn, 0); + } + db_set_int("email-renew-warning", iNewWarn, 0); + } + } + } + +send_alert_done: + alert_sender_free(pSender); + if( g.fSqlTrace ) fossil_trace("-- END alert_send_alerts(%u)\n", flags); + return nSent; +} + +/* +** Do backoffice processing for email notifications. In other words, +** check to see if any email notifications need to occur, and then +** do them. +** +** This routine is intended to run in the background, after webpages. +** +** The mFlags option is zero or more of the SENDALERT_* flags. Normally +** this flag is zero, but the test-set-alert command sets it to +** SENDALERT_TRACE. +*/ +int alert_backoffice(u32 mFlags){ + int iJulianDay; + int nSent = 0; + if( !alert_tables_exist() ) return 0; + nSent = alert_send_alerts(mFlags); + iJulianDay = db_int(0, "SELECT julianday('now')"); + if( iJulianDay>db_get_int("email-last-digest",0) ){ + db_set_int("email-last-digest",iJulianDay,0); + nSent += alert_send_alerts(SENDALERT_DIGEST|SENDALERT_RENEWAL|mFlags); + } + return nSent; +} + +/* +** WEBPAGE: contact_admin +** +** A web-form to send an email message to the repository administrator, +** or (with appropriate permissions) to anybody. +*/ +void contact_admin_page(void){ + const char *zAdminEmail = db_get("email-admin",0); + unsigned int uSeed = 0; + const char *zDecoded; + char *zCaptcha = 0; + + login_check_credentials(); + style_set_current_feature("alerts"); + if( zAdminEmail==0 || zAdminEmail[0]==0 ){ + style_header("Outbound Email Disabled"); + @

Outbound email is disabled on this repository + style_finish_page(); + return; + } + if( P("submit")!=0 + && P("subject")!=0 + && P("msg")!=0 + && P("from")!=0 + && cgi_csrf_safe(2) + && captcha_is_correct(0) + ){ + Blob hdr, body; + AlertSender *pSender = alert_sender_new(0,0); + blob_init(&hdr, 0, 0); + blob_appendf(&hdr, "To: <%s>\r\nSubject: %s administrator message\r\n", + zAdminEmail, db_get("email-subname","Fossil Repo")); + blob_init(&body, 0, 0); + blob_appendf(&body, "Message from [%s]\n", PT("from")/*safe-for-%s*/); + blob_appendf(&body, "Subject: [%s]\n\n", PT("subject")/*safe-for-%s*/); + blob_appendf(&body, "%s", PT("msg")/*safe-for-%s*/); + alert_send(pSender, &hdr, &body, 0); + style_header("Message Sent"); + if( pSender->zErr ){ + @

Internal Error

+ @

The following error was reported by the system: + @

+      @ %h(pSender->zErr)
+      @ 
+ }else{ + @

Your message has been sent to the repository administrator. + @ Thank you for your input.

+ } + alert_sender_free(pSender); + style_finish_page(); + return; + } + if( captcha_needed() ){ + uSeed = captcha_seed(); + zDecoded = captcha_decode(uSeed, 0); + zCaptcha = captcha_render(zDecoded); + } + style_set_current_feature("alerts"); + style_header("Message To Administrator"); + form_begin(0, "%R/contact_admin"); + @

Enter a message to the repository administrator below:

+ @ + if( zCaptcha ){ + @ + @ + @ + @ + } + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + @ + if( zCaptcha ){ + @
+    @ %h(zCaptcha)
+    @ 
+ @ Enter the 8 characters above in the "Security Code" box
+ @
+ } + @ + style_finish_page(); +} + +/* +** Send an announcement message described by query parameter. +** Permission to do this has already been verified. +*/ +static char *alert_send_announcement(void){ + AlertSender *pSender; + char *zErr; + const char *zTo = PT("to"); + char *zSubject = PT("subject"); + int bAll = PB("all"); + int bAA = PB("aa"); + int bMods = PB("mods"); + const char *zSub = db_get("email-subname", "[Fossil Repo]"); + const char *zName = P("name"); /* Debugging options */ + const char *zDest = 0; /* How to send the announcement */ + int bTest = 0; + Blob hdr, body; + + if( fossil_strcmp(zName, "test2")==0 ){ + bTest = 2; + zDest = "blob"; + }else if( fossil_strcmp(zName, "test3")==0 ){ + bTest = 3; + if( fossil_strcmp(db_get("email-send-method",""),"relay")==0 ){ + zDest = "debug-relay"; + } + } + blob_init(&body, 0, 0); + blob_init(&hdr, 0, 0); + blob_appendf(&body, "%s", PT("msg")/*safe-for-%s*/); + pSender = alert_sender_new(zDest, 0); + if( zTo[0] ){ + blob_appendf(&hdr, "To: <%s>\r\nSubject: %s %s\r\n", zTo, zSub, zSubject); + alert_send(pSender, &hdr, &body, 0); + } + if( bAll || bAA || bMods ){ + Stmt q; + int nUsed = blob_size(&body); + const char *zURL = db_get("email-url",0); + if( bAll ){ + db_prepare(&q, "SELECT semail, hex(subscriberCode) FROM subscriber " + " WHERE sverified AND NOT sdonotcall"); + }else if( bAA ){ + db_prepare(&q, "SELECT semail, hex(subscriberCode) FROM subscriber " + " WHERE sverified AND NOT sdonotcall" + " AND ssub LIKE '%%a%%'"); + }else if( bMods ){ + db_prepare(&q, + "SELECT semail, hex(subscriberCode)" + " FROM subscriber, user " + " WHERE sverified AND NOT sdonotcall" + " AND suname=login" + " AND fullcap(cap) GLOB '*5*'"); + } + while( db_step(&q)==SQLITE_ROW ){ + const char *zCode = db_column_text(&q, 1); + zTo = db_column_text(&q, 0); + blob_truncate(&hdr, 0); + blob_appendf(&hdr, "To: <%s>\r\nSubject: %s %s\r\n", zTo, zSub, zSubject); + if( zURL ){ + blob_truncate(&body, nUsed); + blob_appendf(&body,"\n-- \nSubscription info: %s/alerts/%s\n", + zURL, zCode); + } + alert_send(pSender, &hdr, &body, 0); + } + db_finalize(&q); + } + if( bTest && blob_size(&pSender->out) ){ + /* If the URL is "/announce/test2" then no email is actually sent. + ** Instead, the text of the email that would have been sent is + ** displayed in the result window. + ** + ** If the URL is "/announce/test3" and the email-send-method is "relay" + ** then the announcement is sent as it normally would be, but a + ** transcript of the SMTP conversation with the MTA is shown here. + */ + blob_trim(&pSender->out); + @
+    @ %h(blob_str(&pSender->out))
+    @ 
+ blob_reset(&pSender->out); + } + zErr = pSender->zErr; + pSender->zErr = 0; + alert_sender_free(pSender); + return zErr; +} + + +/* +** WEBPAGE: announce +** +** A web-form, available to users with the "Send-Announcement" or "A" +** capability, that allows one to send announcements to whomever +** has subscribed to receive announcements. The administrator can +** also send a message to an arbitrary email address and/or to all +** subscribers regardless of whether or not they have elected to +** receive announcements. +*/ +void announce_page(void){ + const char *zAction = "announce"; + const char *zName = PD("name",""); + /* + ** Debugging Notes: + ** + ** /announce/test1 -> Shows query parameter values + ** /announce/test2 -> Shows the formatted message but does + ** not send it. + ** /announce/test3 -> Sends the message, but also shows + ** the SMTP transcript. + */ + login_check_credentials(); + if( !g.perm.Announce ){ + login_needed(0); + return; + } + if( !g.perm.Setup ){ + zName = 0; /* Disable debugging feature for non-admin users */ + } + style_set_current_feature("alerts"); + if( fossil_strcmp(zName,"test1")==0 ){ + /* Visit the /announce/test1 page to see the CGI variables */ + zAction = "announce/test1"; + @

+ cgi_print_all(0, 0, 0); + @

+ }else if( P("submit")!=0 && cgi_csrf_safe(2) ){ + char *zErr = alert_send_announcement(); + style_header("Announcement Sent"); + if( zErr ){ + @

Error

+ @

The following error was reported by the + @ announcement-sending subsystem: + @

+      @ %h(zErr)
+      @ 
+ }else{ + @

The announcement has been sent. + @ Send another

+ } + style_finish_page(); + return; + } else if( !alert_enabled() ){ + style_header("Cannot Send Announcement"); + @

Either you have no subscribers yet, or email alerts are not yet + @ set up + @ for this repository.

+ return; + } + + style_header("Send Announcement"); + alert_submenu_common(); + if( fossil_strcmp(zName,"test2")==0 ){ + zAction = "announce/test2"; + }else if( fossil_strcmp(zName,"test3")==0 ){ + zAction = "announce/test3"; + } + @
+ login_insert_csrf_secret(); + @ + if( g.perm.Admin ){ + int aa = PB("aa"); + int all = PB("all"); + int aMod = PB("mods"); + const char *aack = aa ? "checked" : ""; + const char *allck = all ? "checked" : ""; + const char *modck = aMod ? "checked" : ""; + @ + @ + @ + @ + } + @ + @ + @ + @ + @ + @ + @ + @ + @ + if( fossil_strcmp(zName,"test2")==0 ){ + @ + @ + @
+ if( g.perm.Setup ){ + @
+ @

Trouble-shooting Options:

+ @
    + @
  1. Normal Processing + @
  2. Only show POST parameters + @ - Do not send the announcement. + @
  3. Show the email text but do + @ not actually send it. + @
  4. Send the message and also show the + @ SMTP traffic when using "relay" mode. + @
+ } + style_finish_page(); +} diff --git a/src/alerts/bflat2.wav b/src/alerts/bflat2.wav new file mode 100644 index 0000000..6af804e Binary files /dev/null and b/src/alerts/bflat2.wav differ diff --git a/src/alerts/bflat3.wav b/src/alerts/bflat3.wav new file mode 100644 index 0000000..055641f Binary files /dev/null and b/src/alerts/bflat3.wav differ diff --git a/src/alerts/bloop.wav b/src/alerts/bloop.wav new file mode 100644 index 0000000..c9de11d Binary files /dev/null and b/src/alerts/bloop.wav differ diff --git a/src/alerts/mkwav.c b/src/alerts/mkwav.c new file mode 100644 index 0000000..3e7c825 --- /dev/null +++ b/src/alerts/mkwav.c @@ -0,0 +1,88 @@ +/* +** This C program was used to generate the "g-minor-triad.wav" file. +** A small modification generated the "b-flat.wav" file. +** +** This code is saved as an historical reference. It is not part +** of Fossil. +*/ +#include +#include +#include + +/* +** Write a four-byte little-endian integer value to out. +*/ +void write_int4(FILE *out, unsigned int i){ + unsigned char z[4]; + z[0] = i&0xff; + z[1] = (i>>8)&0xff; + z[2] = (i>>16)&0xff; + z[3] = (i>>24)&0xff; + fwrite(z, 4, 1, out); +} + +/* +** Write out the WAV file +*/ +void write_wave( + const char *zFilename, /* The file to write */ + unsigned int nData, /* Bytes of data */ + unsigned char *aData /* 8000 samples/sec, 8 bit samples */ +){ + const unsigned char aWavFmt[] = { + 0x57, 0x41, 0x56, 0x45, /* "WAVE" */ + 0x66, 0x6d, 0x74, 0x20, /* "fmt " */ + 0x10, 0x00, 0x00, 0x00, /* 16 bytes in the "fmt " section */ + 0x01, 0x00, /* FormatTag: WAVE_FORMAT_PCM */ + 0x01, 0x00, /* 1 channel */ + 0x40, 0x1f, 0x00, 0x00, /* 8000 samples/second */ + 0x40, 0x1f, 0x00, 0x00, /* 8000 bytes/second */ + 0x01, 0x00, /* Block alignment */ + 0x08, 0x00, /* bits/sample */ + 0x64, 0x61, 0x74, 0x61, /* "data" */ + }; + FILE *out = fopen(zFilename,"wb"); + if( out==0 ){ + fprintf(stderr, "cannot open \"%s\" for writing\n", zFilename); + exit(1); + } + fwrite("RIFF", 4, 1, out); + write_int4(out, nData+4+20+8); + fwrite(aWavFmt, sizeof(aWavFmt), 1, out); + write_int4(out, nData); + fwrite(aData, nData, 1, out); + fclose(out); +} + +int main(int argc, char **argv){ + int i = 0; + unsigned char aBuf[800]; +# define N sizeof(aBuf) +# define pitch1 195.9977*2 /* G */ +# define pitch2 233.0819*2 /* B-flat */ +# define pitch3 293.6648*2 /* D */ + while( iN-200 ){ + v = v*(N-i)/200.0; + } + aBuf[i] = (char)(v+99.0); + i++; + } + while( iN-200 ){ + v = v*(N-i)/200.0; + } + aBuf[i] = (char)(v+99.0); + i++; + } + write_wave("out.wav", N, aBuf); + return 0; +} diff --git a/src/alerts/plunk.wav b/src/alerts/plunk.wav new file mode 100644 index 0000000..7ca7ed2 Binary files /dev/null and b/src/alerts/plunk.wav differ diff --git a/src/allrepo.c b/src/allrepo.c new file mode 100644 index 0000000..689e7b4 --- /dev/null +++ b/src/allrepo.c @@ -0,0 +1,546 @@ +/* +** Copyright (c) 2008 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 to implement the "all" command-line method. +*/ +#include "config.h" +#include "allrepo.h" +#include + +/* +** Build a string that contains all of the command-line options +** specified as arguments. collect_argument() is used for stand-alone +** options and collect_argument_value() is used for options that are +** followed by an argument value. +*/ +static void collect_argument(Blob *pExtra,const char *zArg,const char *zShort){ + const char *z = find_option(zArg, zShort, 0); + if( z!=0 ){ + blob_appendf(pExtra, " %s", z); + } +} +static void collect_argument_value( + Blob *pExtra, const char *zArg, const char *zShort +){ + const char *zValue = find_option(zArg, zShort, 1); + if( zValue ){ + if( zValue[0] ){ + blob_appendf(pExtra, " --%s %$", zArg, zValue); + }else{ + blob_appendf(pExtra, " --%s \"\"", zArg); + } + } +} +static void collect_argv(Blob *pExtra, int iStart){ + int i; + for(i=iStart; i0 ){ + const char *zSql = "DELETE FROM global_config WHERE name IN toDel"; + if( dryRunFlag ){ + fossil_print("%s\n", zSql); + }else{ + db_unprotect(PROTECT_CONFIG); + db_multi_exec("%s", zSql /*safe-for-%s*/ ); + db_protect_pop(); + } + } + + if( stopOnError && bHalted ){ + fossil_fatal("STOPPED: non-zero result code (%d) from\nSTOPPED: %s", + rc, zSyscmd); + } +} diff --git a/src/attach.c b/src/attach.c new file mode 100644 index 0000000..c3fd4e9 --- /dev/null +++ b/src/attach.c @@ -0,0 +1,1859 @@ +/* +** 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); + } + } +} diff --git a/src/backlink.c b/src/backlink.c new file mode 100644 index 0000000..82ad714 --- /dev/null +++ b/src/backlink.c @@ -0,0 +1,456 @@ +/* +** Copyright (c) 2020 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@sqlite.org +** http://www.hwaci.com/drh/ +** +******************************************************************************* +** +** This file contains code to implement for managing backlinks and +** the "backlink" table of the repository database. +** +** A backlink is a reference in Fossil-Wiki or Markdown to some other +** object in the repository. +*/ +#include "config.h" +#include "backlink.h" +#include + + +/* +** Show a graph of all wiki, tickets, and check-ins that refer to object zUuid. +** +** If zLabel is not NULL and the graph is not empty, then output zLabel as +** a prefix to the graph. +*/ +void render_backlink_graph(const char *zUuid, const char *zLabel){ + Blob sql; + Stmt q; + char *zGlob; + int needEndPanel = 0; + zGlob = mprintf("%.5s*", zUuid); + db_multi_exec( + "CREATE TEMP TABLE IF NOT EXISTS ok(rid INTEGER PRIMARY KEY);\n" + "DELETE FROM ok;\n" + "INSERT OR IGNORE INTO ok(rid)\n" + " SELECT CASE srctype\n" + " WHEN 2 THEN (SELECT rid FROM tagxref WHERE tagid=backlink.srcid\n" + " ORDER BY mtime DESC LIMIT 1)\n" + " ELSE srcid END\n" + " FROM backlink\n" + " WHERE target GLOB %Q" + " AND %Q GLOB (target || '*');", + zGlob, zUuid + ); + if( !db_exists("SELECT 1 FROM ok") ) return; + if( zLabel ){ + cgi_printf("%s", zLabel); + if( strstr(zLabel, "accordion")!=0 ){ + cgi_printf("
\n"); + needEndPanel = 1; + } + } + blob_zero(&sql); + blob_append(&sql, timeline_query_for_www(), -1); + blob_append_sql(&sql, " AND event.objid IN ok ORDER BY mtime DESC"); + db_prepare(&q, "%s", blob_sql_text(&sql)); + www_print_timeline(&q, + TIMELINE_DISJOINT|TIMELINE_GRAPH|TIMELINE_NOSCROLL|TIMELINE_REFS, 0); + db_finalize(&q); + if( needEndPanel ){ + cgi_printf("
\n"); + } +} + +/* +** WEBPAGE: test-backlink-timeline +** +** Show a timeline of all check-ins and other events that have entries +** in the backlink table. This is used for testing the rendering +** of the "References" section of the /info page. +*/ +void backlink_timeline_page(void){ + Blob sql; + Stmt q; + + login_check_credentials(); + if( !g.perm.Read || !g.perm.RdTkt || !g.perm.RdWiki ){ + login_needed(g.anon.Read && g.anon.RdTkt && g.anon.RdWiki); + return; + } + style_set_current_feature("test"); + style_header("Backlink Timeline (Internal Testing Use)"); + db_multi_exec( + "CREATE TEMP TABLE IF NOT EXISTS ok(rid INTEGER PRIMARY KEY);" + "DELETE FROM ok;" + "INSERT OR IGNORE INTO ok" + " SELECT blob.rid FROM backlink, blob" + " WHERE blob.uuid BETWEEN backlink.target AND (backlink.target||'x')" + ); + blob_zero(&sql); + blob_append(&sql, timeline_query_for_www(), -1); + blob_append_sql(&sql, " AND event.objid IN ok ORDER BY mtime DESC"); + db_prepare(&q, "%s", blob_sql_text(&sql)); + www_print_timeline(&q, TIMELINE_DISJOINT|TIMELINE_GRAPH|TIMELINE_NOSCROLL,0); + db_finalize(&q); + style_finish_page(); +} + +/* +** WEBPAGE: test-backlinks +** +** Show a table of all backlinks. Admin access only. +*/ +void backlink_table_page(void){ + Stmt q; + int n; + login_check_credentials(); + if( !g.perm.Admin ){ + login_needed(g.anon.Admin); + return; + } + style_set_current_feature("test"); + style_header("Backlink Table (Internal Testing Use)"); + n = db_int(0, "SELECT count(*) FROM backlink"); + @

%d(n) backlink table entries:

+ db_prepare(&q, + "SELECT target, srctype, srcid, datetime(mtime)," + " CASE srctype" + " WHEN 2 THEN (SELECT substr(tagname,6) FROM tag" + " WHERE tagid=srcid AND tagname GLOB 'wiki-*')" + " ELSE null END FROM backlink" + ); + style_table_sorter(); + @ + @ + @ + while( db_step(&q)==SQLITE_ROW ){ + const char *zTarget = db_column_text(&q, 0); + int srctype = db_column_int(&q, 1); + int srcid = db_column_int(&q, 2); + const char *zMtime = db_column_text(&q, 3); + @ + } + @ + @
Target Source mtime
%h(zTarget) + switch( srctype ){ + case BKLNK_COMMENT: { + @ checkin-%d(srcid) + break; + } + case BKLNK_TICKET: { + @ ticket-%d(srcid) + break; + } + case BKLNK_WIKI: { + const char *zName = db_column_text(&q, 4); + @ wiki-%d(srcid) + break; + } + case BKLNK_EVENT: { + @ tecknote-%d(srcid) + break; + } + case BKLNK_FORUM: { + @ forum-%d(srcid) + break; + } + default: { + @ unknown(%d(srctype)) - %d(srcid) + break; + } + } + @ %h(zMtime)
+ db_finalize(&q); + style_finish_page(); +} + +/* +** Remove all prior backlinks for the wiki page given. Then +** add new backlinks for the latest version of the wiki page. +*/ +void backlink_wiki_refresh(const char *zWikiTitle){ + int tagid = wiki_tagid(zWikiTitle); + int rid; + Manifest *pWiki; + if( tagid==0 ) return; + rid = db_int(0, "SELECT rid FROM tagxref WHERE tagid=%d" + " ORDER BY mtime DESC LIMIT 1", tagid); + if( rid==0 ) return; + pWiki = manifest_get(rid, CFTYPE_WIKI, 0); + if( pWiki ){ + int mimetype = parse_mimetype( pWiki->zMimetype ); + backlink_extract(pWiki->zWiki, mimetype, tagid, BKLNK_WIKI, + pWiki->rDate, 1); + manifest_destroy(pWiki); + } +} + +/* +** Structure used to pass down state information through the +** markup formatters into the BACKLINK generator. +*/ +#if INTERFACE +struct Backlink { + int srcid; /* srcid for the source document */ + int srctype; /* One of BKLNK_*. 0=comment 1=ticket 2=wiki */ + double mtime; /* mtime field for new BACKLINK table entries */ +}; +#endif + + +/* +** zTarget is a hyperlink target in some markup format. If this +** target is a self-reference to some other object in the repository, +** then create an appropriate backlink. +*/ +void backlink_create(Backlink *p, const char *zTarget, int nTarget){ + char zLink[HNAME_MAX+4]; + if( zTarget==0 ) return; + if( nTarget<4 ) return; + if( nTarget>=10 && strncmp(zTarget,"/info/",6)==0 ){ + zTarget += 6; + nTarget -= 6; + } + if( nTarget>HNAME_MAX ) return; + if( !validate16(zTarget, nTarget) ) return; + memcpy(zLink, zTarget, nTarget); + zLink[nTarget] = 0; + canonical16(zLink, nTarget); + db_multi_exec( + "REPLACE INTO backlink(target,srctype,srcid,mtime)" + "VALUES(%Q,%d,%d,%.17g)", zLink, p->srctype, p->srcid, p->mtime + ); +} + +/* +** This routine is called by the markdown formatter for each hyperlink. +** If the hyperlink is a backlink, add it to the BACKLINK table. +*/ +static int backlink_md_link( + Blob *ob, /* Write output text here (not used in this case) */ + Blob *target, /* The hyperlink target */ + Blob *title, /* Hyperlink title */ + Blob *content, /* Content of the link */ + void *opaque +){ + Backlink *p = (Backlink*)opaque; + char *zTarget = blob_buffer(target); + int nTarget = blob_size(target); + + backlink_create(p, zTarget, nTarget); + return 1; +} + +/* No-op routines for the rendering callbacks that we do not need */ +static void mkdn_noop_prolog(Blob *b, void *v){ return; } +static void (*mkdn_noop_epilog)(Blob*, void*) = mkdn_noop_prolog; +static void mkdn_noop_footnotes(Blob *b1, const Blob *b2, void *v){ return; } +static void mkdn_noop_blockcode(Blob *b1, Blob *b2, void *v){ return; } +static void (*mkdn_noop_blockquote)(Blob*, Blob*, void*) = mkdn_noop_blockcode; +static void (*mkdn_noop_blockhtml)(Blob*, Blob*, void*) = mkdn_noop_blockcode; +static void mkdn_noop_header(Blob *b1, Blob *b2, int i, void *v){ return; } +static void (*mkdn_noop_hrule)(Blob*, void*) = mkdn_noop_prolog; +static void (*mkdn_noop_list)(Blob*, Blob*, int, void*) = mkdn_noop_header; +static void (*mkdn_noop_listitem)(Blob*, Blob*, int, void*) = mkdn_noop_header; +static void (*mkdn_noop_paragraph)(Blob*, Blob*, void*) = mkdn_noop_blockcode; +static void mkdn_noop_table(Blob *b1, Blob *b2, Blob *b3, void *v){ return; } +static void (*mkdn_noop_table_cell)(Blob*, Blob*, int, + void*) = mkdn_noop_header; +static void (*mkdn_noop_table_row)(Blob*, Blob*, int, + void*) = mkdn_noop_header; +static void mkdn_noop_footnoteitm(Blob *b1, const Blob *b2, int i1, int i2, + void *v){ return; } +static int mkdn_noop_autolink(Blob *b1, Blob *b2, enum mkd_autolink e, + void *v){ return 1; } +static int mkdn_noop_codespan(Blob *b1, Blob *b2, int i, void *v){ return 1; } +static int mkdn_noop_emphasis(Blob *b1, Blob *b2, char c, void *v){ return 1; } +static int (*mkdn_noop_dbl_emphas)(Blob*, Blob*, char, + void*) = mkdn_noop_emphasis; +static int mkdn_noop_image(Blob *b1, Blob *b2, Blob *b3, Blob *b4, + void *v){ return 1; } +static int mkdn_noop_linebreak(Blob *b1, void *v){ return 1; } +static int mkdn_noop_r_html_tag(Blob *b1, Blob *b2, void *v){ return 1; } +static int (*mkdn_noop_tri_emphas)(Blob*, Blob*, char, + void*) = mkdn_noop_emphasis; +static int mkdn_noop_footnoteref(Blob *b1, const Blob *b2, const Blob *b3, + int i1, int i2, void *v){ return 1; } + +/* +** Scan markdown text and add self-hyperlinks to the BACKLINK table. +*/ +void markdown_extract_links( + char *zInputText, + Backlink *p +){ + struct mkd_renderer html_renderer = { + /* prolog */ mkdn_noop_prolog, + /* epilog */ mkdn_noop_epilog, + /* footnotes */ mkdn_noop_footnotes, + + /* blockcode */ mkdn_noop_blockcode, + /* blockquote */ mkdn_noop_blockquote, + /* blockhtml */ mkdn_noop_blockhtml, + /* header */ mkdn_noop_header, + /* hrule */ mkdn_noop_hrule, + /* list */ mkdn_noop_list, + /* listitem */ mkdn_noop_listitem, + /* paragraph */ mkdn_noop_paragraph, + /* table */ mkdn_noop_table, + /* table_cell */ mkdn_noop_table_cell, + /* table_row */ mkdn_noop_table_row, + /* footnoteitm*/ mkdn_noop_footnoteitm, + + /* autolink */ mkdn_noop_autolink, + /* codespan */ mkdn_noop_codespan, + /* dbl_emphas */ mkdn_noop_dbl_emphas, + /* emphasis */ mkdn_noop_emphasis, + /* image */ mkdn_noop_image, + /* linebreak */ mkdn_noop_linebreak, + /* link */ backlink_md_link, + /* r_html_tag */ mkdn_noop_r_html_tag, + /* tri_emphas */ mkdn_noop_tri_emphas, + /* footnoteref*/ mkdn_noop_footnoteref, + + 0, /* entity */ + 0, /* normal_text */ + "*_", /* emphasis characters */ + 0 /* client data */ + }; + Blob out, in; + html_renderer.opaque = (void*)p; + blob_init(&out, 0, 0); + blob_init(&in, zInputText, -1); + markdown(&out, &in, &html_renderer); + blob_reset(&out); + blob_reset(&in); +} + +/* +** Transform mimetype string into an integer code. +** NOTE: In the sake of compatibility empty string is parsed as MT_UNKNOWN; +** it is yet unclear whether it can safely be changed to MT_NONE. +*/ +int parse_mimetype(const char* zMimetype){ + if( zMimetype==0 ) return MT_NONE; + if( strstr(zMimetype,"wiki")!=0 ) return MT_WIKI; + if( strstr(zMimetype,"markdown")!=0 ) return MT_MARKDOWN; + return MT_UNKNOWN; +} +/* +** Parse text looking for hyperlinks. Insert references into the +** BACKLINK table. +*/ +void backlink_extract( + char *zSrc, /* Input text from which links are extracted */ + int mimetype, /* Mimetype of input. MT_NONE works as MT_WIKI */ + int srcid, /* srcid for the source document */ + int srctype, /* One of BKLNK_*. 0=comment 1=ticket 2=wiki */ + double mtime, /* mtime field for new BACKLINK table entries */ + int replaceFlag /* True to overwrite prior BACKLINK entries */ +){ + Backlink bklnk; + if( replaceFlag ){ + db_multi_exec("DELETE FROM backlink WHERE srctype=%d AND srcid=%d", + srctype, srcid); + } + bklnk.srcid = srcid; + assert( ValidBklnk(srctype) ); + assert( ValidMTC(mimetype) ); + bklnk.srctype = srctype; + bklnk.mtime = mtime; + if( mimetype==MT_NONE || mimetype==MT_WIKI ){ + wiki_extract_links(zSrc, &bklnk, srctype==BKLNK_COMMENT ? WIKI_INLINE : 0); + }else if( mimetype==MT_MARKDOWN ){ + markdown_extract_links(zSrc, &bklnk); + } +} + +/* +** COMMAND: test-backlinks +** +** Usage: %fossil test-backlinks SRCTYPE SRCID ?OPTIONS? INPUT-FILE +** +** Read the content of INPUT-FILE and pass it into the backlink_extract() +** routine. But instead of adding backlinks to the backlink table, +** just print them on stdout. SRCID and SRCTYPE are integers. +** +** Options: +** --mtime DATETIME Use an alternative date/time. Defaults to the +** current date/time. +** --mimetype TYPE Use an alternative mimetype +*/ +void test_backlinks_cmd(void){ + const char *zMTime = find_option("mtime",0,1); + const char *zMimetype = find_option("mimetype",0,1); + const int mimetype = parse_mimetype(zMimetype); + Blob in; + int srcid; + int srctype; + double mtime; + + verify_all_options(); + if( g.argc!=5 ){ + usage("SRCTYPE SRCID INPUTFILE"); + } + srctype = atoi(g.argv[2]); + if( srctype<0 || srctype>2 ){ + fossil_fatal("SRCTYPE should be an integer 0, 1, or 2"); + } + srcid = atoi(g.argv[3]); + blob_read_from_file(&in, g.argv[4], ExtFILE); + sqlite3_open(":memory:",&g.db); + if( zMTime==0 ) zMTime = "now"; + mtime = db_double(1721059.5,"SELECT julianday(%Q)",zMTime); + g.fSqlPrint = 1; + sqlite3_create_function(g.db, "print", -1, SQLITE_UTF8, 0,db_sql_print,0,0); + db_multi_exec( + "CREATE TEMP TABLE backlink(target,srctype,srcid,mtime);\n" + "CREATE TRIGGER backlink_insert BEFORE INSERT ON backlink BEGIN\n" + " SELECT print(" + " 'target='||quote(new.target)||" + " ' srctype='||quote(new.srctype)||" + " ' srcid='||quote(new.srcid)||" + " ' mtime='||datetime(new.mtime));\n" + " SELECT raise(ignore);\n" + "END;" + ); + backlink_extract(blob_str(&in),mimetype,srcid,srctype,mtime,0); + blob_reset(&in); +} + + +/* +** COMMAND: test-relink-wiki +** +** Usage: %fossil test-relink-wiki WIKI-PAGE-NAME +** +** Run the backlink_wiki_refresh() procedure on the wiki page +** named. WIKI-PAGE-NAME can be a glob pattern or a prefix +** of the wiki page. +*/ +void test_wiki_relink_cmd(void){ + Stmt q; + db_find_and_open_repository(0, 0); + if( g.argc!=3 ) usage("WIKI-PAGE-NAME"); + db_prepare(&q, + "SELECT substr(tagname,6) FROM tag WHERE tagname GLOB 'wiki-%q*'", + g.argv[2] + ); + while( db_step(&q)==SQLITE_ROW ){ + const char *zPage = db_column_text(&q,0); + fossil_print("Relinking page: %s\n", zPage); + backlink_wiki_refresh(zPage); + } + db_finalize(&q); +} diff --git a/src/backoffice.c b/src/backoffice.c new file mode 100644 index 0000000..696a2ff --- /dev/null +++ b/src/backoffice.c @@ -0,0 +1,881 @@ +/* +** Copyright (c) 2018 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 used to manage a background processes that +** occur after user interaction with the repository. Examples of +** backoffice processing includes: +** +** * Sending alerts and notifications +** * Processing the email queue +** * Handling post-receive hooks +** * Automatically syncing to peer repositories +** +** Backoffice processing is automatically started whenever there are +** changes to the repository. The backoffice process dies off after +** a period of inactivity. +** +** Steps are taken to ensure that only a single backoffice process is +** running at a time. Otherwise, there could be race conditions that +** cause adverse effects such as multiple alerts for the same changes. +** +** At the same time, we do not want a backoffice process to run forever. +** Backoffice processes should die off after doing whatever work they need +** to do. In this way, we avoid having lots of idle processes in the +** process table, doing nothing on rarely accessed repositories, and +** if the Fossil binary is updated on a system, the backoffice processes +** will restart using the new binary automatically. +** +** At any point in time there should be at most two backoffice processes. +** There is a main process that is doing the actual work, and there is +** a second stand-by process that is waiting for the main process to finish +** and that will become the main process after a delay. +** +** After any successful web page reply, the backoffice_check_if_needed() +** routine is called. That routine checks to see if both one or both of +** the backoffice processes are already running. That routine remembers the +** status in a global variable. +** +** Later, after the repository database is closed, the +** backoffice_run_if_needed() routine is called. If the prior call +** to backoffice_check_if_needed() indicated that backoffice processing +** might be required, the run_if_needed() attempts to kick off a backoffice +** process. +** +** All work performed by the backoffice is in the backoffice_work() +** routine. +*/ +#if defined(_WIN32) +# if defined(_WIN32_WINNT) +# undef _WIN32_WINNT +# endif +# define _WIN32_WINNT 0x501 +#endif +#include "config.h" +#include "backoffice.h" +#include +#if defined(_WIN32) +# include +# include +# include +# if defined(__MINGW32__) +# include +# endif +# define GETPID (int)GetCurrentProcessId +#else +# include +# include +# include +# include +# include +# include +# include +# define GETPID getpid +#endif +#include + +/* +** The BKOFCE_LEASE_TIME is the amount of time for which a single backoffice +** processing run is valid. Each backoffice run monopolizes the lease for +** at least this amount of time. Hopefully all backoffice processing is +** finished much faster than this - usually in less than a second. But +** regardless of how long each invocation lasts, successive backoffice runs +** must be spaced out by at least this much time. +*/ +#define BKOFCE_LEASE_TIME 60 /* Length of lease validity in seconds */ + +#if LOCAL_INTERFACE +/* +** An instance of the following object describes a lease on the backoffice +** processing timeslot. This lease is used to help ensure that no more than +** one process is running backoffice at a time. +*/ +struct Lease { + sqlite3_uint64 idCurrent; /* process ID for the current lease holder */ + sqlite3_uint64 tmCurrent; /* Expiration of the current lease */ + sqlite3_uint64 idNext; /* process ID for the next lease holder on queue */ + sqlite3_uint64 tmNext; /* Expiration of the next lease */ +}; +#endif + +/*************************************************************************** +** Local state variables +** +** Set to prevent backoffice processing from ever entering sleep or +** otherwise taking a long time to complete. Set this when a user-visible +** process might need to wait for backoffice to complete. +*/ +static int backofficeNoDelay = 0; + +/* This variable is set to the name of a database on which backoffice +** should run if backoffice process is needed. It is set by the +** backoffice_check_if_needed() routine which must be run while the database +** file is open. Later, after the database is closed, the +** backoffice_run_if_needed() will consult this variable to see if it +** should be a no-op. +** +** The magic string "x" in this variable means "do not run the backoffice". +*/ +static char *backofficeDb = 0; + +/* +** Log backoffice activity to a file named here. If not NULL, this +** overrides the "backoffice-logfile" setting of the database. If NULL, +** the "backoffice-logfile" setting is used instead. +*/ +static const char *backofficeLogfile = 0; + +/* +** Write the log message into this open file. +*/ +static FILE *backofficeFILE = 0; + +/* +** Write backoffice log messages on this BLOB. to this connection: +*/ +static Blob *backofficeBlob = 0; + +/* +** Non-zero for extra logging detail. +*/ +static int backofficeLogDetail = 0; + +/* End of state variables +****************************************************************************/ + +/* +** This function emits a diagnostic message related to the processing in +** this module. +*/ +static void backofficeTrace(const char *zFormat, ...){ + char *zMsg = 0; + if( g.fAnyTrace ){ + va_list ap; + va_start(ap, zFormat); + zMsg = sqlite3_vmprintf(zFormat, ap); + va_end(ap); + } + if( g.fAnyTrace ) fprintf(stderr, "%s", zMsg); + if( zMsg ) sqlite3_free(zMsg); +} + +/* +** Do not allow backoffice processes to sleep waiting on a timeslot. +** They must either do their work immediately or exit. +** +** In a perfect world, this interface would not exist, as there would +** never be a problem with waiting backoffice threads. But in some cases +** a backoffice will delay a UI thread, so we don't want them to run for +** longer than needed. +*/ +void backoffice_no_delay(void){ + backofficeNoDelay = 1; +} + +/* +** Sleeps for the specified number of milliseconds -OR- until interrupted +** by another thread (if supported by the underlying platform). Non-zero +** will be returned if the sleep was interrupted. +*/ +static int backofficeSleep(int milliseconds){ +#if defined(_WIN32) + assert( milliseconds>=0 ); + if( SleepEx((DWORD)milliseconds, TRUE)==WAIT_IO_COMPLETION ){ + return 1; + } +#else + sqlite3_sleep(milliseconds); +#endif + return 0; +} + +/* +** Parse a unsigned 64-bit integer from a string. Return a pointer +** to the character of z[] that occurs after the integer. +*/ +static const char *backofficeParseInt(const char *z, sqlite3_uint64 *pVal){ + *pVal = 0; + if( z==0 ) return 0; + while( fossil_isspace(z[0]) ){ z++; } + while( fossil_isdigit(z[0]) ){ + *pVal = (*pVal)*10 + z[0] - '0'; + z++; + } + return z; +} + +/* +** Read the "backoffice" property and parse it into a Lease object. +** +** The backoffice property should consist of four integers: +** +** (1) Process ID for the active backoffice process. +** (2) Time (seconds since 1970) for when the active backoffice +** lease expires. +** (3) Process ID for the on-deck backoffice process. +** (4) Time when the on-deck process should expire. +** +** No other process should start active backoffice processing until +** process (1) no longer exists and the current time exceeds (2). +*/ +static void backofficeReadLease(Lease *pLease){ + Stmt q; + memset(pLease, 0, sizeof(*pLease)); + db_unprotect(PROTECT_CONFIG); + db_prepare(&q, "SELECT value FROM repository.config" + " WHERE name='backoffice'"); + if( db_step(&q)==SQLITE_ROW ){ + const char *z = db_column_text(&q,0); + z = backofficeParseInt(z, &pLease->idCurrent); + z = backofficeParseInt(z, &pLease->tmCurrent); + z = backofficeParseInt(z, &pLease->idNext); + backofficeParseInt(z, &pLease->tmNext); + } + db_finalize(&q); + db_protect_pop(); +} + +/* +** Return a string that describes how long it has been since the +** last backoffice run. The string is obtained from fossil_malloc(). +*/ +char *backoffice_last_run(void){ + Lease x; + sqlite3_uint64 tmNow; + double rAge; + backofficeReadLease(&x); + tmNow = time(0); + if( x.tmCurrent==0 ){ + return fossil_strdup("never"); + } + if( tmNow<=(x.tmCurrent-BKOFCE_LEASE_TIME) ){ + return fossil_strdup("moments ago"); + } + rAge = (tmNow - (x.tmCurrent-BKOFCE_LEASE_TIME))/86400.0; + return mprintf("%z ago", human_readable_age(rAge)); +} + +/* +** Write a lease to the backoffice property +*/ +static void backofficeWriteLease(Lease *pLease){ + db_unprotect(PROTECT_CONFIG); + db_multi_exec( + "REPLACE INTO repository.config(name,value,mtime)" + " VALUES('backoffice','%lld %lld %lld %lld',now())", + pLease->idCurrent, pLease->tmCurrent, + pLease->idNext, pLease->tmNext); + db_protect_pop(); +} + +/* +** Check to see if the specified Win32 process is still alive. It +** should be noted that even if this function returns non-zero, the +** process may die before another operation on it can be completed. +*/ +#if defined(_WIN32) +#ifndef PROCESS_QUERY_LIMITED_INFORMATION +# define PROCESS_QUERY_LIMITED_INFORMATION (0x1000) +#endif +static int backofficeWin32ProcessExists(DWORD dwProcessId){ + HANDLE hProcess; + hProcess = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION,FALSE,dwProcessId); + if( hProcess==NULL ) return 0; + CloseHandle(hProcess); + return 1; +} +#endif + +/* +** Check to see if the process identified by pid is alive. If +** we cannot prove that the process is dead, return true. +*/ +static int backofficeProcessExists(sqlite3_uint64 pid){ +#if defined(_WIN32) + return pid>0 && backofficeWin32ProcessExists((DWORD)pid)!=0; +#else + return pid>0 && kill((pid_t)pid, 0)==0; +#endif +} + +/* +** Check to see if the process identified by pid has finished. If +** we cannot prove that the process is still running, return true. +*/ +static int backofficeProcessDone(sqlite3_uint64 pid){ +#if defined(_WIN32) + return pid<=0 || backofficeWin32ProcessExists((DWORD)pid)==0; +#else + return pid<=0 || kill((pid_t)pid, 0)!=0; +#endif +} + +/* +** Return a process id number for the current process +*/ +static sqlite3_uint64 backofficeProcessId(void){ + return (sqlite3_uint64)GETPID(); +} + + +/* +** COMMAND: test-process-id +** +** Usage: %fossil [--sleep N] PROCESS-ID ... +** +** Show the current process id, and also tell whether or not all other +** processes IDs on the command line are running or not. If the --sleep N +** option is provide, then sleep for N seconds before exiting. +*/ +void test_process_id_command(void){ + const char *zSleep = find_option("sleep",0,1); + int i; + verify_all_options(); + fossil_print("ProcessID for this process: %lld\n", backofficeProcessId()); + if( zSleep ) sqlite3_sleep(1000*atoi(zSleep)); + for(i=2; i0 ){ + fossil_print(" (now%+d)\n",x.tmCurrent-tmNow); + }else{ + fossil_print("\n"); + } + fossil_print("idNext: %-20lld", x.idNext); + if( backofficeProcessExists(x.idNext) ) fossil_print(" (exists)"); + if( backofficeProcessDone(x.idNext) ) fossil_print(" (done)"); + fossil_print("\n"); + fossil_print("tmNext: %-20lld", x.tmNext); + if( x.tmNext>0 ){ + fossil_print(" (now%+d)\n",x.tmNext-tmNow); + }else{ + fossil_print("\n"); + } +} + +/* +** If backoffice processing is needed set the backofficeDb variable to the +** name of the database file. If no backoffice processing is needed, +** this routine makes no changes to state. +*/ +void backoffice_check_if_needed(void){ + Lease x; + sqlite3_uint64 tmNow; + + if( backofficeDb ) return; + if( g.zRepositoryName==0 ) return; + if( g.db==0 ) return; + if( !db_table_exists("repository","config") ) return; + if( db_get_boolean("backoffice-disable",0) ) return; + tmNow = time(0); + backofficeReadLease(&x); + if( x.tmNext>=tmNow && backofficeProcessExists(x.idNext) ){ + /* Another backoffice process is already queued up to run. This + ** process does not need to do any backoffice work. */ + return; + }else{ + /* We need to run backup to be (at a minimum) on-deck */ + backofficeDb = fossil_strdup(g.zRepositoryName); + } +} + +/* +** Call this routine to disable backoffice +*/ +void backoffice_disable(void){ + backofficeDb = "x"; +} + +/* +** Check for errors prior to running backoffice_thread() or backoffice_run(). +*/ +static void backoffice_error_check_one(int *pOnce){ + if( *pOnce ){ + fossil_panic("multiple calls to backoffice()"); + } + *pOnce = 1; + if( g.db==0 ){ + fossil_panic("database not open for backoffice processing"); + } + if( db_transaction_nesting_depth()!=0 ){ + fossil_panic("transaction %s not closed prior to backoffice processing", + db_transaction_start_point()); + } +} + +/* This is the main loop for backoffice processing. +** +** If another process is already working as the current backoffice and +** the on-deck backoffice, then this routine returns very quickly +** without doing any work. +** +** If no backoffice processes are running at all, this routine becomes +** the main backoffice. +** +** If a primary backoffice is running, but an on-deck backoffice is +** needed, this routine becomes that on-deck backoffice. +*/ +static void backoffice_thread(void){ + Lease x; + sqlite3_uint64 tmNow; + sqlite3_uint64 idSelf; + int lastWarning = 0; + int warningDelay = 30; + static int once = 0; + + if( sqlite3_db_readonly(g.db, 0) ) return; + if( db_is_protected(PROTECT_READONLY) ) return; + g.zPhase = "backoffice-pending"; + backoffice_error_check_one(&once); + idSelf = backofficeProcessId(); + while(1){ + tmNow = time(0); + db_begin_write(); + backofficeReadLease(&x); + if( x.tmNext>=tmNow + && x.idNext!=idSelf + && backofficeProcessExists(x.idNext) + ){ + /* Another backoffice process is already queued up to run. This + ** process does not need to do any backoffice work and can stop + ** immediately. */ + db_end_transaction(0); + backofficeTrace("/***** Backoffice Processing Not Needed In %d *****/\n", + GETPID()); + break; + } + if( x.tmCurrentx.tmCurrent ? tmNow : x.tmCurrent) + BKOFCE_LEASE_TIME; + backofficeWriteLease(&x); + db_end_transaction(0); + backofficeTrace("/***** Backoffice On-deck %d *****/\n", GETPID()); + if( x.tmCurrent >= tmNow ){ + if( backofficeSleep(1000*(x.tmCurrent - tmNow + 1)) ){ + /* The sleep was interrupted by a signal from another thread. */ + backofficeTrace("/***** Backoffice Interrupt %d *****/\n", GETPID()); + db_end_transaction(0); + break; + } + }else{ + if( (sqlite3_uint64)(lastWarning+warningDelay) < tmNow ){ + sqlite3_int64 runningFor = BKOFCE_LEASE_TIME + tmNow - x.tmCurrent; + if( warningDelay>=240 && runningFor<1800 ){ + fossil_warning( + "backoffice process %lld still running after %d seconds", + x.idCurrent, runningFor); + } + lastWarning = tmNow; + warningDelay *= 2; + } + if( backofficeSleep(1000) ){ + /* The sleep was interrupted by a signal from another thread. */ + backofficeTrace("/***** Backoffice Interrupt %d *****/\n", GETPID()); + db_end_transaction(0); + break; + } + } + } + return; +} + +/* +** Append to a message to the backoffice log, if the log is open. +*/ +void backoffice_log(const char *zFormat, ...){ + va_list ap; + if( backofficeBlob==0 ) return; + blob_append_char(backofficeBlob, ' '); + va_start(ap, zFormat); + blob_vappendf(backofficeBlob, zFormat, ap); + va_end(ap); +} + +#if !defined(_WIN32) +/* +** Capture routine for signals while running backoffice. +*/ +static void backoffice_signal_handler(int sig){ + const char *zSig = 0; + if( sig==SIGSEGV ) zSig = "SIGSEGV"; + if( sig==SIGFPE ) zSig = "SIGFPE"; + if( sig==SIGABRT ) zSig = "SIGABRT"; + if( sig==SIGILL ) zSig = "SIGILL"; + if( zSig==0 ){ + backoffice_log("signal-%d", sig); + }else{ + backoffice_log("%s", zSig); + } + fprintf(backofficeFILE, "%s\n", blob_str(backofficeBlob)); + fflush(backofficeFILE); + exit(1); +} +#endif + +#if !defined(_WIN32) +/* +** Convert a struct timeval into an integer number of microseconds +*/ +static long long int tvms(struct timeval *p){ + return ((long long int)p->tv_sec)*1000000 + (long long int)p->tv_usec; +} +#endif + + +/* +** This routine runs to do the backoffice processing. When adding new +** backoffice processing tasks, add them here. +*/ +void backoffice_work(void){ + /* Log the backoffice run for testing purposes. For production deployments + ** the "backoffice-logfile" property should be unset and the following code + ** should be a no-op. */ + const char *zLog = backofficeLogfile; + Blob log; + int nThis; + int nTotal = 0; +#if !defined(_WIN32) + struct timeval sStart, sEnd; +#endif + if( zLog==0 ) zLog = db_get("backoffice-logfile",0); + if( zLog && zLog[0] && (backofficeFILE = fossil_fopen(zLog,"a"))!=0 ){ + int i; + char *zName = db_get("project-name",0); +#if !defined(_WIN32) + gettimeofday(&sStart, 0); + signal(SIGSEGV, backoffice_signal_handler); + signal(SIGABRT, backoffice_signal_handler); + signal(SIGFPE, backoffice_signal_handler); + signal(SIGILL, backoffice_signal_handler); +#endif + if( zName==0 ){ + zName = (char*)file_tail(g.zRepositoryName); + if( zName==0 ) zName = "(unnamed)"; + }else{ + /* Convert all spaces in the "project-name" into dashes */ + for(i=0; zName[i]; i++){ if( zName[i]==' ' ) zName[i] = '-'; } + } + blob_init(&log, 0, 0); + backofficeBlob = &log; + blob_appendf(&log, "%s %s", db_text(0, "SELECT datetime('now')"), zName); + } + + /* Here is where the actual work of the backoffice happens */ + g.zPhase = "backoffice-alerts"; + nThis = alert_backoffice(0); + if( nThis ){ backoffice_log("%d alerts", nThis); nTotal += nThis; } + g.zPhase = "backoffice-hooks"; + nThis = hook_backoffice(); + if( nThis ){ backoffice_log("%d hooks", nThis); nTotal += nThis; } + g.zPhase = "backoffice-close"; + + /* Close the log */ + if( backofficeFILE ){ + if( nTotal || backofficeLogDetail ){ + if( nTotal==0 ) backoffice_log("no-op"); +#if !defined(_WIN32) + gettimeofday(&sEnd,0); + backoffice_log("elapse-time %d us", tvms(&sEnd) - tvms(&sStart)); +#endif + fprintf(backofficeFILE, "%s\n", blob_str(backofficeBlob)); + } + fclose(backofficeFILE); + } +} + +/* +** COMMAND: backoffice* +** +** Usage: %fossil backoffice [OPTIONS...] [REPOSITORIES...] +** +** Run backoffice processing on the repositories listed. If no +** repository is specified, run it on the repository of the local check-out. +** +** This might be done by a cron job or similar to make sure backoffice +** processing happens periodically. Or, the --poll option can be used +** to run this command as a daemon that will periodically invoke backoffice +** on a collection of repositories. +** +** If only a single repository is named and --poll is omitted, then the +** backoffice work is done in-process. But if there are multiple repositories +** or if --poll is used, a separate sub-process is started for each poll of +** each repository. +** +** Standard options: +** +** --debug Show what this command is doing +** +** --logfile FILE Append a log of backoffice actions onto FILE +** +** --min N When polling, invoke backoffice at least +** once every N seconds even if the repository +** never changes. 0 or negative means disable +** this feature. Default: 3600 (once per hour). +** +** --poll N Repeat backoffice calls for repositories that +** change in approximately N-second intervals. +** N less than 1 turns polling off (the default). +** Recommended polling interval: 60 seconds. +** +** --trace Enable debugging output on stderr +** +** Options intended for internal use only which may change or be +** discontinued in future releases: +** +** --nodelay Do not queue up or wait for a backoffice job +** to complete. If no work is available or if +** backoffice has run recently, return immediately. +** +** --nolease Always run backoffice, even if there is a lease +** conflict. This option implies --nodelay. This +** option is added to secondary backoffice commands +** that are invoked by the --poll option. +*/ +void backoffice_command(void){ + int nPoll; + int nMin; + const char *zPoll; + int bDebug = 0; + int bNoLease = 0; + unsigned int nCmd = 0; + if( find_option("trace",0,0)!=0 ) g.fAnyTrace = 1; + if( find_option("nodelay",0,0)!=0 ) backofficeNoDelay = 1; + backofficeLogfile = find_option("logfile",0,1); + zPoll = find_option("poll",0,1); + nPoll = zPoll ? atoi(zPoll) : 0; + zPoll = find_option("min",0,1); + nMin = zPoll ? atoi(zPoll) : 3600; + bDebug = find_option("debug",0,0)!=0; + bNoLease = find_option("nolease",0,0)!=0; + + /* Silently consume the -R or --repository flag, leaving behind its + ** argument. This is for legacy compatibility. Older versions of the + ** backoffice command only ran on a single repository that was specified + ** using the -R option. */ + (void)find_option("repository","R",0); + + verify_all_options(); + if( g.argc>3 || nPoll>0 ){ + /* Either there are multiple repositories named on the command-line + ** or we are polling. In either case, each backoffice should be run + ** using a separate sub-process */ + int i; + time_t iNow = 0; + time_t ix; + i64 *aLastRun = fossil_malloc( sizeof(i64)*g.argc ); + memset(aLastRun, 0, sizeof(i64)*g.argc ); + while( 1 /* exit via "break;" */){ + time_t iNext = time(0); + for(i=2; ifile_mtime(g.argv[i], ExtFILE) + && (nMin<=0 || aLastRun[i]+nMin>iNow) + ){ + continue; /* Not yet time to run this one */ + } + blob_init(&cmd, 0, 0); + blob_append_escaped_arg(&cmd, g.nameOfExe, 1); + blob_append(&cmd, " backoffice --nodelay", -1); + if( g.fAnyTrace ){ + blob_append(&cmd, " --trace", -1); + } + if( bDebug ){ + blob_append(&cmd, " --debug", -1); + } + if( nPoll>0 ){ + blob_append(&cmd, " --nolease", -1); + } + if( backofficeLogfile ){ + blob_append(&cmd, " --logfile", -1); + blob_append_escaped_arg(&cmd, backofficeLogfile, 1); + } + blob_append_escaped_arg(&cmd, g.argv[i], 1); + nCmd++; + if( bDebug ){ + fossil_print("COMMAND[%u]: %s\n", nCmd, blob_str(&cmd)); + } + fossil_system(blob_str(&cmd)); + aLastRun[i] = iNext; + blob_reset(&cmd); + } + if( nPoll<1 ) break; + iNow = iNext; + ix = time(0); + if( ix < iNow+nPoll ){ + sqlite3_int64 nMS = (iNow + nPoll - ix)*1000; + if( bDebug )fossil_print("SLEEP: %lld\n", nMS); + sqlite3_sleep((int)nMS); + } + } + }else{ + /* Not polling and only one repository named. Backoffice is run + ** once by this process, which then exits */ + if( g.argc==3 ){ + g.zRepositoryOption = g.argv[2]; + g.argc--; + } + db_find_and_open_repository(0,0); + if( bDebug ){ + backofficeLogDetail = 1; + } + if( bNoLease ){ + backoffice_work(); + }else{ + backoffice_thread(); + } + } +} + +/* +** This is the main interface to backoffice from the rest of the system. +** This routine launches either backoffice_thread() directly or as a +** subprocess. +*/ +void backoffice_run_if_needed(void){ + if( backofficeDb==0 ) return; + if( strcmp(backofficeDb,"x")==0 ) return; + if( g.db ) return; + if( g.repositoryOpen ) return; +#if defined(_WIN32) + { + int i; + intptr_t x; + char *argv[4]; + wchar_t *ax[5]; + argv[0] = g.nameOfExe; + argv[1] = "backoffice"; + argv[2] = "-R"; + argv[3] = backofficeDb; + ax[4] = 0; + for(i=0; i<=3; i++) ax[i] = fossil_utf8_to_unicode(argv[i]); + x = _wspawnv(_P_NOWAIT, ax[0], (const wchar_t * const *)ax); + for(i=0; i<=3; i++) fossil_unicode_free(ax[i]); + backofficeTrace( + "/***** Subprocess %d creates backoffice child %lu *****/\n", + GETPID(), GetProcessId((HANDLE)x)); + if( x>=0 ) return; + } +#else /* unix */ + { + pid_t pid = fork(); + if( pid>0 ){ + /* This is the parent in a successful fork(). Return immediately. */ + backofficeTrace( + "/***** Subprocess %d creates backoffice child %d *****/\n", + GETPID(), (int)pid); + return; + } + if( pid==0 ){ + /* This is the child of a successful fork(). Run backoffice. */ + int i; + setsid(); + for(i=0; i<=2; i++){ + close(i); + open("/dev/null", O_RDWR); + } + for(i=3; i<100; i++){ close(i); } + g.fDebug = 0; + g.httpIn = 0; + g.httpOut = 0; + db_open_repository(backofficeDb); + backofficeDb = "x"; + backoffice_thread(); + db_close(1); + backofficeTrace("/***** Backoffice Child %d exits *****/\n", GETPID()); + exit(0); + } + fossil_warning("backoffice process %d fork failed, errno %d", GETPID(), + errno); + } +#endif + /* Fork() failed or is unavailable. Run backoffice in this process, but + ** do so with the no-delay setting. + */ + backofficeNoDelay = 1; + db_open_repository(backofficeDb); + backofficeDb = "x"; + backoffice_thread(); + db_close(1); +} diff --git a/src/bag.c b/src/bag.c new file mode 100644 index 0000000..ca016c5 --- /dev/null +++ b/src/bag.c @@ -0,0 +1,237 @@ +/* +** Copyright (c) 2007 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 used to implement a "bag" of integers. +** A bag is an unordered collection without duplicates. In this +** implementation, all elements must be positive integers. +*/ +#include "config.h" +#include "bag.h" +#include + + +#if INTERFACE +/* +** An integer can appear in the bag at most once. +** Integers must be positive. +** +** On a hash collision, search continues to the next slot in the array, +** looping back to the beginning of the array when we reach the end. +** The search stops when a match is found or upon encountering a 0 entry. +** +** When an entry is deleted, its value is changed to -1. +** +** Bag.cnt is the number of live entries in the table. Bag.used is +** the number of live entries plus the number of deleted entries. So +** Bag.used>=Bag.cnt. We want to keep Bag.used-Bag.cnt as small as +** possible. +** +** The length of a search increases as the hash table fills up. So +** the table is enlarged whenever Bag.used reaches half of Bag.sz. That +** way, the expected collision length never exceeds 2. +*/ +struct Bag { + int cnt; /* Number of integers in the bag */ + int sz; /* Number of slots in a[] */ + int used; /* Number of used slots in a[] */ + int *a; /* Hash table of integers that are in the bag */ +}; +/* +** An expression for statically initializing a Bag instance, to be +** assigned to Bag instances at their declaration point. It has +** the same effect as passing the Bag to bag_init(). +*/ +#define Bag_INIT {0,0,0,0} +#endif + +/* +** Initialize a Bag structure +*/ +void bag_init(Bag *p){ + memset(p, 0, sizeof(*p)); +} + +/* +** Destroy a Bag. Delete all of its content. +*/ +void bag_clear(Bag *p){ + free(p->a); + bag_init(p); +} + +/* +** The hash function +*/ +#define bag_hash(i) (((u64)(i))*101) + +/* +** Change the size of the hash table on a bag so that +** it contains N slots +** +** Completely reconstruct the hash table from scratch. Deleted +** entries (indicated by a -1) are removed. When finished, it +** should be the case that p->cnt==p->used. +*/ +static void bag_resize(Bag *p, int newSize){ + int i; + Bag old; + int nDel = 0; /* Number of deleted entries */ + int nLive = 0; /* Number of live entries */ + + old = *p; + assert( newSize>old.cnt ); + p->a = fossil_malloc( sizeof(p->a[0])*newSize ); + p->sz = newSize; + memset(p->a, 0, sizeof(p->a[0])*newSize ); + for(i=0; i0 ){ + unsigned h = bag_hash(e)%newSize; + while( p->a[h] ){ + h++; + if( (int)h==newSize ) h = 0; + } + p->a[h] = e; + nLive++; + }else if( e<0 ){ + nDel++; + } + } + assert( p->cnt == nLive ); + assert( p->used == nLive+nDel ); + p->used = p->cnt; + bag_clear(&old); +} + +/* +** Insert element e into the bag if it is not there already. +** Return TRUE if the insert actually occurred. Return FALSE +** if the element was already in the bag. +*/ +int bag_insert(Bag *p, int e){ + unsigned h; + int rc = 0; + assert( e>0 ); + if( p->used+1 >= p->sz/2 ){ + int n = p->sz*2; + bag_resize(p, n + 20 ); + } + h = bag_hash(e)%p->sz; + while( p->a[h]>0 && p->a[h]!=e ){ + h++; + if( (int)h>=p->sz ) h = 0; + } + if( p->a[h]<=0 ){ + if( p->a[h]==0 ) p->used++; + p->a[h] = e; + p->cnt++; + rc = 1; + } + return rc; +} + +/* +** Return true if e in the bag. Return false if it is no. +*/ +int bag_find(Bag *p, int e){ + unsigned h; + assert( e>0 ); + if( p->sz==0 ){ + return 0; + } + h = bag_hash(e)%p->sz; + while( p->a[h] && p->a[h]!=e ){ + h++; + if( (int)h>=p->sz ) h = 0; + } + return p->a[h]==e; +} + +/* +** Remove element e from the bag if it exists in the bag. +** If e is not in the bag, this is a no-op. +*/ +void bag_remove(Bag *p, int e){ + unsigned h; + assert( e>0 ); + if( p->sz==0 ) return; + h = bag_hash(e)%p->sz; + while( p->a[h] && p->a[h]!=e ){ + h++; + if( (int)h>=p->sz ) h = 0; + } + if( p->a[h] ){ + int nx = h+1; + if( nx>=p->sz ) nx = 0; + if( p->a[nx]==0 ){ + p->a[h] = 0; + p->used--; + }else{ + p->a[h] = -1; + } + p->cnt--; + if( p->cnt==0 ){ + memset(p->a, 0, p->sz*sizeof(p->a[0])); + p->used = 0; + }else if( p->sz>40 && p->cntsz/8 ){ + bag_resize(p, p->sz/2); + } + } +} + +/* +** Return the first element in the bag. Return 0 if the bag +** is empty. +*/ +int bag_first(Bag *p){ + int i; + for(i=0; isz && p->a[i]<=0; i++){} + if( isz ){ + return p->a[i]; + }else{ + return 0; + } +} + +/* +** Return the next element in the bag after e. Return 0 if +** is the last element in the bag. Any insert or removal from +** the bag might reorder the bag. +*/ +int bag_next(Bag *p, int e){ + unsigned h; + assert( p->sz>0 ); + assert( e>0 ); + h = bag_hash(e)%p->sz; + while( p->a[h] && p->a[h]!=e ){ + h++; + if( (int)h>=p->sz ) h = 0; + } + assert( p->a[h] ); + h++; + while( (int)hsz && p->a[h]<=0 ){ + h++; + } + return (int)hsz ? p->a[h] : 0; +} + +/* +** Return the number of elements in the bag. +*/ +int bag_count(Bag *p){ + return p->cnt; +} diff --git a/src/bisect.c b/src/bisect.c new file mode 100644 index 0000000..99a57bd --- /dev/null +++ b/src/bisect.c @@ -0,0 +1,753 @@ +/* +** 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@sqlite.org +** +******************************************************************************* +** +** This file contains code used to implement the "bisect" command. +** +** This file also contains logic used to compute the closure of filename +** changes that have occurred across multiple check-ins. +*/ +#include "config.h" +#include "bisect.h" +#include + +/* +** Local variables for this module +*/ +static struct { + int bad; /* The bad version */ + int good; /* The good version */ +} bisect; + +/* +** Find the shortest path between bad and good. +*/ +void bisect_path(void){ + PathNode *p; + bisect.bad = db_lget_int("bisect-bad", 0); + bisect.good = db_lget_int("bisect-good", 0); + if( bisect.good>0 && bisect.bad==0 ){ + path_shortest(bisect.good, bisect.good, 0, 0, 0, 0); + }else if( bisect.bad>0 && bisect.good==0 ){ + path_shortest(bisect.bad, bisect.bad, 0, 0, 0, 0); + }else if( bisect.bad==0 && bisect.good==0 ){ + fossil_fatal("neither \"good\" nor \"bad\" versions have been identified"); + }else{ + Bag skip; + int bDirect = bisect_option("direct-only"); + char *zLog = db_lget("bisect-log",""); + Blob log, id; + bag_init(&skip); + blob_init(&log, zLog, -1); + while( blob_token(&log, &id) ){ + if( blob_str(&id)[0]=='s' ){ + bag_insert(&skip, atoi(blob_str(&id)+1)); + } + } + blob_reset(&log); + p = path_shortest(bisect.good, bisect.bad, bDirect, 0, &skip, 0); + bag_clear(&skip); + if( p==0 ){ + char *zBad = db_text(0,"SELECT uuid FROM blob WHERE rid=%d",bisect.bad); + char *zGood = db_text(0,"SELECT uuid FROM blob WHERE rid=%d",bisect.good); + fossil_fatal("no path from good ([%S]) to bad ([%S]) or back", + zGood, zBad); + } + } +} + +/* +** The set of all bisect options. +*/ +static const struct { + const char *zName; + const char *zDefault; + const char *zDesc; +} aBisectOption[] = { + { "auto-next", "on", "Automatically run \"bisect next\" after each " + "\"bisect good\", \"bisect bad\", or \"bisect " + "skip\"" }, + { "direct-only", "on", "Follow only primary parent-child links, not " + "merges\n" }, + { "display", "chart", "Command to run after \"next\". \"chart\", " + "\"log\", \"status\", or \"none\"" }, + { "linear", "off", "Do a linear scan rather than a true bisect, " + "stopping at the first \"bad\" result"}, +}; + +/* +** Return the value of a boolean bisect option. +*/ +int bisect_option(const char *zName){ + unsigned int i; + int r = -1; + for(i=0; i=0 ); + return r; +} + +/* +** List a bisect path. +*/ +static void bisect_list(int abbreviated){ + PathNode *p; + int vid = db_lget_int("checkout", 0); + int n; + Stmt s; + int nStep; + int nHidden = 0; + bisect_path(); + db_prepare(&s, "SELECT blob.uuid, datetime(event.mtime) " + " FROM blob, event" + " WHERE blob.rid=:rid AND event.objid=:rid" + " AND event.type='ci'"); + nStep = path_length(); + if( abbreviated ){ + for(p=path_last(); p; p=p->pFrom) p->isHidden = 1; + for(p=path_last(), n=0; p; p=p->pFrom, n++){ + if( p->rid==bisect.good + || p->rid==bisect.bad + || p->rid==vid + || (nStep>1 && n==nStep/2) + ){ + p->isHidden = 0; + if( p->pFrom ) p->pFrom->isHidden = 0; + } + } + for(p=path_last(); p; p=p->pFrom){ + if( p->pFrom && p->pFrom->isHidden==0 ) p->isHidden = 0; + } + } + for(p=path_last(), n=0; p; p=p->pFrom, n++){ + if( p->isHidden && (nHidden || (p->pFrom && p->pFrom->isHidden)) ){ + nHidden++; + continue; + }else if( nHidden ){ + fossil_print(" ... %d other check-ins omitted\n", nHidden); + nHidden = 0; + } + db_bind_int(&s, ":rid", p->rid); + if( db_step(&s)==SQLITE_ROW ){ + const char *zUuid = db_column_text(&s, 0); + const char *zDate = db_column_text(&s, 1); + fossil_print("%s %S", zDate, zUuid); + if( p->rid==bisect.good ) fossil_print(" GOOD"); + if( p->rid==bisect.bad ) fossil_print(" BAD"); + if( p->rid==vid ) fossil_print(" CURRENT"); + if( nStep>1 && n==nStep/2 ) fossil_print(" NEXT"); + fossil_print("\n"); + } + db_reset(&s); + } + db_finalize(&s); +} + +/* +** Append a new entry to the bisect log. Update the bisect-good or +** bisect-bad values as appropriate. +** +** The bisect-log consists of a list of tokens. Each token is an +** integer RID of a check-in. The RID is negative for "bad" check-ins +** and positive for "good" check-ins. +*/ +static void bisect_append_log(int rid){ + if( rid<0 ){ + if( db_lget_int("bisect-bad",0)==(-rid) ) return; + db_lset_int("bisect-bad", -rid); + }else{ + if( db_lget_int("bisect-good",0)==rid ) return; + db_lset_int("bisect-good", rid); + } + db_multi_exec( + "REPLACE INTO vvar(name,value) VALUES('bisect-log'," + "COALESCE((SELECT value||' ' FROM vvar WHERE name='bisect-log'),'')" + " || '%d')", rid); +} + +/* +** Append a new skip entry to the bisect log. +*/ +static void bisect_append_skip(int rid){ + db_multi_exec( + "UPDATE vvar SET value=value||' s%d' WHERE name='bisect-log'", rid + ); +} + +/* +** Append a VALUES entry to the bilog table insert +*/ +static void bisect_log_append(Blob *pSql,int iSeq,const char *zStat,int iRid){ + if( (iSeq%6)==3 ){ + blob_append_sql(pSql, ",\n "); + }else if( iSeq>1 ){ + blob_append_sql(pSql, ","); + } + if( zStat ){ + blob_append_sql(pSql, "(%d,%Q,%d)", iSeq, zStat, iRid); + }else{ + blob_append_sql(pSql, "(NULL,NULL,%d)", iRid); + } +} + +/* +** Create a TEMP table named "bilog" that contains the complete history +** of the current bisect. +** +** If iCurrent>0 then it is the RID of the current check-out and is included +** in the history table. +** +** If zDesc is not NULL, then it is the bid= query parameter to /timeline +** that describes a bisect. Use the information in zDesc rather than in +** the bisect-log variable. +** +** If bDetail is true, then also include information about every node +** in between the inner-most GOOD and BAD nodes. +*/ +int bisect_create_bilog_table(int iCurrent, const char *zDesc, int bDetail){ + char *zLog; + Blob log, id; + int cnt = 0; + int lastGood = -1; + int lastBad = -1; + Blob ins = BLOB_INITIALIZER; + + if( zDesc!=0 ){ + blob_init(&log, 0, 0); + while( zDesc[0]=='y' || zDesc[0]=='n' || zDesc[0]=='s' ){ + int i; + char c; + int rid; + if( blob_size(&log) ) blob_append(&log, " ", 1); + if( zDesc[0]=='n' ) blob_append(&log, "-", 1); + if( zDesc[0]=='s' ) blob_append(&log, "s", 1); + for(i=1; ((c = zDesc[i])>='0' && c<='9') || (c>='a' && c<='f'); i++){} + if( i==1 ) break; + rid = db_int(0, + "SELECT rid FROM blob" + " WHERE uuid LIKE '%.*q%%'" + " AND EXISTS(SELECT 1 FROM plink WHERE cid=rid)", + i-1, zDesc+1 + ); + if( rid==0 ) break; + blob_appendf(&log, "%d", rid); + zDesc += i; + while( zDesc[0]=='-' ) zDesc++; + } + }else{ + zLog = db_lget("bisect-log",""); + blob_init(&log, zLog, -1); + } + db_multi_exec( + "CREATE TEMP TABLE bilog(" + " rid INTEGER PRIMARY KEY," /* Sequence of events */ + " stat TEXT," /* Type of occurrence */ + " seq INTEGER UNIQUE" /* Check-in number */ + ");" + ); + blob_append_sql(&ins, "INSERT OR IGNORE INTO bilog(seq,stat,rid) VALUES"); + while( blob_token(&log, &id) ){ + int rid; + cnt++; + if( blob_str(&id)[0]=='s' ){ + rid = atoi(blob_str(&id)+1); + bisect_log_append(&ins, cnt, "SKIP", rid); + }else{ + rid = atoi(blob_str(&id)); + if( rid>0 ){ + bisect_log_append(&ins, cnt, "GOOD", rid); + lastGood = rid; + }else{ + bisect_log_append(&ins, cnt, "BAD", -rid); + lastBad = -rid; + } + } + } + if( iCurrent>0 ){ + bisect_log_append(&ins, ++cnt, "CURRENT", iCurrent); + } + if( bDetail && lastGood>0 && lastBad>0 ){ + PathNode *p; + p = path_shortest(lastGood, lastBad, bisect_option("direct-only"),0, 0, 0); + while( p ){ + bisect_log_append(&ins, ++cnt, 0, p->rid); + p = p->u.pTo; + } + path_reset(); + } + db_exec_sql(blob_sql_text(&ins)); + blob_reset(&ins); + return 1; +} + +/* Return a permalink description of a bisect. Space is obtained from +** fossil_malloc() and should be freed by the caller. +** +** A bisect description consists of characters 'y' and 'n' and lowercase +** hex digits. Each term begins with 'y' or 'n' (success or failure) and +** is followed by a hash prefix in lowercase hex. +*/ +char *bisect_permalink(void){ + char *zLog = db_lget("bisect-log",""); + char *zResult; + Blob log; + Blob link = BLOB_INITIALIZER; + Blob id; + blob_init(&log, zLog, -1); + while( blob_token(&log, &id) ){ + const char *zUuid; + int rid; + char cPrefix = 'y'; + if( blob_str(&id)[0]=='s' ){ + rid = atoi(blob_str(&id)+1); + cPrefix = 's'; + }else{ + rid = atoi(blob_str(&id)); + if( rid<0 ){ + cPrefix = 'n'; + rid = -rid; + } + } + zUuid = db_text(0,"SELECT lower(uuid) FROM blob WHERE rid=%d", rid); + if( blob_size(&link)>0 ) blob_append(&link, "-", 1); + blob_appendf(&link, "%c%.10s", cPrefix, zUuid); + } + zResult = fossil_strdup(blob_str(&link)); + blob_reset(&link); + blob_reset(&log); + blob_reset(&id); + return zResult; +} + +/* +** Show a chart of bisect "good" and "bad" versions. The chart can be +** sorted either chronologically by bisect time, or by check-in time. +*/ +static void bisect_chart(int sortByCkinTime){ + Stmt q; + int iCurrent = db_lget_int("checkout",0); + bisect_create_bilog_table(iCurrent, 0, 0); + db_prepare(&q, + "SELECT bilog.seq, bilog.stat," + " substr(blob.uuid,1,16), datetime(event.mtime)," + " blob.rid==%d" + " FROM bilog, blob, event" + " WHERE blob.rid=bilog.rid AND event.objid=bilog.rid" + " AND event.type='ci'" + " ORDER BY %s bilog.rowid ASC", + iCurrent, (sortByCkinTime ? "event.mtime DESC, " : "") + ); + while( db_step(&q)==SQLITE_ROW ){ + const char *zGoodBad = db_column_text(&q, 1); + fossil_print("%3d %-7s %s %s%s\n", + db_column_int(&q, 0), + zGoodBad, + db_column_text(&q, 3), + db_column_text(&q, 2), + (db_column_int(&q, 4) && zGoodBad[0]!='C') ? " CURRENT" : ""); + } + db_finalize(&q); +} + + +/* +** Reset the bisect subsystem. +*/ +void bisect_reset(void){ + db_multi_exec( + "DELETE FROM vvar WHERE name IN " + " ('bisect-good', 'bisect-bad', 'bisect-log', 'bisect-complete'," + " 'bisect-linear')" + ); +} + +/* +** fossil bisect run [OPTIONS] COMMAND +** +** Invoke COMMAND (with arguments) repeatedly to perform the bisect. +** +** Options: +** -i|--interactive Prompt user for decisions rather than +** using the return code from COMMAND +** --ii Like -i but also pause after showing +** the status after each step. +*/ +static void bisect_run(void){ + const char *zCmd; + int isInteractive = 0; + int i; + if( g.argc<4 ){ + fossil_fatal("Usage: fossil bisect run [OPTIONS] COMMAND\n"); + } + for(i=3; i=2 && db_lget_int("bisect-complete", 0)==0 ){ + int n; + char *z; + Blob in; + int bContinue = 1; + prompt_user("Run testcase again? (Y)es or No: ", &in); + n = blob_size(&in); + z = blob_str(&in); + if( n>0 && sqlite3_strnicmp("no", z, n)==0 ) bContinue = 0; + blob_reset(&in); + if( !bContinue ) break; + } + } +} + +/* +** COMMAND: bisect +** +** Usage: %fossil bisect SUBCOMMAND ... +** +** Run various subcommands useful for searching back through the change +** history for a particular check-in that causes or fixes a problem. +** +** > fossil bisect bad ?VERSION? +** +** Identify version VERSION as non-working. If VERSION is omitted, +** the current check-out is marked as non-working. +** +** > fossil bisect good ?VERSION? +** +** Identify version VERSION as working. If VERSION is omitted, +** the current check-out is marked as working. +** +** > fossil bisect log +** > fossil bisect chart +** +** Show a log of "good", "bad", and "skip" versions. "bisect log" +** shows the events in the order that they were tested. +** "bisect chart" shows them in order of check-in. +** +** > fossil bisect next +** +** Update to the next version that is halfway between the working and +** non-working versions. +** +** > fossil bisect options ?NAME? ?VALUE? +** +** List all bisect options, or the value of a single option, or set the +** value of a bisect option. +** +** > fossil bisect reset +** +** Reinitialize a bisect session. This cancels prior bisect history +** and allows a bisect session to start over from the beginning. +** +** > fossil bisect run [OPTIONS] COMMAND +** +** Invoke COMMAND repeatedly to run the bisect. The exit code for +** COMMAND should be 0 for "good", 125 for "skip", and any other value +** for "bad". +** +** Options: +** -i|--interactive Prompt the user for the good/bad/skip decision +** after each step, rather than using the exit +** code from COMMAND +** +** > fossil bisect skip ?VERSION? +** +** Cause VERSION (or the current check-out if VERSION is omitted) to +** be ignored for the purpose of the current bisect. This might +** be done, for example, because VERSION does not compile correctly +** or is otherwise unsuitable to participate in this bisect. +** +** > fossil bisect vlist|ls|status ?-a|--all? +** +** List the versions in between the inner-most "bad" and "good". +** +** > fossil bisect ui ?HOST@USER:PATH? +** +** Like "fossil ui" except start on a timeline that shows only the +** check-ins that are part of the current bisect. If the optional +** fourth term is added, then information is shown for the bisect that +** occurred in the PATH directory by USER on remote machine HOST. +** +** > fossil bisect undo +** +** Undo the most recent "good", "bad", or "skip" command. +*/ +void bisect_cmd(void){ + int n; + const char *zCmd; + int foundCmd = 0; + db_must_be_within_tree(); + if( g.argc<3 ){ + goto usage; + } + zCmd = g.argv[2]; + n = strlen(zCmd); + if( n==0 ) zCmd = "-"; + if( strncmp(zCmd, "bad", n)==0 ){ + int ridBad; + foundCmd = 1; + if( g.argc==3 ){ + ridBad = db_lget_int("checkout",0); + }else{ + ridBad = name_to_typed_rid(g.argv[3], "ci"); + } + if( ridBad>0 ){ + bisect_append_log(-ridBad); + if( bisect_option("auto-next") && db_lget_int("bisect-good",0)>0 ){ + zCmd = "next"; + n = 4; + } + } + }else if( strncmp(zCmd, "good", n)==0 ){ + int ridGood; + foundCmd = 1; + if( g.argc==3 ){ + ridGood = db_lget_int("checkout",0); + }else{ + ridGood = name_to_typed_rid(g.argv[3], "ci"); + } + if( ridGood>0 ){ + bisect_append_log(ridGood); + if( bisect_option("auto-next") && db_lget_int("bisect-bad",0)>0 ){ + zCmd = "next"; + n = 4; + } + } + }else if( strncmp(zCmd, "skip", n)==0 ){ + int ridSkip; + foundCmd = 1; + if( g.argc==3 ){ + ridSkip = db_lget_int("checkout",0); + }else{ + ridSkip = name_to_typed_rid(g.argv[3], "ci"); + } + if( ridSkip>0 ){ + bisect_append_skip(ridSkip); + if( bisect_option("auto-next") + && db_lget_int("bisect-bad",0)>0 + && db_lget_int("bisect-good",0)>0 + ){ + zCmd = "next"; + n = 4; + } + } + }else if( strncmp(zCmd, "undo", n)==0 ){ + char *zLog; + Blob log, id; + int ridBad = 0; + int ridGood = 0; + int cnt = 0, i; + foundCmd = 1; + db_begin_transaction(); + zLog = db_lget("bisect-log",""); + blob_init(&log, zLog, -1); + while( blob_token(&log, &id) ){ cnt++; } + if( cnt==0 ){ + fossil_fatal("no previous bisect steps to undo"); + } + blob_rewind(&log); + for(i=0; irid==db_lget_int("checkout",0) ) pMid = 0; + }else{ + pMid = path_midpoint(); + } + if( pMid==0 ){ + fossil_print("bisect complete\n"); + db_lset_int("bisect-complete",1); + }else{ + int nSpan = path_length_not_hidden(); + int nStep = path_search_depth(); + g.argv[1] = "update"; + g.argv[2] = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", pMid->rid); + g.argc = 3; + g.fNoSync = 1; + update_cmd(); + fossil_print("span: %d steps-remaining: %d\n", nSpan, nStep); + } + + if( strncmp(zDisplay,"chart",m)==0 ){ + bisect_chart(1); + }else if( strncmp(zDisplay, "log", m)==0 ){ + bisect_chart(0); + }else if( strncmp(zDisplay, "status", m)==0 ){ + bisect_list(1); + } + }else if( strncmp(zCmd, "log", n)==0 ){ + bisect_chart(0); + }else if( strncmp(zCmd, "chart", n)==0 ){ + bisect_chart(1); + }else if( strncmp(zCmd, "run", n)==0 ){ + bisect_run(); + }else if( strncmp(zCmd, "options", n)==0 ){ + if( g.argc==3 ){ + unsigned int i; + for(i=0; i=count(aBisectOption) ){ + fossil_fatal("no such bisect option: %s", g.argv[3]); + } + }else{ + usage("options ?NAME? ?VALUE?"); + } + }else if( strncmp(zCmd, "reset", n)==0 ){ + bisect_reset(); + }else if( strcmp(zCmd, "ui")==0 ){ + char *newArgv[8]; + verify_all_options(); + newArgv[0] = g.argv[0]; + newArgv[1] = "ui"; + newArgv[2] = "--page"; + newArgv[3] = "timeline?bisect"; + if( g.argc==4 ){ + newArgv[4] = g.argv[3]; + g.argc = 5; + }else{ + g.argc = 4; + } + newArgv[g.argc] = 0; + g.argv = newArgv; + cmd_webserver(); + }else if( strncmp(zCmd, "vlist", n)==0 + || strncmp(zCmd, "ls", n)==0 + || strncmp(zCmd, "status", n)==0 + ){ + int fAll = find_option("all", "a", 0)!=0; + bisect_list(!fAll); + }else if( !foundCmd ){ +usage: + usage("bad|good|log|chart|next|options|reset|run|skip|status|ui|undo"); + } +} diff --git a/src/blob.c b/src/blob.c new file mode 100644 index 0000000..4a665da --- /dev/null +++ b/src/blob.c @@ -0,0 +1,2047 @@ +/* +** Copyright (c) 2006 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/ +** +******************************************************************************* +** +** A Blob is a variable-length containers for arbitrary string +** or binary data. +*/ +#include "config.h" +#include +#include "blob.h" +#if defined(_WIN32) +#include +#include +#endif + +#if INTERFACE +/* +** A Blob can hold a string or a binary object of arbitrary size. The +** size changes as necessary. +*/ +struct Blob { + unsigned int nUsed; /* Number of bytes used in aData[] */ + unsigned int nAlloc; /* Number of bytes allocated for aData[] */ + unsigned int iCursor; /* Next character of input to parse */ + unsigned int blobFlags; /* One or more BLOBFLAG_* bits */ + char *aData; /* Where the information is stored */ + void (*xRealloc)(Blob*,u64); /* Function to reallocate the buffer */ +}; + +/* +** Allowed values for Blob.blobFlags +*/ +#define BLOBFLAG_NotSQL 0x0001 /* Non-SQL text */ + +/* +** The current size of a Blob +*/ +#define blob_size(X) ((X)->nUsed) + +/* +** The buffer holding the blob data +*/ +#define blob_buffer(X) ((X)->aData) + +/* +** Number of elements that fits into the current blob's size +*/ +#define blob_count(X,elType) (blob_size(X)/sizeof(elType)) + +/* +** Append blob contents to another +*/ +#define blob_appendb(dest, src) \ + blob_append((dest), blob_buffer(src), blob_size(src)) + +/* +** Append a string literal to a blob +** TODO: Consider renaming to blob_appendl() +*/ +#define blob_append_literal(blob, literal) \ + blob_append((blob), "" literal, (sizeof literal)-1) + /* + * The empty string in the second argument leads to a syntax error + * when the macro is not used with a string literal. Unfortunately + * the error is not overly explicit. + */ + +/* +** TODO: Suggested for removal because the name seems misleading. +*/ +#define blob_append_string blob_append_literal + +/* +** Seek whence parameter values +*/ +#define BLOB_SEEK_SET 1 +#define BLOB_SEEK_CUR 2 + +#endif /* INTERFACE */ + +/* +** Make sure a blob is initialized +*/ +#define blob_is_init(x) \ + assert((x)->xRealloc==blobReallocMalloc || (x)->xRealloc==blobReallocStatic) + +/* +** Make sure a blob does not contain malloced memory. +** +** This might fail if we are unlucky and x is uninitialized. For that +** reason it should only be used locally for debugging. Leave it turned +** off for production. +*/ +#if 0 /* Enable for debugging only */ +#define assert_blob_is_reset(x) assert(blob_is_reset(x)) +#else +#define assert_blob_is_reset(x) +#endif + + + +/* +** We find that the built-in isspace() function does not work for +** some international character sets. So here is a substitute. +*/ +int fossil_isspace(char c){ + return c==' ' || (c<='\r' && c>='\t'); +} + +/* +** Other replacements for ctype.h functions. +*/ +int fossil_islower(char c){ return c>='a' && c<='z'; } +int fossil_isupper(char c){ return c>='A' && c<='Z'; } +int fossil_isdigit(char c){ return c>='0' && c<='9'; } +int fossil_isxdigit(char c){ return (c>='0' && c<='9') || (c>='a' && c<='f'); } +int fossil_isXdigit(char c){ + return (c>='0' && c<='9') || (c>='A' && c<='F') || (c>='a' && c<='f'); +} +int fossil_tolower(char c){ + return fossil_isupper(c) ? c - 'A' + 'a' : c; +} +int fossil_toupper(char c){ + return fossil_islower(c) ? c - 'a' + 'A' : c; +} +int fossil_isalpha(char c){ + return (c>='a' && c<='z') || (c>='A' && c<='Z'); +} +int fossil_isalnum(char c){ + return (c>='a' && c<='z') || (c>='A' && c<='Z') || (c>='0' && c<='9'); +} + +/* Return true if and only if the entire string consists of only +** alphanumeric characters. +*/ +int fossil_no_strange_characters(const char *z){ + while( z && (fossil_isalnum(z[0]) || z[0]=='_' || z[0]=='-') ) z++; + return z[0]==0; +} + + +/* +** COMMAND: test-isspace +** +** Verify that the fossil_isspace() routine is working correctly by +** testing it on all possible inputs. +*/ +void isspace_cmd(void){ + int i; + for(i=0; i<=255; i++){ + if( i==' ' || i=='\n' || i=='\t' || i=='\v' + || i=='\f' || i=='\r' ){ + assert( fossil_isspace((char)i) ); + }else{ + assert( !fossil_isspace((char)i) ); + } + } + fossil_print("All 256 characters OK\n"); +} + +/* +** This routine is called if a blob operation fails because we +** have run out of memory. +*/ +static void blob_panic(void){ + static const char zErrMsg[] = "out of memory\n"; + fputs(zErrMsg, stderr); + fossil_exit(1); +} + +/* +** Maximum size of a Blob's managed memory. This is ~2GB, largely for +** historical reasons. +** +*/ +#define MAX_BLOB_SIZE 0x7fff0000 + +/* +** If n >= MAX_BLOB_SIZE, calls blob_panic(), +** else this is a no-op. +*/ +static void blob_assert_safe_size(u64 n){ + if( n>=(i64)MAX_BLOB_SIZE ){ + blob_panic(); + } +} + +/* +** A reallocation function that assumes that aData came from malloc(). +** This function attempts to resize the buffer of the blob to hold +** newSize bytes. +** +** No attempt is made to recover from an out-of-memory error. +** If an OOM error occurs, an error message is printed on stderr +** and the program exits. +*/ +void blobReallocMalloc(Blob *pBlob, u64 newSize){ + if( newSize==0 ){ + free(pBlob->aData); + pBlob->aData = 0; + pBlob->nAlloc = 0; + pBlob->nUsed = 0; + pBlob->iCursor = 0; + pBlob->blobFlags = 0; + }else if( newSize>pBlob->nAlloc || newSize+4000nAlloc ){ + char *pNew; + blob_assert_safe_size(newSize); + pNew = fossil_realloc(pBlob->aData, newSize); + pBlob->aData = pNew; + pBlob->nAlloc = (unsigned int)newSize; + if( pBlob->nUsed>pBlob->nAlloc ){ + pBlob->nUsed = pBlob->nAlloc; + } + } +} + +/* +** An initializer for Blobs +*/ +#if INTERFACE +#define BLOB_INITIALIZER {0,0,0,0,0,blobReallocMalloc} +#endif +const Blob empty_blob = BLOB_INITIALIZER; + +/* +** A reallocation function for when the initial string is in unmanaged +** space. Copy the string to memory obtained from malloc(). +*/ +static void blobReallocStatic(Blob *pBlob, u64 newSize){ + if( newSize==0 ){ + *pBlob = empty_blob; + }else{ + char *pNew; + blob_assert_safe_size(newSize); + pNew = fossil_malloc( newSize ); + if( pBlob->nUsed>newSize ) pBlob->nUsed = newSize; + memcpy(pNew, pBlob->aData, pBlob->nUsed); + pBlob->aData = pNew; + pBlob->xRealloc = blobReallocMalloc; + pBlob->nAlloc = (unsigned int)newSize; + } +} + +/* +** Reset a blob to be an empty container. +*/ +void blob_reset(Blob *pBlob){ + blob_is_init(pBlob); + pBlob->xRealloc(pBlob, 0); +} + + +/* +** Return true if the blob has been zeroed - in other words if it contains +** no malloced memory. This only works reliably if the blob has been +** initialized - it can return a false negative on an uninitialized blob. +*/ +int blob_is_reset(Blob *pBlob){ + if( pBlob==0 ) return 1; + if( pBlob->nUsed ) return 0; + if( pBlob->xRealloc==blobReallocMalloc && pBlob->nAlloc ) return 0; + return 1; +} + +/* +** Initialize a blob to a string or byte-array constant of a specified length. +** Any prior data in the blob is discarded. +*/ +void blob_init(Blob *pBlob, const char *zData, int size){ + assert_blob_is_reset(pBlob); + if( zData==0 ){ + *pBlob = empty_blob; + }else{ + if( size<=0 ) size = strlen(zData); + pBlob->nUsed = pBlob->nAlloc = size; + pBlob->aData = (char*)zData; + pBlob->iCursor = 0; + pBlob->blobFlags = 0; + pBlob->xRealloc = blobReallocStatic; + } +} + +/* +** Initialize a blob to a nul-terminated string. +** Any prior data in the blob is discarded. +*/ +void blob_set(Blob *pBlob, const char *zStr){ + blob_init(pBlob, zStr, -1); +} + +/* +** Initialize a blob to a nul-terminated string obtained from fossil_malloc(). +** The blob will take responsibility for freeing the string. +*/ +void blob_set_dynamic(Blob *pBlob, char *zStr){ + blob_init(pBlob, zStr, -1); + pBlob->xRealloc = blobReallocMalloc; +} + +/* +** Initialize a blob to an empty string. +*/ +void blob_zero(Blob *pBlob){ + static const char zEmpty[] = ""; + assert_blob_is_reset(pBlob); + pBlob->nUsed = 0; + pBlob->nAlloc = 1; + pBlob->aData = (char*)zEmpty; + pBlob->iCursor = 0; + pBlob->blobFlags = 0; + pBlob->xRealloc = blobReallocStatic; +} + +/* +** Append text or data to the end of a blob. Or, if pBlob==NULL, send +** the text to standard output in terminal mode, or to standard CGI output +** in CGI mode. +** +** If nData<0 then output all of aData up to the first 0x00 byte. +** +** Use the blob_append() routine in all application code. The blob_append() +** routine is faster, but blob_append_full() handles all the corner cases. +** The blob_append() routine automatically calls blob_append_full() if +** necessary. +*/ +static void blob_append_full(Blob *pBlob, const char *aData, int nData){ + u64 nNew; + /* assert( aData!=0 || nData==0 ); // omitted for speed */ + /* blob_is_init(pBlob); // omitted for speed */ + if( nData<0 ) nData = strlen(aData); + if( nData==0 ) return; + if( pBlob==0 ){ + if( g.cgiOutput ){ + pBlob = cgi_output_blob(); + }else{ + fossil_puts(aData, 0, nData); + return; + } + } + nNew = pBlob->nUsed; + nNew += nData; + if( nNew >= pBlob->nAlloc ){ + nNew += pBlob->nAlloc; + nNew += 100; + blob_assert_safe_size(nNew); + pBlob->xRealloc(pBlob, (unsigned)nNew); + if( pBlob->nUsed + nData >= pBlob->nAlloc ){ + blob_panic(); + } + } + memcpy(&pBlob->aData[pBlob->nUsed], aData, nData); + pBlob->nUsed += nData; + pBlob->aData[pBlob->nUsed] = 0; /* Blobs are always nul-terminated */ +} +void blob_append(Blob *pBlob, const char *aData, int nData){ + u64 nUsed; + /* assert( aData!=0 || nData==0 ); // omitted for speed */ + if( nData<=0 || pBlob==0 || pBlob->nUsed + nData >= pBlob->nAlloc ){ + blob_append_full(pBlob, aData, nData); + return; + } + nUsed = pBlob->nUsed; + pBlob->nUsed += nData; + pBlob->aData[pBlob->nUsed] = 0; + memcpy(&pBlob->aData[nUsed], aData, nData); +} + +/* +** Append a single character to the blob. If pBlob is zero then the +** character is written directly to stdout. +*/ +void blob_append_char(Blob *pBlob, char c){ + if( pBlob==0 || pBlob->nUsed+1 >= pBlob->nAlloc ){ + blob_append_full(pBlob, &c, 1); + }else{ + pBlob->aData[pBlob->nUsed++] = c; + } +} + +/* +** Copy a blob. pTo is reinitialized to be a copy of pFrom. +*/ +void blob_copy(Blob *pTo, Blob *pFrom){ + blob_is_init(pFrom); + blob_zero(pTo); + blob_append(pTo, blob_buffer(pFrom), blob_size(pFrom)); +} + +/* +** Append the second blob onto the end of the first blob and reset the +** second blob. If the first blob (pTo) is NULL, then the content +** of the second blob is written to stdout or to CGI depending on if the +** Fossil is running in terminal or CGI mode. +*/ +void blob_append_xfer(Blob *pTo, Blob *pFrom){ + blob_append(pTo, blob_buffer(pFrom), blob_size(pFrom)); + blob_reset(pFrom); +} + +/* +** Write into pOut, a string literal representation for the first n bytes +** of z[]. The string literal representation is compatible with C, TCL, +** and JSON. Double-quotes are added to both ends. Double-quote and +** backslash characters are escaped. +*/ +void blob_append_tcl_literal(Blob *pOut, const char *z, size_t n){ + size_t i; + blob_append_char(pOut, '"'); + for(i=0; inUsed==0 ){ + blob_append_char(p, 0); /* NOTE: Changes nUsed. */ + p->nUsed = 0; + } + if( p->nUsednAlloc ){ + p->aData[p->nUsed] = 0; + }else{ + blob_materialize(p); + } + return p->aData; +} + +/* +** Compute the string length of a Blob. If there are embedded +** nul characters, truncate the to blob at the first nul. +*/ +int blob_strlen(Blob *p){ + char *z = blob_str(p); + if( z==0 ) return 0; + p->nUsed = (int)strlen(p->aData); + return p->nUsed; +} + +/* +** Return a pointer to a null-terminated string for a blob that has +** been created using blob_append_sql() and not blob_appendf(). If +** text was ever added using blob_appendf() then throw an error. +*/ +char *blob_sql_text(Blob *p){ + blob_is_init(p); + if( (p->blobFlags & BLOBFLAG_NotSQL) ){ + fossil_panic("use of blob_appendf() to construct SQL text"); + } + return blob_str(p); +} + + +/* +** Return a pointer to a null-terminated string for a blob. +** +** WARNING: If the blob is ephemeral, it might cause a '\000' +** character to be inserted into the middle of the parent blob. +** Example: Suppose p is a token extracted from some larger +** blob pBig using blob_token(). If you call this routine on p, +** then a '\000' character will be inserted in the middle of +** pBig in order to cause p to be nul-terminated. If pBig +** should not be modified, then use blob_str() instead of this +** routine. blob_str() will make a copy of the p if necessary +** to avoid modifying pBig. +*/ +char *blob_terminate(Blob *p){ + blob_is_init(p); + if( p->nUsed==0 ) return ""; + p->aData[p->nUsed] = 0; + return p->aData; +} + +/* +** Compare two blobs. Return negative, zero, or positive if the first +** blob is less then, equal to, or greater than the second. +*/ +int blob_compare(const Blob *pA, const Blob *pB){ + int szA, szB, sz, rc; + blob_is_init(pA); + blob_is_init(pB); + szA = blob_size(pA); + szB = blob_size(pB); + sz = szAnUsed==sizeof(S"")-1 && memcmp((B)->aData,S,sizeof(S)-1)==0) +#endif + + +/* +** Attempt to resize a blob so that its internal buffer is +** nByte in size. The blob is truncated if necessary. +*/ +void blob_resize(Blob *pBlob, u64 newSize){ + pBlob->xRealloc(pBlob, newSize+1); + pBlob->nUsed = newSize; + pBlob->aData[newSize] = 0; +} + +/* +** Ensures that the given blob has at least the given amount of memory +** allocated to it. Does not modify pBlob->nUsed nor will it reduce +** the currently-allocated amount of memory. +** +** For semantic compatibility with blob_append_full(), if newSize is +** >=MAX_BLOB_SIZE then this function will trigger blob_panic(). If it +** didn't, it would be possible to bypass that hard-coded limit via +** this function. +** +** We've had at least one report: +** https://fossil-scm.org/forum/forumpost/b7bbd28db4 +** which implies that this is unconditionally failing on mingw 32-bit +** builds. +*/ +void blob_reserve(Blob *pBlob, u64 newSize){ + blob_assert_safe_size( newSize ); + if(newSize>pBlob->nAlloc){ + pBlob->xRealloc(pBlob, newSize+1); + pBlob->aData[newSize] = 0; + } +} + +/* +** Make sure a blob is nul-terminated and is not a pointer to unmanaged +** space. Return a pointer to the data. +*/ +char *blob_materialize(Blob *pBlob){ + blob_resize(pBlob, pBlob->nUsed); + return pBlob->aData; +} + +/* +** Call dehttpize on a blob. This causes an ephemeral blob to be +** materialized. +*/ +void blob_dehttpize(Blob *pBlob){ + blob_materialize(pBlob); + pBlob->nUsed = dehttpize(pBlob->aData); +} + +/* +** Extract N bytes from blob pFrom and use it to initialize blob pTo. +** Return the actual number of bytes extracted. The cursor position +** is advanced by the number of bytes extracted. +** +** After this call completes, pTo will be an ephemeral blob. +*/ +int blob_extract(Blob *pFrom, int N, Blob *pTo){ + blob_is_init(pFrom); + assert_blob_is_reset(pTo); + if( pFrom->iCursor + N > pFrom->nUsed ){ + N = pFrom->nUsed - pFrom->iCursor; + if( N<=0 ){ + blob_zero(pTo); + return 0; + } + } + pTo->nUsed = N; + pTo->nAlloc = N; + pTo->aData = &pFrom->aData[pFrom->iCursor]; + pTo->iCursor = 0; + pTo->xRealloc = blobReallocStatic; + pFrom->iCursor += N; + return N; +} + +/* +** Extract N **lines** of text from blob pFrom beginning at the current +** cursor position and use that text to initialize blob pTo. Unlike the +** blob_extract() routine, the cursor position is unchanged. +** +** pTo is assumed to be uninitialized. +** +** After this call completes, pTo will be an ephemeral blob. +*/ +int blob_extract_lines(Blob *pFrom, int N, Blob *pTo){ + int i; + int mx; + int iStart; + int n; + const char *z; + + blob_zero(pTo); + z = pFrom->aData; + i = pFrom->iCursor; + mx = pFrom->nUsed; + while( N>0 ){ + while( i=mx ) break; + i++; + N--; + } + iStart = pFrom->iCursor; + n = blob_extract(pFrom, i-pFrom->iCursor, pTo); + pFrom->iCursor = iStart; + return n; +} + +/* +** Return the number of lines of text in the blob. If the last +** line is incomplete (if it does not have a \n at the end) then +** it still counts. +*/ +int blob_linecount(Blob *p){ + int n = 0; + int i; + for(i=0; inUsed; i++){ + if( p->aData[i]=='\n' ) n++; + } + if( p->nUsed>0 && p->aData[p->nUsed-1]!='\n' ) n++; + return n; +} + +/* +** Rewind the cursor on a blob back to the beginning. +*/ +void blob_rewind(Blob *p){ + p->iCursor = 0; +} + +/* +** Truncate a blob to the specified length in bytes. +*/ +void blob_truncate(Blob *p, int sz){ + if( sz>=0 && sz<(int)(p->nUsed) ) p->nUsed = sz; +} + +/* +** Truncate a blob to the specified length in bytes. If truncation +** results in an incomplete UTF-8 sequence at the end, remove up +** to three more bytes back to the last complete UTF-8 sequence. +*/ +void blob_truncate_utf8(Blob *p, int sz){ + if( sz>=0 && sz<(int)(p->nUsed) ){ + p->nUsed = utf8_nearest_codepoint(p->aData,sz); + } +} + +/* +** Seek the cursor in a blob to the indicated offset. +*/ +int blob_seek(Blob *p, int offset, int whence){ + if( whence==BLOB_SEEK_SET ){ + p->iCursor = offset; + }else if( whence==BLOB_SEEK_CUR ){ + p->iCursor += offset; + } + if( p->iCursor>p->nUsed ){ + p->iCursor = p->nUsed; + } + return p->iCursor; +} + +/* +** Return the current offset into the blob +*/ +int blob_tell(Blob *p){ + return p->iCursor; +} + +/* +** Extract a single line of text from pFrom beginning at the current +** cursor location and use that line of text to initialize pTo. +** pTo will include the terminating \n. Return the number of bytes +** in the line including the \n at the end. 0 is returned at +** end-of-file. +** +** The cursor of pFrom is left pointing at the first byte past the +** \n that terminated the line. +** +** pTo will be an ephemeral blob. If pFrom changes, it might alter +** pTo as well. +*/ +int blob_line(Blob *pFrom, Blob *pTo){ + char *aData = pFrom->aData; + int n = pFrom->nUsed; + int i = pFrom->iCursor; + + while( iiCursor, pTo); + return pTo->nUsed; +} + +/* +** Trim whitespace off of the end of a blob. Return the number +** of characters remaining. +** +** All this does is reduce the length counter. This routine does +** not insert a new zero terminator. +*/ +int blob_trim(Blob *p){ + char *z = p->aData; + int n = p->nUsed; + while( n>0 && fossil_isspace(z[n-1]) ){ n--; } + p->nUsed = n; + return n; +} + +/* +** Extract a single token from pFrom and use it to initialize pTo. +** Return the number of bytes in the token. If no token is found, +** return 0. +** +** A token consists of one or more non-space characters. Leading +** whitespace is ignored. +** +** The cursor of pFrom is left pointing at the first character past +** the end of the token. +** +** pTo will be an ephemeral blob. If pFrom changes, it might alter +** pTo as well. +*/ +int blob_token(Blob *pFrom, Blob *pTo){ + char *aData = pFrom->aData; + int n = pFrom->nUsed; + int i = pFrom->iCursor; + while( iiCursor = i; + while( iiCursor, pTo); + while( iiCursor = i; + return pTo->nUsed; +} + +/* +** Extract a single SQL token from pFrom and use it to initialize pTo. +** Return the number of bytes in the token. If no token is found, +** return 0. +** +** An SQL token consists of one or more non-space characters. If the +** first character is ' then the token is terminated by a matching ' +** (ignoring double '') or by the end of the string +** +** The cursor of pFrom is left pointing at the first character past +** the end of the token. +** +** pTo will be an ephemeral blob. If pFrom changes, it might alter +** pTo as well. +*/ +int blob_sqltoken(Blob *pFrom, Blob *pTo){ + char *aData = pFrom->aData; + int n = pFrom->nUsed; + int i = pFrom->iCursor; + while( iiCursor = i; + if( aData[i]=='\'' ){ + i++; + while( iiCursor, pTo); + while( iiCursor = i; + return pTo->nUsed; +} + +/* +** Extract everything from the current cursor to the end of the blob +** into a new blob. The new blob is an ephemeral reference to the +** original blob. The cursor of the original blob is unchanged. +*/ +int blob_tail(Blob *pFrom, Blob *pTo){ + int iCursor = pFrom->iCursor; + blob_extract(pFrom, pFrom->nUsed-pFrom->iCursor, pTo); + pFrom->iCursor = iCursor; + return pTo->nUsed; +} + +/* +** Copy N lines of text from pFrom into pTo. The copy begins at the +** current cursor position of pIn. The pIn cursor is left pointing +** at the first character past the last \n copied. +** +** If pTo==NULL then this routine simply skips over N lines. +*/ +void blob_copy_lines(Blob *pTo, Blob *pFrom, int N){ + char *z = pFrom->aData; + int i = pFrom->iCursor; + int n = pFrom->nUsed; + int cnt = 0; + + if( N==0 ) return; + while( iaData[pFrom->iCursor], i - pFrom->iCursor); + } + pFrom->iCursor = i; +} + +/* +** Remove comment lines (starting with '#') from a blob pIn. +** Keep lines starting with "\#" but remove the initial backslash. +** +** Store the result in pOut. It is ok for pIn and pOut to be the same blob. +** +** pOut must either be the same as pIn or else uninitialized. +*/ +void blob_strip_comment_lines(Blob *pIn, Blob *pOut){ + char *z = pIn->aData; + unsigned int i = 0; + unsigned int n = pIn->nUsed; + unsigned int lineStart = 0; + unsigned int copyStart = 0; + int doCopy = 1; + Blob temp; + blob_zero(&temp); + + while( iaData[copyStart], i - copyStart + 1); + lineStart = copyStart = i + 1; + doCopy = 1; + } + i++; + } + /* Last line */ + if( doCopy ) blob_append(&temp, &pIn->aData[copyStart], i - copyStart); + + if( pOut==pIn ) blob_reset(pOut); + *pOut = temp; +} + +/* +** COMMAND: test-strip-comment-lines +** +** Usage: %fossil test-strip-comment-lines ?OPTIONS? INPUTFILE +** +** Read INPUTFILE and print it without comment lines (starting with '#'). +** Keep lines starting with "\\#" but remove the initial backslash. +** +** This is used to test and debug the blob_strip_comment_lines() routine. +** +** Options: +** -y|--side-by-side Show diff of INPUTFILE and output side-by-side +** -W|--width N Width of lines in side-by-side diff +*/ +void test_strip_comment_lines_cmd(void){ + Blob f, h; /* uninitialized */ + Blob out; + DiffConfig dCfg; + int sbs = 0; + const char *z; + int w = 0; + + memset(&dCfg, 0, sizeof(dCfg)); + + sbs = find_option("side-by-side","y",0)!=0; + if( (z = find_option("width","W",1))!=0 && (w = atoi(z))>0 ){ + dCfg.wColumn = w; + } + verify_all_options(); + if( g.argc!=3 ) usage("INPUTFILE"); + + blob_read_from_file(&f, g.argv[2], ExtFILE); + blob_strip_comment_lines(&f, &h); + + if ( !sbs ){ + blob_write_to_file(&h, "-"); + }else{ + blob_zero(&out); + dCfg.nContext = -1; /* whole content */ + dCfg.diffFlags = DIFF_SIDEBYSIDE | DIFF_CONTEXT_EX | DIFF_STRIP_EOLCR; + diff_begin(&dCfg); + text_diff(&f, &h, &out, &dCfg); + blob_write_to_file(&out, "-"); + diff_end(&dCfg, 0); + } +} + +/* +** Ensure that the text in pBlob ends with '\n' +*/ +void blob_add_final_newline(Blob *pBlob){ + if( pBlob->nUsed<=0 ) return; + if( pBlob->aData[pBlob->nUsed-1]!='\n' ){ + blob_append_char(pBlob, '\n'); + } +} + +/* +** Return true if the blob contains a valid base16 identifier artifact hash. +** +** The value returned is actually one of HNAME_SHA1 OR HNAME_K256 if the +** hash is valid. Both of these are non-zero and therefore "true". +** If the hash is not valid, then HNAME_ERROR is returned, which is zero or +** false. +*/ +int blob_is_hname(Blob *pBlob){ + return hname_validate(blob_buffer(pBlob), blob_size(pBlob)); +} + +/* +** Return true if the blob contains a valid filename +*/ +int blob_is_filename(Blob *pBlob){ + return file_is_simple_pathname(blob_str(pBlob), 1); +} + +/* +** Return true if the blob contains a valid non-negative 32-bit integer +** and store the integer value in *pValue. If the blob is not a valid +** non-negative 32-bit integer, return false and leave *pValue unchanged. +*/ +int blob_is_int(Blob *pBlob, int *pValue){ + const char *z = blob_buffer(pBlob); + int i, n, c; + sqlite3_uint64 v; + n = blob_size(pBlob); + v = 0; + for(i=0; i='0' && c<='9'; i++){ + v = v*10 + c - '0'; + if( v>0x7fffffff ) return 0; + } + if( i==n ){ + *pValue = (int)v; + return 1; + }else{ + return 0; + } +} + +/* +** Return true if the blob contains a valid non-negative 64-bit integer +** and store the integer value in *pValue. If the blob is not a valid +** non-negative 64-bit integer, return false and leave *pValue unchanged. +*/ +int blob_is_int64(Blob *pBlob, sqlite3_int64 *pValue){ + const char *z = blob_buffer(pBlob); + int i, n, c; + sqlite3_uint64 v; + n = blob_size(pBlob); + v = 0; + for(i=0; i='0' && c<='9'; i++){ + if( v<922337203685477580ULL || (v==922337203685477580ULL && c<='7') ){ + v = v*10 + c - '0'; + }else{ + return 0; + } + } + if( i==n ){ + *pValue = (sqlite3_int64)v; + return 1; + }else{ + return 0; + } +} + +/* +** COMMAND: test-atoi +** +** Use the blob_is_int() and blob_is_int64() routines to convert arguments +** into integers. Used for unit testing of those routines. +*/ +void blob_is_int_cmd(void){ + int i; + for(i=2; iblobFlags |= BLOBFLAG_NotSQL; +} +void blob_append_sql(Blob *pBlob, const char *zFormat, ...){ + va_list ap; + va_start(ap, zFormat); + vxprintf(pBlob, zFormat, ap); + va_end(ap); +} +void blob_vappendf(Blob *pBlob, const char *zFormat, va_list ap){ + vxprintf(pBlob, zFormat, ap); +} + +/* +** Initialize a blob to the data on an input channel. Return +** the number of bytes read into the blob. Any prior content +** of the blob is discarded, not freed. +*/ +int blob_read_from_channel(Blob *pBlob, FILE *in, int nToRead){ + size_t n; + blob_zero(pBlob); + if( nToRead<0 ){ + char zBuf[10000]; + while( !feof(in) ){ + n = fread(zBuf, 1, sizeof(zBuf), in); + if( n>0 ){ + blob_append(pBlob, zBuf, n); + } + } + }else{ + blob_resize(pBlob, nToRead); + n = fread(blob_buffer(pBlob), 1, nToRead, in); + blob_resize(pBlob, n); + } + return blob_size(pBlob); +} + +/* +** Initialize a blob to the data read from HTTP input. Return +** the number of bytes read into the blob. Any prior content +** of the blob is discarded, not freed. +*/ +int blob_read_from_cgi(Blob *pBlob, int nToRead){ + size_t n; + blob_zero(pBlob); + if( nToRead<0 ){ + char zBuf[10000]; + while( !cgi_feof() ){ + n = cgi_fread(zBuf, sizeof(zBuf)); + if( n>0 ){ + blob_append(pBlob, zBuf, n); + } + } + }else{ + blob_resize(pBlob, nToRead); + n = cgi_fread(blob_buffer(pBlob), nToRead); + blob_resize(pBlob, n); + } + return blob_size(pBlob); +} + +/* +** Initialize a blob to be the content of a file. If the filename +** is blank or "-" then read from standard input. +** +** If zFilename is a symbolic link, behavior depends on the eFType +** parameter: +** +** * If eFType is ExtFILE or allow-symlinks is OFF, then the +** pBlob is initialized to the *content* of the object to which +** the zFilename symlink points. +** +** * If eFType is RepoFILE and allow-symlinks is ON, then the +** pBlob is initialized to the *name* of the object to which +** the zFilename symlink points. +** +** Any prior content of the blob is discarded, not freed. +** +** Return the number of bytes read. Calls fossil_fatal() on error (i.e. +** it exit()s and does not return). +*/ +sqlite3_int64 blob_read_from_file( + Blob *pBlob, /* The blob to be initialized */ + const char *zFilename, /* Extract content from this file */ + int eFType /* ExtFILE or RepoFILE - see above */ +){ + sqlite3_int64 size, got; + FILE *in; + if( zFilename==0 || zFilename[0]==0 + || (zFilename[0]=='-' && zFilename[1]==0) ){ + return blob_read_from_channel(pBlob, stdin, -1); + } + if( file_islink(zFilename) ){ + return blob_read_link(pBlob, zFilename); + } + size = file_size(zFilename, eFType); + blob_zero(pBlob); + if( size<0 ){ + fossil_fatal("no such file: %s", zFilename); + } + if( size==0 ){ + return 0; + } + blob_resize(pBlob, size); + in = fossil_fopen(zFilename, "rb"); + if( in==0 ){ + fossil_fatal("cannot open %s for reading", zFilename); + } + got = fread(blob_buffer(pBlob), 1, size, in); + fclose(in); + if( got=0 ) return nWrote; + fflush(stdout); + _setmode(_fileno(stdout), _O_BINARY); +#endif + nWrote = fwrite(blob_buffer(pBlob), 1, blob_size(pBlob), stdout); +#if defined(_WIN32) + fflush(stdout); + _setmode(_fileno(stdout), _O_TEXT); +#endif + }else{ + file_mkfolder(zFilename, ExtFILE, 1, 0); + out = fossil_fopen(zFilename, "wb"); + if( out==0 ){ +#if defined(_WIN32) + const char *zReserved = file_is_win_reserved(zFilename); + if( zReserved ){ + fossil_fatal("cannot open \"%s\" because \"%s\" is " + "a reserved name on Windows", zFilename, zReserved); + } +#endif + fossil_fatal_recursive("unable to open file \"%s\" for writing", + zFilename); + return 0; + } + blob_is_init(pBlob); + nWrote = fwrite(blob_buffer(pBlob), 1, blob_size(pBlob), out); + fclose(out); + if( nWrote!=(int)blob_size(pBlob) ){ + fossil_fatal_recursive("short write: %d of %d bytes to %s", nWrote, + blob_size(pBlob), zFilename); + } + } + return nWrote; +} + +/* +** Compress a blob pIn. Store the result in pOut. It is ok for pIn and +** pOut to be the same blob. +** +** pOut must either be the same as pIn or else uninitialized. +*/ +void blob_compress(Blob *pIn, Blob *pOut){ + unsigned int nIn = blob_size(pIn); + unsigned int nOut = 13 + nIn + (nIn+999)/1000; + unsigned long int nOut2; + unsigned char *outBuf; + Blob temp; + blob_zero(&temp); + blob_resize(&temp, nOut+4); + outBuf = (unsigned char*)blob_buffer(&temp); + outBuf[0] = nIn>>24 & 0xff; + outBuf[1] = nIn>>16 & 0xff; + outBuf[2] = nIn>>8 & 0xff; + outBuf[3] = nIn & 0xff; + nOut2 = (long int)nOut; + compress(&outBuf[4], &nOut2, + (unsigned char*)blob_buffer(pIn), blob_size(pIn)); + if( pOut==pIn ) blob_reset(pOut); + assert_blob_is_reset(pOut); + *pOut = temp; + blob_resize(pOut, nOut2+4); +} + +/* +** COMMAND: test-compress +** +** Usage: %fossil test-compress INPUTFILE OUTPUTFILE +** +** Run compression on INPUTFILE and write the result into OUTPUTFILE. +** +** This is used to test and debug the blob_compress() routine. +*/ +void compress_cmd(void){ + Blob f; + if( g.argc!=4 ) usage("INPUTFILE OUTPUTFILE"); + blob_read_from_file(&f, g.argv[2], ExtFILE); + blob_compress(&f, &f); + blob_write_to_file(&f, g.argv[3]); +} + +/* +** Compress the concatenation of a blobs pIn1 and pIn2. Store the result +** in pOut. +** +** pOut must be either uninitialized or must be the same as either pIn1 or +** pIn2. +*/ +void blob_compress2(Blob *pIn1, Blob *pIn2, Blob *pOut){ + unsigned int nIn = blob_size(pIn1) + blob_size(pIn2); + unsigned int nOut = 13 + nIn + (nIn+999)/1000; + unsigned char *outBuf; + z_stream stream; + Blob temp; + blob_zero(&temp); + blob_resize(&temp, nOut+4); + outBuf = (unsigned char*)blob_buffer(&temp); + outBuf[0] = nIn>>24 & 0xff; + outBuf[1] = nIn>>16 & 0xff; + outBuf[2] = nIn>>8 & 0xff; + outBuf[3] = nIn & 0xff; + stream.zalloc = (alloc_func)0; + stream.zfree = (free_func)0; + stream.opaque = 0; + stream.avail_out = nOut; + stream.next_out = &outBuf[4]; + deflateInit(&stream, 9); + stream.avail_in = blob_size(pIn1); + stream.next_in = (unsigned char*)blob_buffer(pIn1); + deflate(&stream, 0); + stream.avail_in = blob_size(pIn2); + stream.next_in = (unsigned char*)blob_buffer(pIn2); + deflate(&stream, 0); + deflate(&stream, Z_FINISH); + blob_resize(&temp, stream.total_out + 4); + deflateEnd(&stream); + if( pOut==pIn1 ) blob_reset(pOut); + if( pOut==pIn2 ) blob_reset(pOut); + assert_blob_is_reset(pOut); + *pOut = temp; +} + +/* +** COMMAND: test-compress-2 +** +** Usage: %fossil test-compress-2 IN1 IN2 OUT +** +** Read files IN1 and IN2, concatenate the content, compress the +** content, then write results into OUT. +** +** This is used to test and debug the blob_compress2() routine. +*/ +void compress2_cmd(void){ + Blob f1, f2; + if( g.argc!=5 ) usage("INPUTFILE1 INPUTFILE2 OUTPUTFILE"); + blob_read_from_file(&f1, g.argv[2], ExtFILE); + blob_read_from_file(&f2, g.argv[3], ExtFILE); + blob_compress2(&f1, &f2, &f1); + blob_write_to_file(&f1, g.argv[4]); +} + +/* +** Uncompress blob pIn and store the result in pOut. It is ok for pIn and +** pOut to be the same blob. +** +** pOut must be either uninitialized or the same as pIn. +*/ +int blob_uncompress(Blob *pIn, Blob *pOut){ + unsigned int nOut; + unsigned char *inBuf; + unsigned int nIn = blob_size(pIn); + Blob temp; + int rc; + unsigned long int nOut2; + if( nIn<=4 ){ + return 0; + } + inBuf = (unsigned char*)blob_buffer(pIn); + nOut = (inBuf[0]<<24) + (inBuf[1]<<16) + (inBuf[2]<<8) + inBuf[3]; + blob_zero(&temp); + blob_resize(&temp, nOut+1); + nOut2 = (long int)nOut; + rc = uncompress((unsigned char*)blob_buffer(&temp), &nOut2, + &inBuf[4], nIn - 4); + if( rc!=Z_OK ){ + blob_reset(&temp); + return 1; + } + blob_resize(&temp, nOut2); + if( pOut==pIn ) blob_reset(pOut); + assert_blob_is_reset(pOut); + *pOut = temp; + return 0; +} + +/* +** COMMAND: test-uncompress +** +** Usage: %fossil test-uncompress IN OUT +** +** Read the content of file IN, uncompress that content, and write the +** result into OUT. This command is intended for testing of the +** blob_compress() function. +*/ +void uncompress_cmd(void){ + Blob f; + if( g.argc!=4 ) usage("INPUTFILE OUTPUTFILE"); + blob_read_from_file(&f, g.argv[2], ExtFILE); + blob_uncompress(&f, &f); + blob_write_to_file(&f, g.argv[3]); +} + +/* +** COMMAND: test-cycle-compress +** +** Compress and uncompress each file named on the command line. +** Verify that the original content is recovered. +*/ +void test_cycle_compress(void){ + int i; + Blob b1, b2, b3; + for(i=2; iaData; + int j = p->nUsed; + int i, n; + for(i=n=0; i=(int)(p->nAlloc) ){ + blob_resize(p, j); + z = p->aData; + } + p->nUsed = j; + z[j] = 0; + while( j>i ){ + if( (z[--j] = z[--i]) =='\n' ){ + z[--j] = '\r'; + } + } +} + +/* +** Remove every \r character from the given blob, replacing each one with +** a \n character if it was not already part of a \r\n pair. +*/ +void blob_to_lf_only(Blob *p){ + int i, j; + char *z = blob_materialize(p); + for(i=j=0; z[i]; i++){ + if( z[i]!='\r' ) z[j++] = z[i]; + else if( z[i+1]!='\n' ) z[j++] = '\n'; + } + z[j] = 0; + p->nUsed = j; +} + +/* +** Convert blob from cp1252 to UTF-8. As cp1252 is a superset +** of iso8859-1, this is useful on UNIX as well. +** +** This table contains the character translations for 0x80..0xA0. +*/ + +static const unsigned short cp1252[32] = { + 0x20ac, 0x81, 0x201A, 0x0192, 0x201E, 0x2026, 0x2020, 0x2021, + 0x02C6, 0x2030, 0x0160, 0x2039, 0x0152, 0x8D, 0x017D, 0x8F, + 0x90, 0x2018, 0x2019, 0x201C, 0x201D, 0x2022, 0x2013, 0x2014, + 0x2DC, 0x2122, 0x0161, 0x203A, 0x0153, 0x9D, 0x017E, 0x0178 +}; + +void blob_cp1252_to_utf8(Blob *p){ + unsigned char *z = (unsigned char *)p->aData; + int j = p->nUsed; + int i, n; + for(i=n=0; i=0x80 ){ + if( (z[i]<0xa0) && (cp1252[z[i]&0x1f]>=0x800) ){ + n++; + } + n++; + } + } + j += n; + if( j>=(int)(p->nAlloc) ){ + blob_resize(p, j); + z = (unsigned char *)p->aData; + } + p->nUsed = j; + z[j] = 0; + while( j>i ){ + if( z[--i]>=0x80 ){ + if( z[i]<0xa0 ){ + unsigned short sym = cp1252[z[i]&0x1f]; + if( sym>=0x800 ){ + z[--j] = 0x80 | (sym&0x3f); + z[--j] = 0x80 | ((sym>>6)&0x3f); + z[--j] = 0xe0 | (sym>>12); + }else{ + z[--j] = 0x80 | (sym&0x3f); + z[--j] = 0xc0 | (sym>>6); + } + }else{ + z[--j] = 0x80 | (z[i]&0x3f); + z[--j] = 0xC0 | (z[i]>>6); + } + }else{ + z[--j] = z[i]; + } + } +} + +/* +** ASCII (for reference): +** x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xa xb xc xd xe xf +** 0x ^` ^a ^b ^c ^d ^e ^f ^g \b \t \n () \f \r ^n ^o +** 1x ^p ^q ^r ^s ^t ^u ^v ^w ^x ^y ^z ^{ ^| ^} ^~ ^ +** 2x () ! " # $ % & ' ( ) * + , - . / +** 3x 0 1 2 3 4 5 6 7 8 9 : ; < = > ? +** 4x @ A B C D E F G H I J K L M N O +** 5x P Q R S T U V W X Y Z [ \ ] ^ _ +** 6x ` a b c d e f g h i j k l m n o +** 7x p q r s t u v w x y z { | } ~ ^_ +*/ + +/* +** Meanings for bytes in a filename: +** +** 0 Ordinary character. No encoding required +** 1 Needs to be escaped +** 2 Illegal character. Do not allow in a filename +** 3 First byte of a 2-byte UTF-8 +** 4 First byte of a 3-byte UTF-8 +** 5 First byte of a 4-byte UTF-8 +*/ +static const char aSafeChar[256] = { +#ifdef _WIN32 +/* Windows +** Prohibit: all control characters, including tab, \r and \n. +** Escape: (space) " # $ % & ' ( ) * ; < > ? [ ] ^ ` { | } +*/ +/* x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xa xb xc xd xe xf */ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, /* 0x */ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, /* 1x */ + 1, 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, /* 2x */ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, /* 3x */ + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 4x */ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 1, 0, /* 5x */ + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 6x */ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, /* 7x */ +#else +/* Unix +** Prohibit: all control characters, including tab, \r and \n +** Escape: (space) ! " # $ % & ' ( ) * ; < > ? [ \ ] ^ ` { | } +*/ +/* x0 x1 x2 x3 x4 x5 x6 x7 x8 x9 xa xb xc xd xe xf */ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, /* 0x */ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, /* 1x */ + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, /* 2x */ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0, 1, 1, /* 3x */ + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 4x */ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 0, /* 5x */ + 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, /* 6x */ + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 0, 1, /* 7x */ +#endif + /* all bytes 0x80 through 0xbf are unescaped, being secondary + ** bytes to UTF8 characters. Bytes 0xc0 through 0xff are the + ** first byte of a UTF8 character and do get escaped */ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, /* 8x */ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, /* 9x */ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, /* ax */ + 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, /* bx */ + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, /* cx */ + 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, /* dx */ + 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, /* ex */ + 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5 /* fx */ +}; + +/* +** pBlob is a shell command under construction. This routine safely +** appends filename argument zIn. +** +** The argument is escaped if it contains white space or other characters +** that need to be escaped for the shell. If zIn contains characters +** that cannot be safely escaped, then throw a fatal error. +** +** If the isFilename argument is true, then the argument is expected +** to be a filename. As shell commands commonly have command-line +** options that begin with "-" and since we do not want an attacker +** to be able to invoke these switches using filenames that begin +** with "-", if zIn begins with "-", prepend an additional "./" +** (or ".\\" on Windows). +*/ +void blob_append_escaped_arg(Blob *pBlob, const char *zIn, int isFilename){ + int i; + unsigned char c; + int needEscape = 0; + int n = blob_size(pBlob); + char *z = blob_buffer(pBlob); + + /* Look for illegal byte-sequences and byte-sequences that require + ** escaping. No control-characters are allowed. All spaces and + ** non-ASCII unicode characters and some punctuation characters require + ** escaping. */ + for(i=0; (c = (unsigned char)zIn[i])!=0; i++){ + if( aSafeChar[c] ){ + unsigned char x = aSafeChar[c]; + needEscape = 1; + if( x==2 ){ + Blob bad; + blob_token(pBlob, &bad); + fossil_fatal("the [%s] argument to the \"%s\" command contains " + "a character (ascii 0x%02x) that is not allowed in " + "filename arguments", + zIn, blob_str(&bad), c); + }else if( x>2 ){ + if( (zIn[i+1]&0xc0)!=0x80 + || (x>=4 && (zIn[i+2]&0xc0)!=0x80) + || (x==5 && (zIn[i+3]&0xc0)!=0x80) + ){ + Blob bad; + blob_token(pBlob, &bad); + fossil_fatal("the [%s] argument to the \"%s\" command contains " + "an illegal UTF-8 character", + zIn, blob_str(&bad)); + } + i += x-2; + } + } + } + + /* Separate from the previous argument by a space */ + if( n>0 && !fossil_isspace(z[n-1]) ){ + blob_append_char(pBlob, ' '); + } + + /* Check for characters that need quoting */ + if( !needEscape ){ + if( isFilename && zIn[0]=='-' ){ + blob_append_char(pBlob, '.'); +#if defined(_WIN32) + blob_append_char(pBlob, '\\'); +#else + blob_append_char(pBlob, '/'); +#endif + } + blob_append(pBlob, zIn, -1); + }else{ +#if defined(_WIN32) + /* Quoting strategy for windows: + ** Put the entire name inside of "...". Any " characters within + ** the name get doubled. + */ + blob_append_char(pBlob, '"'); + if( isFilename && zIn[0]=='-' ){ + blob_append_char(pBlob, '.'); + blob_append_char(pBlob, '\\'); + }else if( zIn[0]=='/' ){ + blob_append_char(pBlob, '.'); + } + for(i=0; (c = (unsigned char)zIn[i])!=0; i++){ + blob_append_char(pBlob, (char)c); + if( c=='"' ) blob_append_char(pBlob, '"'); + if( c=='\\' ) blob_append_char(pBlob, '\\'); + if( c=='%' && isFilename ) blob_append(pBlob, "%cd:~,%", 7); + } + blob_append_char(pBlob, '"'); +#else + /* Quoting strategy for unix: + ** If the name does not contain ', then surround the whole thing + ** with '...'. If there is one or more ' characters within the + ** name, then put \ before each special character. + */ + if( strchr(zIn,'\'') ){ + if( isFilename && zIn[0]=='-' ){ + blob_append_char(pBlob, '.'); + blob_append_char(pBlob, '/'); + } + for(i=0; (c = (unsigned char)zIn[i])!=0; i++){ + if( aSafeChar[c] && aSafeChar[c]!=2 ) blob_append_char(pBlob, '\\'); + blob_append_char(pBlob, (char)c); + } + }else{ + blob_append_char(pBlob, '\''); + if( isFilename && zIn[0]=='-' ){ + blob_append_char(pBlob, '.'); + blob_append_char(pBlob, '/'); + } + blob_append(pBlob, zIn, -1); + blob_append_char(pBlob, '\''); + } +#endif + } +} + +/* +** COMMAND: test-escaped-arg +** +** Usage %fossil ARGS ... +** +** Run each argument through blob_append_escaped_arg() and show the +** result. Append each argument to "fossil test-echo" and run that +** using fossil_system() to verify that it really does get escaped +** correctly. +** +** Other options: +** +** --filename-args BOOL Subsequent arguments are assumed to be +** filenames if BOOL is true, or not if BOOL +** is false. Defaults on. +** +** --hex HEX Skip the --hex flag and instead decode HEX +** into ascii. This provides a way to insert +** unusual characters as an argument for testing. +** +** --compare HEX ASCII Verify that argument ASCII is identical to +** to decoded HEX. +** +** --fuzz N Run N fuzz cases. Each case is a call +** to "fossil test-escaped-arg --compare HEX ARG" +** where HEX and ARG are the same argument. +** The argument is chosen at random. +*/ +void test_escaped_arg_command(void){ + int i; + Blob x; + const char *zArg; + int isFilename = 1; + char zBuf[100]; + blob_init(&x, 0, 0); + for(i=2; i=(sizeof(zBuf)-1)*2 ){ + fossil_fatal("Argument to --hex is too big"); + } + memset(zBuf, 0, sizeof(zBuf)); + decode16((const unsigned char*)g.argv[i], (unsigned char*)zBuf, (int)n); + zArg = zBuf; + }else if( fossil_strcmp(zArg, "--compare")==0 && i+2=(sizeof(zBuf)-1)*2 ){ + fossil_fatal("HEX argument to --compare is too big"); + } + memset(zBuf, 0, sizeof(zBuf)); + if( decode16((const unsigned char*)g.argv[i], (unsigned char*)zBuf, + (int)n) ){ + fossil_fatal("HEX decode of %s failed", g.argv[i]); + } + zArg = g.argv[++i]; + if( zArg[0]=='-' ){ + fossil_fatal("filename argument \"%s\" begins with \"-\"", zArg); + } +#ifdef _WIN32 + if( zBuf[0]=='-' && zArg[0]=='.' && zArg[1]=='\\' ) zArg += 2; +#else + if( zBuf[0]=='-' && zArg[0]=='.' && zArg[1]=='/' ) zArg += 2; +#endif + if( strcmp(zBuf, zArg)!=0 ){ + fossil_fatal("argument disagree: \"%s\" (%s) versus \"%s\"", + zBuf, g.argv[i-1], zArg); + } + continue; + }else if( fossil_strcmp(zArg, "--fuzz")==0 && i+1=0x7f ){ + /* Translate illegal bytes into various non-ASCII unicode + ** characters in order to exercise those code paths */ + unsigned int u; + if( cx>=0x7f ){ + u = cx; + }else if( cx>=0x08 ){ + u = 0x800 + cx; + }else{ + u = 0x10000 + cx; + } + if( u<0x00080 ){ + zWord[k] = u & 0xFF; + }else if( u<0x00800 ){ + zWord[k++] = 0xC0 + (u8)((u>>6)&0x1F); + zWord[k] = 0x80 + (u8)(u & 0x3F); + }else if( u<0x10000 ){ + zWord[k++] = 0xE0 + (u8)((u>>12)&0x0F); + zWord[k++] = 0x80 + (u8)((u>>6) & 0x3F); + zWord[k] = 0x80 + (u8)(u & 0x3F); + }else{ + zWord[k++] = 0xF0 + (u8)((u>>18) & 0x07); + zWord[k++] = 0x80 + (u8)((u>>12) & 0x3F); + zWord[k++] = 0x80 + (u8)((u>>6) & 0x3F); + zWord[k] = 0x80 + (u8)(u & 0x3F); + } + } + } + zWord[k] = 0; + encode16(zWord, (unsigned char*)zBuf, (int)k); + blob_appendf(&x, "%$ test-escaped-arg --compare %s %$", + g.nameOfExe, zBuf,zWord); + rc = fossil_system(blob_str(&x)); + if( rc ) fossil_fatal("failed test (%d): %s\n", rc, blob_str(&x)); + blob_reset(&x); + } + continue; + }else if( fossil_strcmp(zArg, "--filename-args")==0 ){ + if( i+1iCursor, and copies them +** to pDest (which must be valid memory at least nLen bytes long). +** +** Returns the number of bytes read/copied, which may be less than +** nLen (if end-of-blob is encountered). +** +** Updates pIn's cursor. +** +** Returns 0 if pIn contains no data. +*/ +unsigned int blob_read(Blob *pIn, void * pDest, unsigned int nLen ){ + if( !pIn->aData || (pIn->iCursor >= pIn->nUsed) ){ + return 0; + } else if( (pIn->iCursor + nLen) > (unsigned int)pIn->nUsed ){ + nLen = (unsigned int) (pIn->nUsed - pIn->iCursor); + } + assert( pIn->nUsed > pIn->iCursor ); + assert( (pIn->iCursor+nLen) <= pIn->nUsed ); + if( nLen ){ + memcpy( pDest, pIn->aData, nLen ); + pIn->iCursor += nLen; + } + return nLen; +} + +/* +** Swaps the contents of the given blobs. Results +** are unspecified if either value is NULL or both +** point to the same blob. +*/ +void blob_swap( Blob *pLeft, Blob *pRight ){ + Blob swap = *pLeft; + *pLeft = *pRight; + *pRight = swap; +} + +/* +** Strip a possible byte-order-mark (BOM) from the blob. On Windows, if there +** is either no BOM at all or an (le/be) UTF-16 BOM, a conversion to UTF-8 is +** done. If useMbcs is false and there is no BOM, the input string is assumed +** to be UTF-8 already, so no conversion is done. +*/ +void blob_to_utf8_no_bom(Blob *pBlob, int useMbcs){ + char *zUtf8; + int bomSize = 0; + int bomReverse = 0; + if( starts_with_utf8_bom(pBlob, &bomSize) ){ + struct Blob temp; + zUtf8 = blob_str(pBlob) + bomSize; + blob_zero(&temp); + blob_append(&temp, zUtf8, -1); + blob_swap(pBlob, &temp); + blob_reset(&temp); + }else if( starts_with_utf16_bom(pBlob, &bomSize, &bomReverse) ){ + zUtf8 = blob_buffer(pBlob); + if( bomReverse ){ + /* Found BOM, but with reversed bytes */ + unsigned int i = blob_size(pBlob); + while( i>1 ){ + /* swap bytes of unicode representation */ + char zTemp = zUtf8[--i]; + zUtf8[i] = zUtf8[i-1]; + zUtf8[--i] = zTemp; + } + } + /* Make sure the blob contains two terminating 0-bytes */ + blob_append(pBlob, "\000\000", 3); + zUtf8 = blob_str(pBlob) + bomSize; + zUtf8 = fossil_unicode_to_utf8(zUtf8); + blob_reset(pBlob); + blob_set_dynamic(pBlob, zUtf8); + }else if( useMbcs && invalid_utf8(pBlob) ){ +#if defined(_WIN32) + zUtf8 = fossil_mbcs_to_utf8(blob_str(pBlob)); + blob_reset(pBlob); + blob_append(pBlob, zUtf8, -1); + fossil_mbcs_free(zUtf8); +#else + blob_cp1252_to_utf8(pBlob); +#endif /* _WIN32 */ + } +} diff --git a/src/branch.c b/src/branch.c new file mode 100644 index 0000000..0ff853d --- /dev/null +++ b/src/branch.c @@ -0,0 +1,1164 @@ +/* +** Copyright (c) 2007 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 used to create new branches within a repository. +*/ +#include "config.h" +#include "branch.h" +#include + +/* +** Return the name of the main branch. Cache the result. +** +** This is the current value of the "main-branch" setting, or its default +** value (historically, and as of 2025-10-28: "trunk") if not set. +*/ +const char *db_main_branch(void){ + static char *zMainBranch = 0; + if( zMainBranch==0 ) zMainBranch = db_get("main-branch", 0); + return zMainBranch; +} + +/* +** Return true if zBr is the branch name associated with check-in with +** blob.uuid value of zUuid +*/ +int branch_includes_uuid(const char *zBr, const char *zUuid){ + return db_exists( + "SELECT 1 FROM tagxref, blob" + " WHERE blob.uuid=%Q AND tagxref.rid=blob.rid" + " AND tagxref.value=%Q AND tagxref.tagtype>0" + " AND tagxref.tagid=%d", + zUuid, zBr, TAG_BRANCH + ); +} + +/* +** If RID refers to a check-in, return the name of the branch for that +** check-in. +** +** Space to hold the returned value is obtained from fossil_malloc() +** and should be freed by the caller. +*/ +char *branch_of_rid(int rid){ + char *zBr = 0; + static Stmt q; + db_static_prepare(&q, + "SELECT value FROM tagxref" + " WHERE rid=$rid AND tagid=%d" + " AND tagtype>0", TAG_BRANCH); + db_bind_int(&q, "$rid", rid); + if( db_step(&q)==SQLITE_ROW ){ + zBr = fossil_strdup(db_column_text(&q,0)); + } + db_reset(&q); + if( zBr==0 ){ + zBr = fossil_strdup(db_main_branch()); + } + return zBr; +} + +/* +** fossil branch new NAME BASIS ?OPTIONS? +** argv0 argv1 argv2 argv3 argv4 +*/ +void branch_new(void){ + int rootid; /* RID of the root check-in - what we branch off of */ + int brid; /* RID of the branch check-in */ + int noSign; /* True if the branch is unsigned */ + int i; /* Loop counter */ + char *zUuid; /* Artifact ID of origin */ + Stmt q; /* Generic query */ + const char *zBranch; /* Name of the new branch */ + char *zDate; /* Date that branch was created */ + char *zComment; /* Check-in comment for the new branch */ + const char *zColor; /* Color of the new branch */ + Blob branch; /* manifest for the new branch */ + Manifest *pParent; /* Parsed parent manifest */ + Blob mcksum; /* Self-checksum on the manifest */ + const char *zDateOvrd; /* Override date string */ + const char *zUserOvrd; /* Override user name */ + int isPrivate = 0; /* True if the branch should be private */ + + noSign = find_option("nosign","",0)!=0; + if( find_option("nosync",0,0) ) g.fNoSync = 1; + zColor = find_option("bgcolor","c",1); + isPrivate = find_option("private",0,0)!=0; + zDateOvrd = find_option("date-override",0,1); + zUserOvrd = find_option("user-override",0,1); + verify_all_options(); + if( g.argc<5 ){ + usage("new BRANCH-NAME BASIS ?OPTIONS?"); + } + db_find_and_open_repository(0, 0); + noSign = db_get_boolean("omitsign", 0)|noSign; + if( db_get_boolean("clearsign", 0)==0 ){ noSign = 1; } + + /* fossil branch new name */ + zBranch = g.argv[3]; + if( zBranch==0 || zBranch[0]==0 ){ + fossil_fatal("branch name cannot be empty"); + } + if( branch_is_open(zBranch) ){ + fossil_fatal("an open branch named \"%s\" already exists", zBranch); + } + + user_select(); + db_begin_transaction(); + rootid = name_to_typed_rid(g.argv[4], "ci"); + if( rootid==0 ){ + fossil_fatal("unable to locate check-in off of which to branch"); + } + + pParent = manifest_get(rootid, CFTYPE_MANIFEST, 0); + if( pParent==0 ){ + fossil_fatal("%s is not a valid check-in", g.argv[4]); + } + + /* Create a manifest for the new branch */ + blob_zero(&branch); + if( pParent->zBaseline ){ + blob_appendf(&branch, "B %s\n", pParent->zBaseline); + } + zComment = mprintf("Create new branch named \"%h\"", zBranch); + blob_appendf(&branch, "C %F\n", zComment); + zDate = date_in_standard_format(zDateOvrd ? zDateOvrd : "now"); + blob_appendf(&branch, "D %s\n", zDate); + + /* Copy all of the content from the parent into the branch */ + for(i=0; inFile; ++i){ + blob_appendf(&branch, "F %F", pParent->aFile[i].zName); + if( pParent->aFile[i].zUuid ){ + blob_appendf(&branch, " %s", pParent->aFile[i].zUuid); + if( pParent->aFile[i].zPerm && pParent->aFile[i].zPerm[0] ){ + blob_appendf(&branch, " %s", pParent->aFile[i].zPerm); + } + } + blob_append(&branch, "\n", 1); + } + zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rootid); + blob_appendf(&branch, "P %s\n", zUuid); + if( pParent->zRepoCksum ){ + blob_appendf(&branch, "R %s\n", pParent->zRepoCksum); + } + manifest_destroy(pParent); + + /* Add the symbolic branch name and the "branch" tag to identify + ** this as a new branch */ + if( content_is_private(rootid) ) isPrivate = 1; + if( zColor!=0 ){ + blob_appendf(&branch, "T *bgcolor * %F\n", zColor); + } + blob_appendf(&branch, "T *branch * %F\n", zBranch); + blob_appendf(&branch, "T *sym-%F *\n", zBranch); + if( isPrivate ){ + noSign = 1; + } + + /* Cancel all other symbolic tags */ + db_prepare(&q, + "SELECT tagname FROM tagxref, tag" + " WHERE tagxref.rid=%d AND tagxref.tagid=tag.tagid" + " AND tagtype>0 AND tagname GLOB 'sym-*'" + " ORDER BY tagname", + rootid); + while( db_step(&q)==SQLITE_ROW ){ + const char *zTag = db_column_text(&q, 0); + blob_appendf(&branch, "T -%F *\n", zTag); + } + db_finalize(&q); + + blob_appendf(&branch, "U %F\n", zUserOvrd ? zUserOvrd : login_name()); + md5sum_blob(&branch, &mcksum); + blob_appendf(&branch, "Z %b\n", &mcksum); + if( !noSign && clearsign(&branch, &branch) ){ + Blob ans; + char cReply; + prompt_user("unable to sign manifest. continue (y/N)? ", &ans); + cReply = blob_str(&ans)[0]; + if( cReply!='y' && cReply!='Y'){ + db_end_transaction(1); + fossil_exit(1); + } + } + + brid = content_put_ex(&branch, 0, 0, 0, isPrivate); + if( brid==0 ){ + fossil_fatal("trouble committing manifest: %s", g.zErrMsg); + } + db_add_unsent(brid); + if( manifest_crosslink(brid, &branch, MC_PERMIT_HOOKS)==0 ){ + fossil_fatal("%s", g.zErrMsg); + } + assert( blob_is_reset(&branch) ); + content_deltify(rootid, &brid, 1, 0); + zUuid = rid_to_uuid(brid); + fossil_print("New branch: %s\n", zUuid); + if( g.argc==3 ){ + fossil_print( + "\n" + "Note: the local check-out has not been updated to the new\n" + " branch. To begin working on the new branch, do this:\n" + "\n" + " %s update %s\n", + g.argv[0], zBranch + ); + } + + + /* Commit */ + db_end_transaction(0); + + /* Do an autosync push, if requested */ + if( !isPrivate ) autosync_loop(SYNC_PUSH, 0, "branch"); +} + +/* +** Create a TEMP table named "tmp_brlist" with 7 columns: +** +** name Name of the branch +** mtime Time of last check-in on this branch +** isclosed True if the branch is closed +** mergeto Another branch this branch was merged into +** nckin Number of check-ins on this branch +** ckin Hash of the last check-in on this branch +** isprivate True if the branch is private +** bgclr Background color for this branch +*/ +static const char createBrlistQuery[] = +@ CREATE TEMP TABLE IF NOT EXISTS tmp_brlist AS +@ SELECT +@ tagxref.value AS name, +@ max(event.mtime) AS mtime, +@ EXISTS(SELECT 1 FROM tagxref AS tx +@ WHERE tx.rid=tagxref.rid +@ AND tx.tagid=(SELECT tagid FROM tag WHERE tagname='closed') +@ AND tx.tagtype>0) AS isclosed, +@ (SELECT tagxref.value +@ FROM plink CROSS JOIN tagxref +@ WHERE plink.pid=event.objid +@ AND tagxref.rid=plink.cid +@ AND tagxref.tagid=(SELECT tagid FROM tag WHERE tagname='branch') +@ AND tagtype>0) AS mergeto, +@ count(*) AS nckin, +@ (SELECT uuid FROM blob WHERE rid=tagxref.rid) AS ckin, +@ event.bgcolor AS bgclr, +@ EXISTS(SELECT 1 FROM private WHERE rid=tagxref.rid) AS isprivate +@ FROM tagxref, tag, event +@ WHERE tagxref.tagid=tag.tagid +@ AND tagxref.tagtype>0 +@ AND tag.tagname='branch' +@ AND event.objid=tagxref.rid +@ GROUP BY 1; +; + +/* Call this routine to create the TEMP table */ +static void brlist_create_temp_table(void){ + db_exec_sql(createBrlistQuery); +} + + +#if INTERFACE +/* +** Allows bits in the mBplqFlags parameter to branch_prepare_list_query(). +*/ +#define BRL_CLOSED_ONLY 0x001 /* Show only closed branches */ +#define BRL_OPEN_ONLY 0x002 /* Show only open branches */ +#define BRL_BOTH 0x003 /* Show both open and closed branches */ +#define BRL_OPEN_CLOSED_MASK 0x003 +#define BRL_ORDERBY_MTIME 0x004 /* Sort by MTIME. (otherwise sort by name)*/ +#define BRL_REVERSE 0x008 /* Reverse the sort order */ +#define BRL_PRIVATE 0x010 /* Show only private branches */ +#define BRL_MERGED 0x020 /* Show only merged branches */ +#define BRL_UNMERGED 0x040 /* Show only unmerged branches */ +#define BRL_LIST_USERS 0x080 /* Populate list of users participating */ + +#endif /* INTERFACE */ + +/* +** Prepare a query that will list branches. +** +** If the BRL_ORDERBY_MTIME flag is set and nLimitMRU ("Limit Most Recently Used +** style") is a non-zero number, the result is limited to nLimitMRU entries, and +** the BRL_REVERSE flag is applied in an outer query after processing the limit, +** so that it's possible to generate short lists with the most recently modified +** branches sorted chronologically in either direction, as does the "branch lsh" +** command. +** For other cases, the outer query is also generated, but works as a no-op. The +** code to build the outer query is marked with *//* OUTER QUERY *//* comments. +*/ +void branch_prepare_list_query( + Stmt *pQuery, + int brFlags, + const char *zBrNameGlob, + int nLimitMRU, + const char *zUser +){ + Blob sql; + blob_init(&sql, 0, 0); + brlist_create_temp_table(); + /* Ignore nLimitMRU if no chronological sort requested. */ + if( (brFlags & BRL_ORDERBY_MTIME)==0 ) nLimitMRU = 0; + /* Negative values for nLimitMRU also mean "no limit". */ + if( nLimitMRU<0 ) nLimitMRU = 0; + /* OUTER QUERY */ + blob_append_sql(&sql,"SELECT name, isprivate, mergeto,"); + if( brFlags & BRL_LIST_USERS ){ + blob_append_sql(&sql, + " (SELECT group_concat(user) FROM (" + " SELECT DISTINCT * FROM (" + " SELECT coalesce(euser,user) AS user" + " FROM event" + " WHERE type='ci' AND objid IN (" + " SELECT rid FROM tagxref WHERE value=name)" + " ORDER BY 1)))" + ); + }else{ + blob_append_sql(&sql, " NULL"); + } + blob_append_sql(&sql," FROM ("); + /* INNER QUERY */ + switch( brFlags & BRL_OPEN_CLOSED_MASK ){ + case BRL_CLOSED_ONLY: { + blob_append_sql(&sql, + "SELECT name, isprivate, mtime, mergeto FROM tmp_brlist WHERE isclosed" + ); + break; + } + case BRL_BOTH: { + blob_append_sql(&sql, + "SELECT name, isprivate, mtime, mergeto FROM tmp_brlist WHERE 1" + ); + break; + } + case BRL_OPEN_ONLY: { + blob_append_sql(&sql, + "SELECT name, isprivate, mtime, mergeto FROM tmp_brlist " + " WHERE NOT isclosed" + ); + break; + } + } + if( brFlags & BRL_PRIVATE ) blob_append_sql(&sql, " AND isprivate"); + if( brFlags & BRL_MERGED ) blob_append_sql(&sql, " AND mergeto IS NOT NULL"); + if( zBrNameGlob ) blob_append_sql(&sql, " AND (name GLOB %Q)", zBrNameGlob); + if( zUser && zUser[0] ) blob_append_sql(&sql, + " AND EXISTS (SELECT 1 FROM event WHERE type='ci' AND (user=%Q OR euser=%Q)" + " AND objid in (SELECT rid FROM tagxref WHERE value=tmp_brlist.name))", + zUser, zUser + ); + if( brFlags & BRL_ORDERBY_MTIME ){ + blob_append_sql(&sql, " ORDER BY -mtime"); + }else{ + blob_append_sql(&sql, " ORDER BY name COLLATE nocase"); + } + if( brFlags & BRL_REVERSE && !nLimitMRU ){ + blob_append_sql(&sql," DESC"); + } + if( nLimitMRU ){ + blob_append_sql(&sql," LIMIT %d",nLimitMRU); + } + blob_append_sql(&sql,")"); /* OUTER QUERY */ + if( brFlags & BRL_REVERSE && nLimitMRU ){ + blob_append_sql(&sql," ORDER BY mtime"); /* OUTER QUERY */ + } + db_prepare_blob(pQuery, &sql); + blob_reset(&sql); +} + +/* +** If the branch named in the argument is open, return a RID for one of +** the open leaves of that branch. If the branch does not exists or is +** closed, return 0. +*/ +int branch_is_open(const char *zBrName){ + return db_int(0, + "SELECT rid FROM tagxref AS ox" + " WHERE tagid=%d" + " AND tagtype=2" + " AND value=%Q" + " AND rid IN leaf" + " AND NOT EXISTS(SELECT 1 FROM tagxref AS ix" + " WHERE tagid=%d" + " AND tagtype=1" + " AND ox.rid=ix.rid)", + TAG_BRANCH, zBrName, TAG_CLOSED + ); +} + +/* +** Internal helper for branch_cmd_close() and friends. Adds a row to +** the to the brcmdtag TEMP table, initializing that table if needed, +** holding a pending tag for the given blob.rid (which is assumed to +** be valid). zTag must be a fully-formed tag name, including the +** (+,-,*) prefix character. +** +*/ +static void branch_cmd_tag_add(int rid, const char *zTag){ + static int once = 0; + assert(zTag && ('+'==zTag[0] || '-'==zTag[0] || '*'==zTag[0])); + if(0==once++){ + db_multi_exec("CREATE TEMP TABLE brcmdtag(" + "rid INTEGER UNIQUE ON CONFLICT IGNORE," + "tag TEXT NOT NULL" + ")"); + } + db_multi_exec("INSERT INTO brcmdtag(rid,tag) VALUES(%d,%Q)", + rid, zTag); +} + +/* +** Internal helper for branch_cmd_close() and friends. Creates and +** saves a control artifact of tag changes stored via +** branch_cmd_tag_add(). Fails fatally on error, returns 0 if it saves +** an artifact, and a negative value if it does not save anything +** because no tags were queued up. A positive return value is reserved +** for potential future semantics. +** +** This function asserts that a transaction is underway and it ends +** the transaction, committing or rolling back, as appropriate. +*/ +static int branch_cmd_tag_finalize(int fDryRun /* roll back if true */, + int fVerbose /* output extra info */, + const char *zDateOvrd /* --date-override */, + const char *zUserOvrd /* --user-override */){ + int nTags = 0; + Stmt q = empty_Stmt; + Blob manifest = empty_blob; + int doRollback = fDryRun!=0; + + assert(db_transaction_nesting_depth() > 0); + if(!db_table_exists("temp","brcmdtag")){ + fossil_warning("No tags added - nothing to do."); + db_end_transaction(1); + return -1; + } + db_prepare(&q, "SELECT b.uuid, t.tag " + "FROM blob b, brcmdtag t " + "WHERE b.rid=t.rid " + "ORDER BY t.tag, b.uuid"); + blob_appendf(&manifest, "D %z\n", + date_in_standard_format( zDateOvrd ? zDateOvrd : "now")); + while(SQLITE_ROW==db_step(&q)){ + const char * zHash = db_column_text(&q, 0); + const char * zTag = db_column_text(&q, 1); + blob_appendf(&manifest, "T %s %s\n", zTag, zHash); + ++nTags; + } + if(!nTags){ + fossil_warning("No tags added - nothing to do."); + db_end_transaction(1); + blob_reset(&manifest); + return -1; + } + user_select(); + blob_appendf(&manifest, "U %F\n", zUserOvrd ? zUserOvrd : login_name()); + { /* Z-card and save artifact */ + int newRid; + Blob cksum = empty_blob; + md5sum_blob(&manifest, &cksum); + blob_appendf(&manifest, "Z %b\n", &cksum); + blob_reset(&cksum); + if(fDryRun && fVerbose){ + fossil_print("Dry-run mode: will roll back new artifact:\n%b", + &manifest); + /* Run through the saving steps, though, noting that doing so + ** will clear out &manifest, which is why we output it here + ** instead of after saving. */ + } + newRid = content_put(&manifest); + if(0==newRid){ + fossil_fatal("Problem saving new artifact: %s\n%b", + g.zErrMsg, &manifest); + }else if(manifest_crosslink(newRid, &manifest, 0)==0){ + fossil_fatal("Crosslinking error: %s", g.zErrMsg); + } + fossil_print("Saved new control artifact %z (RID %d).\n", + rid_to_uuid(newRid), newRid); + db_add_unsent(newRid); + if(fDryRun){ + fossil_print("Dry-run mode: rolling back new artifact.\n"); + assert(0!=doRollback); + } + } + db_multi_exec("DROP TABLE brcmdtag"); + blob_reset(&manifest); + db_end_transaction(doRollback); + return 0; +} + +/* +** Internal helper for branch_cmd_close() and friends. zName is a +** symbolic check-in name. Returns the blob.rid of the check-in or fails +** fatally if the name does not resolve unambiguously. If zUuid is +** not NULL, *zUuid is set to the resolved blob.uuid and must be freed +** by the caller via fossil_free(). +*/ +static int branch_resolve_name(char const *zName, char **zUuid){ + const int rid = name_to_uuid2(zName, "ci", zUuid); + if(0==rid){ + fossil_fatal("Cannot resolve name: %s", zName); + }else if(rid<0){ + fossil_fatal("Ambiguous name: %s", zName); + } + return rid; +} + +/* +** Implementation of (branch hide/unhide) subcommands. nStartAtArg is +** the g.argv index to start reading branch/check-in names. fHide is +** true for hiding, false for unhiding. Fails fatally on error. +*/ +static void branch_cmd_hide(int nStartAtArg, int fHide){ + int argPos = nStartAtArg; /* g.argv pos with first branch/ci name */ + char * zUuid = 0; /* Resolved branch UUID. */ + const int fVerbose = find_option("verbose","v",0)!=0; + const int fDryRun = find_option("dry-run","n",0)!=0; + const char *zDateOvrd = find_option("date-override",0,1); + const char *zUserOvrd = find_option("user-override",0,1); + + verify_all_options(); + db_begin_transaction(); + for( ; argPos < g.argc; fossil_free(zUuid), ++argPos ){ + const char * zName = g.argv[argPos]; + const int rid = branch_resolve_name(zName, &zUuid); + const int isHidden = rid_has_tag(rid, TAG_HIDDEN); + /* Potential TODO: check for existing 'hidden' flag and skip this + ** entry if it already has (if fHide) or does not have (if !fHide) + ** that tag. FWIW, /ci_edit does not do so. */ + if(fHide && isHidden){ + fossil_warning("Skipping hidden check-in %s: %s.", zName, zUuid); + continue; + }else if(!fHide && !isHidden){ + fossil_warning("Skipping non-hidden check-in %s: %s.", zName, zUuid); + continue; + } + branch_cmd_tag_add(rid, fHide ? "*hidden" : "-hidden"); + if(fVerbose!=0){ + fossil_print("%s check-in [%s] %s\n", + fHide ? "Hiding" : "Unhiding", + zName, zUuid); + } + } + branch_cmd_tag_finalize(fDryRun, fVerbose, zDateOvrd, zUserOvrd); +} + +/* +** Implementation of (branch close|reopen) subcommands. nStartAtArg is +** the g.argv index to start reading branch/check-in names. The given +** check-ins are closed if fClose is true, else their "closed" tag (if +** any) is cancelled. Fails fatally on error. +*/ +static void branch_cmd_close(int nStartAtArg, int fClose){ + int argPos = nStartAtArg; /* g.argv pos with first branch name */ + char * zUuid = 0; /* Resolved branch UUID. */ + const int fVerbose = find_option("verbose","v",0)!=0; + const int fDryRun = find_option("dry-run","n",0)!=0; + const char *zDateOvrd = find_option("date-override",0,1); + const char *zUserOvrd = find_option("user-override",0,1); + + verify_all_options(); + db_begin_transaction(); + for( ; argPos < g.argc; fossil_free(zUuid), ++argPos ){ + const char * zName = g.argv[argPos]; + const int rid = branch_resolve_name(zName, &zUuid); + const int isClosed = leaf_is_closed(rid); + if(!is_a_leaf(rid)){ + /* This behaviour is different from /ci_edit closing, where + ** is_a_leaf() adds a "+" tag and !is_a_leaf() adds a "*" + ** tag. We might want to change this to match for consistency's + ** sake, but it currently seems unnecessary to close/re-open a + ** non-leaf. */ + fossil_warning("Skipping non-leaf [%s] %s", zName, zUuid); + continue; + }else if(fClose && isClosed){ + fossil_warning("Skipping closed leaf [%s] %s", zName, zUuid); + continue; + }else if(!fClose && !isClosed){ + fossil_warning("Skipping non-closed leaf [%s] %s", zName, zUuid); + continue; + } + branch_cmd_tag_add(rid, fClose ? "+closed" : "-closed"); + if(fVerbose!=0){ + fossil_print("%s branch [%s] %s\n", + fClose ? "Closing" : "Re-opening", + zName, zUuid); + } + } + branch_cmd_tag_finalize(fDryRun, fVerbose, zDateOvrd, zUserOvrd); +} + +/* +** COMMAND: branch +** +** Usage: %fossil branch SUBCOMMAND ... ?OPTIONS? +** +** Run various subcommands to manage branches of the open repository or +** of the repository identified by the -R or --repository option. +** +** > fossil branch close|reopen ?OPTIONS? BRANCH-NAME ?...BRANCH-NAMES? +** +** Adds or cancels the "closed" tag to one or more branches. +** It accepts arbitrary unambiguous symbolic names but +** will only resolve check-in names and skips any which resolve +** to non-leaf check-ins. +** +** Options: +** -n|--dry-run Do not commit changes, but dump artifact +** to stdout +** -v|--verbose Output more information +** --date-override DATE DATE to use instead of 'now' +** --user-override USER USER to use instead of the current default +** +** > fossil branch current +** +** Print the name of the branch for the current check-out +** +** > fossil branch hide|unhide ?OPTIONS? BRANCH-NAME ?...BRANCH-NAMES? +** +** Adds or cancels the "hidden" tag for the specified branches or +** or check-in IDs. Accepts the same options as the close +** subcommand. +** +** > fossil branch info BRANCH-NAME +** +** Print information about a branch +** +** > fossil branch list|ls ?OPTIONS? ?GLOB? +** > fossil branch lsh ?OPTIONS? ?LIMIT? +** +** List all branches. +** +** Options: +** -a|--all List all branches. Default show only open branches +** -c|--closed List closed branches +** -m|--merged List branches merged into the current branch +** -M|--unmerged List branches not merged into the current branch +** -p List only private branches +** -r Reverse the sort order +** -t Show recently changed branches first +** --self List only branches where you participate +** --username USER List only branches where USER participates +** --users N List up to N users participating +** +** The current branch is marked with an asterisk. Private branches are +** marked with a hash sign. +** +** If GLOB is given, show only branches matching the pattern. +** +** The "lsh" variant of this subcommand shows recently changed branches, +** and accepts an optional LIMIT argument (defaults to 5) to cap output, +** but no GLOB argument. All other options are supported, with -t being +** an implied no-op. +** +** > fossil branch new BRANCH-NAME BASIS ?OPTIONS? +** +** Create a new branch BRANCH-NAME off of check-in BASIS. +** +** This command is available for people who want to create a branch +** in advance. But the use of this command is discouraged. The +** preferred idiom in Fossil is to create new branches at the point +** of need, using the "--branch NAME" option to the "fossil commit" +** command. +** +** Options: +** --private Branch is private (i.e., remains local) +** --bgcolor COLOR Use COLOR instead of automatic background +** --nosign Do not sign the manifest for the check-in +** that creates this branch +** --nosync Do not auto-sync prior to creating the branch +** --date-override DATE DATE to use instead of 'now' +** --user-override USER USER to use instead of the current default +** +** Options: +** -R|--repository REPO Run commands on repository REPO +*/ +void branch_cmd(void){ + int n; + const char *zCmd = "list"; + db_find_and_open_repository(0, 0); + if( g.argc>=3 ) zCmd = g.argv[2]; + n = strlen(zCmd); + if( strncmp(zCmd,"current",n)==0 ){ + if( !g.localOpen ){ + fossil_fatal("not within an open check-out"); + }else{ + int vid = db_lget_int("checkout", 0); + char *zCurrent = db_text(0, "SELECT value FROM tagxref" + " WHERE rid=%d AND tagid=%d", vid, TAG_BRANCH); + fossil_print("%s\n", zCurrent); + fossil_free(zCurrent); + } + }else if( strncmp(zCmd,"info",n)==0 ){ + int i; + for(i=3; i4 || (g.argc==4 && (nLimit = atoi(g.argv[3]))==0) ){ + fossil_fatal("the lsh subcommand allows one optional numeric argument"); + } + brFlags |= BRL_ORDERBY_MTIME; + }else{ + if( g.argc >= 4 ) zBrNameGlob = g.argv[3]; + } + + if( g.localOpen ){ + vid = db_lget_int("checkout", 0); + zCurrent = db_text(0, "SELECT value FROM tagxref" + " WHERE rid=%d AND tagid=%d", vid, TAG_BRANCH); + } + branch_prepare_list_query(&q, brFlags, zBrNameGlob, nLimit, zUser); + blob_init(&txt, 0, 0); + while( db_step(&q)==SQLITE_ROW ){ + const char *zBr = db_column_text(&q, 0); + int isPriv = db_column_int(&q, 1)==1; + const char *zMergeTo = db_column_text(&q, 2); + int isCur = zCurrent!=0 && fossil_strcmp(zCurrent,zBr)==0; + const char *zUsers = db_column_text(&q, 3); + if( (brFlags & BRL_MERGED) && fossil_strcmp(zCurrent,zMergeTo)!=0 ){ + continue; + } + if( (brFlags & BRL_UNMERGED) && (fossil_strcmp(zCurrent,zMergeTo)==0 + || isCur) ){ + continue; + } + blob_appendf(&txt, "%s%s%s", + ( (brFlags & BRL_PRIVATE) ? " " : ( isPriv ? "#" : " ") ), + (isCur ? "* " : " "), zBr); + if( nUsers ){ + char c; + const char *cp; + const char *pComma = 0; + int commas = 0; + for( cp = zUsers; ( c = *cp ) != 0; cp++ ){ + if( c == ',' ){ + commas++; + if( commas == nUsers ) pComma = cp; + } + } + if( pComma ){ + blob_appendf(&txt, " (%.*s,... %i more)", + pComma - zUsers, zUsers, commas + 1 - nUsers); + }else{ + blob_appendf(&txt, " (%s)", zUsers); + } + } + fossil_print("%s\n", blob_str(&txt)); + blob_reset(&txt); + } + db_finalize(&q); + }else if( strncmp(zCmd,"new",n)==0 ){ + branch_new(); + }else if( strncmp(zCmd,"close",5)==0 ){ + if(g.argc<4){ + usage("close branch-name(s)..."); + } + branch_cmd_close(3, 1); + }else if( strncmp(zCmd,"reopen",6)==0 ){ + if(g.argc<4){ + usage("reopen branch-name(s)..."); + } + branch_cmd_close(3, 0); + }else if( strncmp(zCmd,"hide",4)==0 ){ + if(g.argc<4){ + usage("hide branch-name(s)..."); + } + branch_cmd_hide(3,1); + }else if( strncmp(zCmd,"unhide",6)==0 ){ + if(g.argc<4){ + usage("unhide branch-name(s)..."); + } + branch_cmd_hide(3,0); + }else{ + fossil_fatal("branch subcommand should be one of: " + "close current hide info list ls lsh new reopen unhide"); + } +} + +/* +** This is the new-style branch-list page that shows the branch names +** together with their ages (time of last check-in) and whether or not +** they are closed or merged to another branch. +** +** Control jumps to this routine from brlist_page() (the /brlist handler) +** if there are no query parameters. +*/ +static void new_brlist_page(void){ + Stmt q; + double rNow; + int show_colors = PB("colors"); + const char *zMainBranch; + login_check_credentials(); + if( !g.perm.Read ){ login_needed(g.anon.Read); return; } + style_set_current_feature("branch"); + style_header("Branches"); + style_adunit_config(ADUNIT_RIGHT_OK); + style_submenu_checkbox("colors", "Use Branch Colors", 0, 0); + + login_anonymous_available(); + zMainBranch = db_main_branch(); + + brlist_create_temp_table(); + db_prepare(&q, "SELECT * FROM tmp_brlist ORDER BY mtime DESC"); + rNow = db_double(0.0, "SELECT julianday('now')"); + @ + @
+ @ + @ + @ + @ + @ + @ + @ + @ + while( db_step(&q)==SQLITE_ROW ){ + const char *zBranch = db_column_text(&q, 0); + double rMtime = db_column_double(&q, 1); + int isClosed = db_column_int(&q, 2); + const char *zMergeTo = db_column_text(&q, 3); + int nCkin = db_column_int(&q, 4); + const char *zLastCkin = db_column_text(&q, 5); + const char *zBgClr = db_column_text(&q, 6); + char *zAge = human_readable_age(rNow - rMtime); + sqlite3_int64 iMtime = (sqlite3_int64)(rMtime*86400.0); + if( zMergeTo && zMergeTo[0]==0 ) zMergeTo = 0; + if( zBgClr ) zBgClr = reasonable_bg_color(zBgClr, 0); + if( zBgClr==0 ){ + if( zBranch==0 || strcmp(zBranch, zMainBranch)==0 ){ + zBgClr = 0; + }else{ + zBgClr = hash_color(zBranch); + } + } + if( zBgClr && zBgClr[0] && show_colors ){ + @ + }else{ + @ + } + @ + @ + @ + fossil_free(zAge); + @ + if( zMergeTo ){ + @ + }else{ + @ + } + @ + } + @
Branch NameLast ChangeCheck-insStatusResolution
%z(href("%R/timeline?r=%T",zBranch))%h(zBranch)%s(zAge)%d(nCkin)%s(isClosed?"closed":"")merged into + @ %z(href("%R/timeline?f=%!S",zLastCkin))%h(zMergeTo)
+ db_finalize(&q); + builtin_request_js("fossil.page.brlist.js"); + style_table_sorter(); + style_finish_page(); +} + +/* +** WEBPAGE: brlist +** Show a list of branches. With no query parameters, a sortable table +** is used to show all branches. If query parameters are present a +** fixed bullet list is shown. +** +** Query parameters: +** +** all Show all branches +** closed Show only closed branches +** open Show only open branches +** colortest Show all branches with automatic color +** +** When there are no query parameters, a new-style /brlist page shows +** all branches in a sortable table. The new-style /brlist page is +** preferred and is the default. +*/ +void brlist_page(void){ + Stmt q; + int cnt; + int showClosed = P("closed")!=0; + int showAll = P("all")!=0; + int showOpen = P("open")!=0; + int colorTest = P("colortest")!=0; + int brFlags = BRL_OPEN_ONLY; + + if( showClosed==0 && showAll==0 && showOpen==0 && colorTest==0 ){ + new_brlist_page(); + return; + } + login_check_credentials(); + if( !g.perm.Read ){ login_needed(g.anon.Read); return; } + cgi_check_for_malice(); + if( colorTest ){ + showClosed = 0; + showAll = 1; + } + if( showAll ) brFlags = BRL_BOTH; + if( showClosed ) brFlags = BRL_CLOSED_ONLY; + + style_set_current_feature("branch"); + style_header("%s", showClosed ? "Closed Branches" : + showAll ? "All Branches" : "Open Branches"); + style_submenu_element("Timeline", "brtimeline"); + if( showClosed ){ + style_submenu_element("All", "brlist?all"); + style_submenu_element("Open", "brlist?open"); + }else if( showAll ){ + style_submenu_element("Closed", "brlist?closed"); + style_submenu_element("Open", "brlist"); + }else{ + style_submenu_element("All", "brlist?all"); + style_submenu_element("Closed", "brlist?closed"); + } + if( !colorTest ){ + style_submenu_element("Color-Test", "brlist?colortest"); + }else{ + style_submenu_element("All", "brlist?all"); + } + login_anonymous_available(); +#if 0 + style_sidebox_begin("Nomenclature:", "33%"); + @
    + @
  1. An
    %z(href("brlist")) + @ open branch
    is a branch that has one or more + @
    %z(href("leaves"))open leaves.
    + @ The presence of open leaves presumably means + @ that the branch is still being extended with new check-ins.
  2. + @
  3. A
    %z(href("brlist?closed")) + @ closed branch
    is a branch with only + @
    %z(href("leaves?closed")) + @ closed leaves
    . + @ Closed branches are fixed and do not change (unless they are first + @ reopened).
  4. + @
+ style_sidebox_end(); +#endif + + branch_prepare_list_query(&q, brFlags, 0, 0, 0); + cnt = 0; + while( db_step(&q)==SQLITE_ROW ){ + const char *zBr = db_column_text(&q, 0); + if( cnt==0 ){ + if( colorTest ){ + @

Default background colors for all branches:

+ }else if( showClosed ){ + @

Closed Branches:

+ }else if( showAll ){ + @

All Branches:

+ }else{ + @

Open Branches:

+ } + @
    + cnt++; + } + if( colorTest ){ + const char *zColor = hash_color(zBr); + @
  • + @ %h(zBr) → %s(zColor)
  • + }else{ + @
  • %z(href("%R/timeline?r=%T",zBr))%h(zBr)
  • + } + } + if( cnt ){ + @
+ } + db_finalize(&q); + style_finish_page(); +} + +/* +** This routine is called while for each check-in that is rendered by +** the timeline of a "brlist" page. Add some additional hyperlinks +** to the end of the line. +*/ +static void brtimeline_extra( + Stmt *pQuery, /* Current row of the timeline query */ + int tmFlags, /* Flags to www_print_timeline() */ + const char *zThisUser, /* Suppress links to this user */ + const char *zThisTag /* Suppress links to this tag */ +){ + int rid; + int tmFlagsNew; + char *zBrName; + + if( (tmFlags & TIMELINE_INLINE)!=0 ){ + tmFlagsNew = (tmFlags & ~TIMELINE_VIEWS) | TIMELINE_MODERN; + cgi_printf("("); + }else{ + tmFlagsNew = tmFlags; + } + timeline_extra(pQuery,tmFlagsNew,zThisUser,zThisTag); + + if( !g.perm.Hyperlink ) return; + rid = db_column_int(pQuery,0); + zBrName = branch_of_rid(rid); + @ branch: \ + @ %z(href("%R/timeline?r=%T",zBrName))%h(zBrName) + if( (tmFlags & TIMELINE_INLINE)!=0 ){ + cgi_printf(")"); + } +} + +/* +** WEBPAGE: brtimeline +** +** List the first check of every branch, starting with the most recent +** and going backwards in time. +** +** Query parameters: +** +** ubg Color the graph by user, not by branch. +*/ +void brtimeline_page(void){ + Blob sql = empty_blob; + Stmt q; + int tmFlags; /* Timeline display flags */ + int fNoHidden = PB("nohidden")!=0; /* The "nohidden" query parameter */ + int fOnlyHidden = PB("onlyhidden")!=0; /* The "onlyhidden" query parameter */ + TimelineXtra xtra; + + login_check_credentials(); + if( !g.perm.Read ){ login_needed(g.anon.Read); return; } + if( robot_restrict("timelineX") ) return; + + style_set_current_feature("branch"); + style_header("Branches"); + style_submenu_element("Branch List", "brlist"); + login_anonymous_available(); + timeline_ss_submenu(); + cgi_check_for_malice(); + @

The initial check-in for each branch:

+ blob_append(&sql, timeline_query_for_www(), -1); + blob_append_sql(&sql, + "AND blob.rid IN (SELECT rid FROM tagxref" + " WHERE tagtype>0 AND tagid=%d AND srcid!=0)", TAG_BRANCH); + if( fNoHidden || fOnlyHidden ){ + const char* zUnaryOp = fNoHidden ? "NOT" : ""; + blob_append_sql(&sql, + " AND %s EXISTS(SELECT 1 FROM tagxref" + " WHERE tagid=%d AND tagtype>0 AND rid=blob.rid)\n", + zUnaryOp/*safe-for-%s*/, TAG_HIDDEN); + } + db_prepare(&q, "%s ORDER BY event.mtime DESC", blob_sql_text(&sql)); + blob_reset(&sql); + /* Always specify TIMELINE_DISJOINT, or graph_finish() may fail because of too + ** many descenders to (off-screen) parents. */ + tmFlags = TIMELINE_DISJOINT | TIMELINE_NOSCROLL; + if( PB("ubg")!=0 ){ + tmFlags |= TIMELINE_UCOLOR; + }else{ + tmFlags |= TIMELINE_BRCOLOR; + } + memset(&xtra, 0, sizeof(xtra)); + xtra.xExtra = brtimeline_extra; + www_print_timeline(&q, tmFlags, &xtra); + db_finalize(&q); + style_finish_page(); +} + +/* +** Generate a multichoice submenu for the few recent active branches. zName is +** the query parameter used to select the current check-in. zCI is optional and +** represent the currently selected check-in, so if it is a check-in hash +** instead of a branch, it can be part of the multichoice menu. +*/ +void generate_branch_submenu_multichoice( + const char* zName, /* Query parameter name */ + const char* zCI /* Current check-in */ +){ + Stmt q; + const int brFlags = BRL_ORDERBY_MTIME | BRL_OPEN_ONLY; + static const char *zBranchMenuList[32*2]; /* 2 per entries */ + const int nLimit = count(zBranchMenuList)/2; + int i = 0; + + if( zName == 0 ) zName = "ci"; + + branch_prepare_list_query(&q, brFlags, 0, nLimit, 0); + zBranchMenuList[i++] = ""; + zBranchMenuList[i++] = "All Check-ins"; + + if( zCI ){ + zCI = fossil_strdup(zCI); + zBranchMenuList[i++] = zCI; + zBranchMenuList[i++] = zCI; + } + /* If current check-in is not "tip", add it to the list */ + if( zCI==0 || strcmp(zCI, "tip") ){ + zBranchMenuList[i++] = "tip"; + zBranchMenuList[i++] = "tip"; + } + while( i/2 < nLimit && db_step(&q)==SQLITE_ROW ){ + const char* zBr = fossil_strdup(db_column_text(&q, 0)); + /* zCI is already in the list, don't add it twice */ + if( zCI==0 || strcmp(zBr, zCI) ){ + zBranchMenuList[i++] = zBr; + zBranchMenuList[i++] = zBr; + } + } + db_finalize(&q); + style_submenu_multichoice(zName, i/2, zBranchMenuList, 0); +} diff --git a/src/browse.c b/src/browse.c new file mode 100644 index 0000000..742df86 --- /dev/null +++ b/src/browse.c @@ -0,0 +1,1262 @@ +/* +** Copyright (c) 2008 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 to implement the file browser web interface. +*/ +#include "config.h" +#include "browse.h" +#include + +/* +** This is the implementation of the "pathelement(X,N)" SQL function. +** +** If X is a unix-like pathname (with "/" separators) and N is an +** integer, then skip the initial N characters of X and return the +** name of the path component that begins on the N+1th character +** (numbered from 0). If the path component is a directory (if +** it is followed by other path components) then prepend "/". +** +** Examples: +** +** pathelement('abc/pqr/xyz', 4) -> '/pqr' +** pathelement('abc/pqr', 4) -> 'pqr' +** pathelement('abc/pqr/xyz', 0) -> '/abc' +*/ +void pathelementFunc( + sqlite3_context *context, + int argc, + sqlite3_value **argv +){ + const unsigned char *z; + int len, n, i; + char *zOut; + + assert( argc==2 ); + z = sqlite3_value_text(argv[0]); + if( z==0 ) return; + len = sqlite3_value_bytes(argv[0]); + n = sqlite3_value_int(argv[1]); + if( len<=n ) return; + if( n>0 && z[n-1]!='/' ) return; + for(i=n; i", + zSep, zLink, j-i, &zPath[i]); + }else{ + char *zLink = href("%R/%s?name=%#T%s", zURI, j, zPath, zREx); + blob_appendf(pOut, "%s%z%#h", + zSep, zLink, j-i, &zPath[i]); + } + zSep = "/"; + while( zPath[j]=='/' ){ j++; } + } +} + +/* +** WEBPAGE: docdir +** +** Show the files and subdirectories within a single directory of the +** source tree. This works similarly to /dir but with the following +** differences: +** +** * Links to files go to /doc (showing the file content directly, +** depending on mimetype) rather than to /file (which always shows +** the file embedded in a standard Fossil page frame). +** +** * The submenu and the page title is not shown. The page is plain. +** +** The /docdir page is a shorthand for /dir with the "dx" query parameter. +** +** Query parameters: +** +** ci=LABEL Show only files in this check-in. If omitted, the +** "trunk" directory is used. +** name=PATH Directory to display. Optional. Top-level if missing +** re=REGEXP Show only files matching REGEXP +** noreadme Do not attempt to display the README file. +** dx File links to go to /doc instead of /file or /finfo. +*/ +void page_docdir(void){ page_dir(); } + +/* +** WEBPAGE: dir +** +** Show the files and subdirectories within a single directory of the +** source tree. Only files for a single check-in are shown if the ci= +** query parameter is present. If ci= is missing, the union of files +** across all check-ins is shown. +** +** Query parameters: +** +** ci=LABEL Show only files in this check-in. Optional. +** name=PATH Directory to display. Optional. Top-level if missing +** re=REGEXP Show only files matching REGEXP +** type=TYPE TYPE=flat: use this display +** TYPE=tree: use the /tree display instead +** noreadme Do not attempt to display the README file. +** dx Behave like /docdir +*/ +void page_dir(void){ + char *zD = fossil_strdup(P("name")); + int nD = zD ? strlen(zD)+1 : 0; + int mxLen; + char *zPrefix; + Stmt q; + const char *zCI = P("ci"); + int rid = 0; + char *zUuid = 0; + Manifest *pM = 0; + const char *zSubdirLink; + HQuery sURI; + int isSymbolicCI = 0; /* ci= is symbolic name, not a hash prefix */ + int isBranchCI = 0; /* True if ci= refers to a branch name */ + char *zHeader = 0; + const char *zRegexp; /* The re= query parameter */ + char *zMatch; /* Extra title text describing the match */ + int bDocDir = PB("dx") || strncmp(g.zPath, "docdir", 6)==0; + + if( zCI && strlen(zCI)==0 ){ zCI = 0; } + if( strcmp(PD("type","flat"),"tree")==0 ){ page_tree(); return; } + login_check_credentials(); + if( !g.perm.Read ){ login_needed(g.anon.Read); return; } + while( nD>1 && zD[nD-2]=='/' ){ zD[(--nD)-1] = 0; } + + /* If the name= parameter is an empty string, make it a NULL pointer */ + if( zD && strlen(zD)==0 ){ zD = 0; } + + /* If a specific check-in is requested, fetch and parse it. If the + ** specific check-in does not exist, clear zCI. zCI==0 will cause all + ** files from all check-ins to be displayed. + */ + if( bDocDir && zCI==0 ) zCI = db_main_branch(); + if( zCI ){ + pM = manifest_get_by_name(zCI, &rid); + if( pM ){ + zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid); + isSymbolicCI = (sqlite3_strnicmp(zUuid, zCI, strlen(zCI))!=0); + isBranchCI = branch_includes_uuid(zCI, zUuid); + if( bDocDir ) zCI = mprintf("%S", zUuid); + Th_StoreUnsafe("current_checkin", zCI); + }else{ + zCI = 0; + } + } + + assert( isSymbolicCI==0 || (zCI!=0 && zCI[0]!=0) ); + if( zD==0 ){ + if( zCI ){ + zHeader = mprintf("Top-level Files of %s", zCI); + }else{ + zHeader = mprintf("All Top-level Files"); + } + }else{ + if( zCI ){ + zHeader = mprintf("Files in %s/ of %s", zD, zCI); + }else{ + zHeader = mprintf("All Files in %s/", zD); + } + } + zRegexp = P("re"); + if( zRegexp ){ + zHeader = mprintf("%z matching \"%s\"", zHeader, zRegexp); + zMatch = mprintf(" matching \"%h\"", zRegexp); + }else{ + zMatch = ""; + } + style_header("%s", zHeader); + fossil_free(zHeader); + if( rid && zD==0 && zMatch[0]==0 && g.perm.Zip ){ + style_submenu_element("Download","%R/rchvdwnld/%!S",zUuid); + } + style_adunit_config(ADUNIT_RIGHT_OK); + sqlite3_create_function(g.db, "pathelement", 2, SQLITE_UTF8, 0, + pathelementFunc, 0, 0); + url_initialize(&sURI, "dir"); + cgi_check_for_malice(); + cgi_query_parameters_to_url(&sURI); + + /* Compute the title of the page */ + if( bDocDir ){ + zPrefix = zD ? mprintf("%s/",zD) : ""; + }else if( zD ){ + Blob dirname; + blob_init(&dirname, 0, 0); + hyperlinked_path(zD, &dirname, zCI, "dir", "", 0); + @

Files in directory %s(blob_str(&dirname)) \ + blob_reset(&dirname); + zPrefix = mprintf("%s/", zD); + style_submenu_element("Top-Level", "%s", + url_render(&sURI, "name", 0, 0, 0)); + }else{ + @

Files in the top-level directory \ + zPrefix = ""; + } + if( zCI ){ + if( bDocDir ){ + /* No header for /docdir. Just give the list of files. */ + }else if( fossil_strcmp(zCI,"tip")==0 ){ + @ from the %z(href("%R/info?name=%T",zCI))latest check-in\ + @ %s(zMatch)

+ }else if( isBranchCI ){ + @ from the %z(href("%R/info?name=%T",zCI))latest check-in \ + @ of branch %z(href("%R/timeline?r=%T",zCI))%h(zCI)\ + @ %s(zMatch) + }else { + @ of check-in %z(href("%R/info?name=%T",zCI))%h(zCI)\ + @ %s(zMatch) + } + if( bDocDir ){ + zSubdirLink = mprintf("%R/docdir?ci=%T&name=%T", zCI, zPrefix); + }else{ + zSubdirLink = mprintf("%R/dir?ci=%T&name=%T", zCI, zPrefix); + } + if( nD==0 && !bDocDir ){ + style_submenu_element("File Ages", "%R/fileage?name=%T", zCI); + } + }else{ + @ in any check-in + zSubdirLink = mprintf("%R/dir?name=%T", zPrefix); + } + if( zD && !bDocDir ){ + style_submenu_element("History","%R/timeline?chng=%T/*", zD); + } + if( !bDocDir ){ + style_submenu_element("Tree-View", "%s", + url_render(&sURI, "type", "tree", 0, 0)); + } + + if( !bDocDir ){ + /* Generate the Branch list submenu */ + generate_branch_submenu_multichoice("ci", zCI); + } + + /* Compute the temporary table "localfiles" containing the names + ** of all files and subdirectories in the zD[] directory. + ** + ** Subdirectory names begin with "/". This causes them to sort + ** first and it also gives us an easy way to distinguish files + ** from directories in the loop that follows. + */ + db_multi_exec( + "CREATE TEMP TABLE localfiles(x UNIQUE NOT NULL, u);" + ); + if( zCI ){ + /* Files in the specific checked given by zCI */ + if( zD ){ + db_multi_exec( + "INSERT OR IGNORE INTO localfiles" + " SELECT pathelement(filename,%d), uuid" + " FROM files_of_checkin(%Q)" + " WHERE filename GLOB '%q/*'", + nD, zCI, zD + ); + }else{ + db_multi_exec( + "INSERT OR IGNORE INTO localfiles" + " SELECT pathelement(filename,%d), uuid" + " FROM files_of_checkin(%Q)", + nD, zCI + ); + } + }else{ + /* All files across all check-ins */ + if( zD ){ + db_multi_exec( + "INSERT OR IGNORE INTO localfiles" + " SELECT pathelement(name,%d), NULL FROM filename" + " WHERE name GLOB '%q/*'", + nD, zD + ); + }else{ + db_multi_exec( + "INSERT OR IGNORE INTO localfiles" + " SELECT pathelement(name,0), NULL FROM filename" + ); + } + } + + /* If the re=REGEXP query parameter is present, filter out names that + ** do not match the pattern */ + if( zRegexp ){ + db_multi_exec( + "DELETE FROM localfiles WHERE x NOT REGEXP %Q", zRegexp + ); + } + + /* Generate a multi-column table listing the contents of zD[] + ** directory. + */ + mxLen = db_int(12, "SELECT max(length(x)) FROM localfiles /*scan*/"); + if( mxLen<12 ) mxLen = 12; + mxLen += (mxLen+9)/10; + db_prepare(&q, + "SELECT x, u FROM localfiles ORDER BY x COLLATE uintnocase /*scan*/"); + @
+ @
    + while( db_step(&q)==SQLITE_ROW ){ + const char *zFN; + zFN = db_column_text(&q, 0); + if( zFN[0]=='/' ){ + zFN++; + @
  • %z(href("%s%T",zSubdirLink,zFN))%h(zFN)
  • + }else{ + const char *zLink; + if( bDocDir ){ + zLink = href("%R/doc/%T/%T%T", zCI, zPrefix, zFN); + }else if( zCI ){ + zLink = href("%R/file?name=%T%T&ci=%T",zPrefix,zFN,zCI); + }else{ + zLink = href("%R/finfo?name=%T%T",zPrefix,zFN); + } + @
  • %z(zLink)%h(zFN)
  • + } + } + db_finalize(&q); + manifest_destroy(pM); + @
+ + /* If the "noreadme" query parameter is present, do not try to + ** show the content of the README file. + */ + if( P("noreadme")!=0 ){ + style_finish_page(); + return; + } + + /* If the directory contains a readme file, then display its content below + ** the list of files + */ + db_prepare(&q, + "SELECT x, u FROM localfiles" + " WHERE x COLLATE nocase IN" + " ('readme','readme.txt','readme.md','readme.wiki','readme.markdown'," + " 'readme.org','readme.html') ORDER BY x COLLATE uintnocase LIMIT 1;" + ); + if( db_step(&q)==SQLITE_ROW ){ + const char *zName = db_column_text(&q,0); + const char *zUuid = db_column_text(&q,1); + if( zUuid ){ + rid = fast_uuid_to_rid(zUuid); + }else{ + if( zD ){ + rid = db_int(0, + "SELECT fid FROM filename, mlink, event" + " WHERE name='%q/%q'" + " AND mlink.fnid=filename.fnid" + " AND event.objid=mlink.mid" + " ORDER BY event.mtime DESC LIMIT 1", + zD, zName + ); + }else{ + rid = db_int(0, + "SELECT fid FROM filename, mlink, event" + " WHERE name='%q'" + " AND mlink.fnid=filename.fnid" + " AND event.objid=mlink.mid" + " ORDER BY event.mtime DESC LIMIT 1", + zName + ); + } + } + if( rid ){ + @
+ if( sqlite3_strlike("readme.html", zName, 0)==0 ){ + if( zUuid==0 ){ + zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid); + } + @ + }else{ + Blob content; + const char *zMime = mimetype_from_name(zName); + content_get(rid, &content); + safe_html_context(DOCSRC_FILE); + if( fossil_strcmp(zMime,"text/x-org")==0 ){ + Blob orgHtml = BLOB_INITIALIZER; + org_to_html(&content,0,&orgHtml); + @ %s(blob_str(&orgHtml)) + blob_reset(&orgHtml); + }else{ + wiki_render_by_mimetype(&content, zMime); + } + document_emit_js(); + } + } + } + db_finalize(&q); + style_finish_page(); +} + +/* +** Objects used by the "tree" webpage. +*/ +typedef struct FileTreeNode FileTreeNode; +typedef struct FileTree FileTree; + +/* +** A single line of the file hierarchy +*/ +struct FileTreeNode { + FileTreeNode *pNext; /* Next entry in an ordered list of them all */ + FileTreeNode *pParent; /* Directory containing this entry */ + FileTreeNode *pSibling; /* Next element in the same subdirectory */ + FileTreeNode *pChild; /* List of child nodes */ + FileTreeNode *pLastChild; /* Last child on the pChild list */ + char *zName; /* Name of this entry. The "tail" */ + char *zFullName; /* Full pathname of this entry */ + char *zUuid; /* Artifact hash of this file. May be NULL. */ + double mtime; /* Modification time for this entry */ + double sortBy; /* Either mtime or size, depending on desired + sort order */ + int iSize; /* Size for this entry */ + unsigned nFullName; /* Length of zFullName */ + unsigned iLevel; /* Levels of parent directories */ +}; + +/* +** A complete file hierarchy +*/ +struct FileTree { + FileTreeNode *pFirst; /* First line of the list */ + FileTreeNode *pLast; /* Last line of the list */ + FileTreeNode *pLastTop; /* Last top-level node */ +}; + +/* +** Add one or more new FileTreeNodes to the FileTree object so that the +** leaf object zPathname is at the end of the node list. +** +** The caller invokes this routine once for each leaf node (each file +** as opposed to each directory). This routine fills in any missing +** intermediate nodes automatically. +** +** When constructing a list of FileTreeNodes, all entries that have +** a common directory prefix must be added consecutively in order for +** the tree to be constructed properly. +*/ +static void tree_add_node( + FileTree *pTree, /* Tree into which nodes are added */ + const char *zPath, /* The full pathname of file to add */ + const char *zUuid, /* Hash of the file. Might be NULL. */ + double mtime, /* Modification time for this entry */ + int size, /* Size for this entry */ + int sortOrder /* 0: filename, 1: mtime, 2: size */ +){ + int i; + FileTreeNode *pParent; /* Parent (directory) of the next node to insert */ + + /* Make pParent point to the most recent ancestor of zPath, or + ** NULL if there are no prior entires that are a container for zPath. + */ + pParent = pTree->pLast; + while( pParent!=0 && + ( strncmp(pParent->zFullName, zPath, pParent->nFullName)!=0 + || zPath[pParent->nFullName]!='/' ) + ){ + pParent = pParent->pParent; + } + i = pParent ? pParent->nFullName+1 : 0; + while( zPath[i] ){ + FileTreeNode *pNew; + int iStart = i; + int nByte; + while( zPath[i] && zPath[i]!='/' ){ i++; } + nByte = sizeof(*pNew) + i + 1; + if( zUuid!=0 && zPath[i]==0 ) nByte += HNAME_MAX+1; + pNew = fossil_malloc( nByte ); + memset(pNew, 0, sizeof(*pNew)); + pNew->zFullName = (char*)&pNew[1]; + memcpy(pNew->zFullName, zPath, i); + pNew->zFullName[i] = 0; + pNew->nFullName = i; + if( zUuid!=0 && zPath[i]==0 ){ + pNew->zUuid = pNew->zFullName + i + 1; + memcpy(pNew->zUuid, zUuid, strlen(zUuid)+1); + } + pNew->zName = pNew->zFullName + iStart; + if( pTree->pLast ){ + pTree->pLast->pNext = pNew; + }else{ + pTree->pFirst = pNew; + } + pTree->pLast = pNew; + pNew->pParent = pParent; + if( pParent ){ + if( pParent->pChild ){ + pParent->pLastChild->pSibling = pNew; + }else{ + pParent->pChild = pNew; + } + pNew->iLevel = pParent->iLevel + 1; + pParent->pLastChild = pNew; + }else{ + if( pTree->pLastTop ) pTree->pLastTop->pSibling = pNew; + pTree->pLastTop = pNew; + } + pNew->mtime = mtime; + pNew->iSize = size; + if( sortOrder ){ + pNew->sortBy = sortOrder==1 ? mtime : (double)size; + }else{ + pNew->sortBy = 0.0; + } + while( zPath[i]=='/' ){ i++; } + pParent = pNew; + } + while( pParent && pParent->pParent ){ + if( pParent->pParent->mtime < pParent->mtime ){ + pParent->pParent->mtime = pParent->mtime; + } + pParent = pParent->pParent; + } +} + +/* Comparison function for two FileTreeNode objects. Sort first by +** sortBy (larger numbers first) and then by zName (smaller names first). +** +** The sortBy field will be the same as mtime in order to sort by time, +** or the same as iSize to sort by file size. +** +** Return negative if pLeftpRight. +** Return zero if pLeft==pRight. +*/ +static int compareNodes(FileTreeNode *pLeft, FileTreeNode *pRight){ + if( pLeft->sortBy>pRight->sortBy ) return -1; + if( pLeft->sortBysortBy ) return +1; + return fossil_stricmp(pLeft->zName, pRight->zName); +} + +/* Merge together two sorted lists of FileTreeNode objects */ +static FileTreeNode *mergeNodes(FileTreeNode *pLeft, FileTreeNode *pRight){ + FileTreeNode *pEnd; + FileTreeNode base; + pEnd = &base; + while( pLeft && pRight ){ + if( compareNodes(pLeft,pRight)<=0 ){ + pEnd = pEnd->pSibling = pLeft; + pLeft = pLeft->pSibling; + }else{ + pEnd = pEnd->pSibling = pRight; + pRight = pRight->pSibling; + } + } + if( pLeft ){ + pEnd->pSibling = pLeft; + }else{ + pEnd->pSibling = pRight; + } + return base.pSibling; +} + +/* Sort a list of FileTreeNode objects in sortmtime order. */ +static FileTreeNode *sortNodes(FileTreeNode *p){ + FileTreeNode *a[30]; + FileTreeNode *pX; + int i; + + memset(a, 0, sizeof(a)); + while( p ){ + pX = p; + p = pX->pSibling; + pX->pSibling = 0; + for(i=0; ipSibling){ + if( pX->pChild ) pX->pChild = sortTree(pX->pChild); + } + return sortNodes(p); +} + +/* Reconstruct the FileTree by reconnecting the FileTreeNode.pNext +** fields in sequential order. +*/ +static void relinkTree(FileTree *pTree, FileTreeNode *pRoot){ + while( pRoot ){ + if( pTree->pLast ){ + pTree->pLast->pNext = pRoot; + }else{ + pTree->pFirst = pRoot; + } + pTree->pLast = pRoot; + if( pRoot->pChild ) relinkTree(pTree, pRoot->pChild); + pRoot = pRoot->pSibling; + } + if( pTree->pLast ) pTree->pLast->pNext = 0; +} + + +/* +** WEBPAGE: tree +** +** Show the files using a tree-view. If the ci= query parameter is present +** then show only the files for the check-in identified. If ci= is omitted, +** then show the union of files over all check-ins. +** +** The type=tree query parameter is required or else the /dir format is +** used. +** +** Query parameters: +** +** type=tree Required to prevent use of /dir format +** name=PATH Directory to display. Optional +** ci=LABEL Show only files in this check-in. Optional. +** re=REGEXP Show only files matching REGEXP. Optional. +** expand Begin with the tree fully expanded. +** nofiles Show directories (folders) only. Omit files. +** sort 0: by filename, 1: by mtime, 2: by size +*/ +void page_tree(void){ + char *zD = fossil_strdup(P("name")); + int nD = zD ? strlen(zD)+1 : 0; + const char *zCI = P("ci"); + int rid = 0; + char *zUuid = 0; + Blob dirname; + Manifest *pM = 0; + double rNow = 0; + char *zNow = 0; + int useMtime = atoi(PD("mtime","0")); + int sortOrder = atoi(PD("sort",useMtime?"1":"0")); + const char *zRE; /* the value for the re=REGEXP query parameter */ + const char *zObjType; /* "files" by default or "folders" for "nofiles" */ + char *zREx = ""; /* Extra parameters for path hyperlinks */ + ReCompiled *pRE = 0; /* Compiled regular expression */ + FileTreeNode *p; /* One line of the tree */ + FileTree sTree; /* The complete tree of files */ + HQuery sURI; /* Hyperlink */ + int startExpanded; /* True to start out with the tree expanded */ + int showDirOnly; /* Show directories only. Omit files */ + int nDir = 0; /* Number of directories. Used for ID attributes */ + char *zProjectName = db_get("project-name", 0); + int isSymbolicCI = 0; /* ci= is a symbolic name, not a hash prefix */ + int isBranchCI = 0; /* ci= refers to a branch name */ + char *zHeader = 0; + + if( zCI && strlen(zCI)==0 ){ zCI = 0; } + if( strcmp(PD("type","flat"),"flat")==0 ){ page_dir(); return; } + memset(&sTree, 0, sizeof(sTree)); + login_check_credentials(); + if( !g.perm.Read ){ login_needed(g.anon.Read); return; } + while( nD>1 && zD[nD-2]=='/' ){ zD[(--nD)-1] = 0; } + sqlite3_create_function(g.db, "pathelement", 2, SQLITE_UTF8, 0, + pathelementFunc, 0, 0); + url_initialize(&sURI, "tree"); + cgi_query_parameters_to_url(&sURI); + if( PB("nofiles") ){ + showDirOnly = 1; + }else{ + showDirOnly = 0; + } + style_adunit_config(ADUNIT_RIGHT_OK); + if( PB("expand") ){ + startExpanded = 1; + }else{ + startExpanded = 0; + } + + /* If a regular expression is specified, compile it */ + zRE = P("re"); + if( zRE ){ + fossil_re_compile(&pRE, zRE, 0); + zREx = mprintf("&re=%T", zRE); + } + cgi_check_for_malice(); + + /* If the name= parameter is an empty string, make it a NULL pointer */ + if( zD && strlen(zD)==0 ){ zD = 0; } + + /* If a specific check-in is requested, fetch and parse it. If the + ** specific check-in does not exist, clear zCI. zCI==0 will cause all + ** files from all check-ins to be displayed. + */ + if( zCI ){ + pM = manifest_get_by_name(zCI, &rid); + if( pM ){ + zUuid = db_text(0, "SELECT uuid FROM blob WHERE rid=%d", rid); + rNow = db_double(0.0, "SELECT mtime FROM event WHERE objid=%d", rid); + zNow = db_text("", "SELECT datetime(mtime,toLocal())" + " FROM event WHERE objid=%d", rid); + isSymbolicCI = (sqlite3_strnicmp(zUuid, zCI, strlen(zCI)) != 0); + isBranchCI = branch_includes_uuid(zCI, zUuid); + Th_StoreUnsafe("current_checkin", zCI); + }else{ + zCI = 0; + } + } + if( zCI==0 ){ + rNow = db_double(0.0, "SELECT max(mtime) FROM event"); + zNow = db_text("", "SELECT datetime(max(mtime),toLocal()) FROM event"); + } + + /* Generate the Branch list submenu */ + generate_branch_submenu_multichoice("ci", zCI); + + assert( isSymbolicCI==0 || (zCI!=0 && zCI[0]!=0) ); + if( zD==0 ){ + if( zCI ){ + zHeader = mprintf("Top-level Files of %s", zCI); + }else{ + zHeader = mprintf("All Top-level Files"); + } + }else{ + if( zCI ){ + zHeader = mprintf("Files in %s/ of %s", zD, zCI); + }else{ + zHeader = mprintf("All Files in %s/", zD); + } + } + style_header("%s", zHeader); + fossil_free(zHeader); + + /* Compute the title of the page */ + blob_zero(&dirname); + if( zD ){ + blob_append(&dirname, "within directory ", -1); + hyperlinked_path(zD, &dirname, zCI, "tree", zREx, 0); + if( zRE ) blob_appendf(&dirname, " matching \"%s\"", zRE); + style_submenu_element("Top-Level", "%s", + url_render(&sURI, "name", 0, 0, 0)); + }else if( zRE ){ + blob_appendf(&dirname, "matching \"%s\"", zRE); + } + { + static const char *const sort_orders[] = { + "0", "Sort By Filename", + "1", "Sort By Age", + "2", "Sort By Size" + }; + style_submenu_multichoice("sort", 3, sort_orders, 0); + } + if( zCI ){ + if( nD==0 && !showDirOnly ){ + style_submenu_element("File Ages", "%R/fileage?name=%T", zCI); + } + } + style_submenu_element("Flat-View", "%s", + url_render(&sURI, "type", "flat", 0, 0)); + if( rid && zD==0 && zRE==0 && !showDirOnly && g.perm.Zip ){ + style_submenu_element("Download","%R/rchvdwnld/%!S", zUuid); + } + + /* Compute the file hierarchy. + */ + if( zCI ){ + Stmt q; + compute_fileage(rid, 0); + db_prepare(&q, + "SELECT filename.name, blob.uuid, blob.size, fileage.mtime\n" + " FROM fileage, filename, blob\n" + " WHERE filename.fnid=fileage.fnid\n" + " AND blob.rid=fileage.fid\n" + " ORDER BY filename.name COLLATE uintnocase;" + ); + while( db_step(&q)==SQLITE_ROW ){ + const char *zFile = db_column_text(&q,0); + const char *zUuid = db_column_text(&q,1); + int size = db_column_int(&q,2); + double mtime = db_column_double(&q,3); + if( nD>0 && (fossil_strncmp(zFile, zD, nD-1)!=0 || zFile[nD-1]!='/') ){ + continue; + } + if( pRE && re_match(pRE, (const unsigned char*)zFile, -1)==0 ) continue; + tree_add_node(&sTree, zFile, zUuid, mtime, size, sortOrder); + } + db_finalize(&q); + }else{ + Stmt q; + db_prepare(&q, + "WITH mx(fnid,fid,mtime) AS (\n" + " SELECT fnid, fid, max(event.mtime)\n" + " FROM mlink, event\n" + " WHERE event.objid=mlink.mid\n" + " GROUP BY 1\n" + ")\n" + "SELECT\n" + " filename.name,\n" + " blob.uuid,\n" + " blob.size,\n" + " mx.mtime\n" + "FROM mx\n" + " LEFT JOIN filename ON filename.fnid=mx.fnid\n" + " LEFT JOIN blob ON blob.rid=mx.fid\n" + " ORDER BY 1 COLLATE uintnocase;"); + while( db_step(&q)==SQLITE_ROW ){ + const char *zName = db_column_text(&q, 0); + const char *zUuid = db_column_text(&q,1); + int size = db_column_int(&q,2); + double mtime = db_column_double(&q,3); + if( nD>0 && (fossil_strncmp(zName, zD, nD-1)!=0 || zName[nD-1]!='/') ){ + continue; + } + if( pRE && re_match(pRE, (const u8*)zName, -1)==0 ) continue; + tree_add_node(&sTree, zName, zUuid, mtime, size, sortOrder); + } + db_finalize(&q); + } + style_submenu_checkbox("nofiles", "Folders Only", 0, 0); + + if( showDirOnly ){ + zObjType = "Folders"; + }else{ + zObjType = "Files"; + } + + if( zCI && strcmp(zCI,"tip")==0 ){ + @

%s(zObjType) in the %z(href("%R/info?name=tip"))latest check-in + }else if( isBranchCI ){ + @

%s(zObjType) in the %z(href("%R/info?name=%T",zCI))latest check-in\ + @ for branch %z(href("%R/timeline?r=%T",zCI))%h(zCI) + if( blob_size(&dirname) ){ + @ and %s(blob_str(&dirname)) + } + }else if( zCI ){ + @

%s(zObjType) for check-in \ + @ %z(href("%R/info?name=%T",zCI))%h(zCI) + if( blob_size(&dirname) ){ + @ and %s(blob_str(&dirname)) + } + }else{ + int n = db_int(0, "SELECT count(*) FROM plink"); + @

%s(zObjType) from all %d(n) check-ins %s(blob_str(&dirname)) + } + if( sortOrder==1 ){ + @ sorted by modification time

+ }else if( sortOrder==2 ){ + @ sorted by size + }else{ + @ sorted by filename + } + + if( zNow ){ + @

File ages are expressed relative to the check-in time of + @ %z(href("%R/timeline?c=%t",zNow))%s(zNow).

+ } + + /* Generate tree of lists. + ** + ** Each file and directory is a list element:
  • . Files have class=file + ** and if the filename as the suffix "xyz" the file also has class=file-xyz. + ** Directories have class=dir. The directory specfied by the name= query + ** parameter (or the top-level directory if there is no name= query parameter) + ** adds class=subdir. + ** + ** The
  • element for directories also contains a sublist
      + ** for the contents of that directory. + */ + @
        + if( nD ){ + @
      • + }else{ + @
      • + } + @
        + @ %z(href("%s",url_render(&sURI,"name",0,0,0)))%h(zProjectName) + if( zNow ){ + @
        Last Change
        + @
        Size
        + } + @
        + @
          + if( sortOrder ){ + p = sortTree(sTree.pFirst); + memset(&sTree, 0, sizeof(sTree)); + relinkTree(&sTree, p); + } + for(p=sTree.pFirst, nDir=0; p; p=p->pNext){ + const char *zLastClass = p->pSibling==0 ? " last" : ""; + if( p->pChild ){ + const char *zSubdirClass = (int)(p->nFullName)==nD-1 ? " subdir" : ""; + @
        • + @ %z(href("%s",url_render(&sURI,"name",p->zFullName,0,0)))%h(p->zName) + if( p->mtime>0.0 ){ + char *zAge = human_readable_age(rNow - p->mtime); + @
          %s(zAge)
          + @
          + } + @
          + if( startExpanded || (int)(p->nFullName)<=nD ){ + @
            + }else{ + @ + } + } + } + @
          + @
      + builtin_request_js("tree.js"); + style_finish_page(); + + /* We could free memory used by sTree here if we needed to. But + ** the process is about to exit, so doing so would not really accomplish + ** anything useful. */ +} + +/* +** Return a CSS class name based on the given filename's extension. +** Result must be freed by the caller. +**/ +const char *fileext_class(const char *zFilename){ + char *zClass; + const char *zExt = strrchr(zFilename, '.'); + int isExt = zExt && zExt!=zFilename && zExt[1]; + int i; + for( i=1; isExt && zExt[i]; i++ ) isExt &= fossil_isalnum(zExt[i]); + if( isExt ){ + zClass = mprintf("file file-%s", zExt+1); + for( i=5; zClass[i]; i++ ) zClass[i] = fossil_tolower(zClass[i]); + }else{ + zClass = mprintf("file"); + } + return zClass; +} + +/* +** SQL used to compute the age of all files in check-in :ckin whose +** names match :glob +*/ +static const char zComputeFileAgeSetup[] = +@ CREATE TABLE IF NOT EXISTS temp.fileage( +@ fnid INTEGER PRIMARY KEY, +@ fid INTEGER, +@ mid INTEGER, +@ mtime DATETIME, +@ pathname TEXT, +@ uuid TEXT +@ ); +@ CREATE VIRTUAL TABLE IF NOT EXISTS temp.foci USING files_of_checkin; +; + +static const char zComputeFileAgeRun[] = +@ WITH RECURSIVE +@ ckin(x) AS (VALUES(:ckin) +@ UNION +@ SELECT plink.pid +@ FROM ckin, plink +@ WHERE plink.cid=ckin.x) +@ INSERT OR IGNORE INTO fileage(fnid, fid, mid, mtime, pathname, uuid) +@ SELECT filename.fnid, mlink.fid, mlink.mid, event.mtime, filename.name, +@ foci.uuid +@ FROM foci, filename, blob, mlink, event +@ WHERE foci.checkinID=:ckin +@ AND foci.filename GLOB :glob +@ AND filename.name=foci.filename +@ AND blob.uuid=foci.uuid +@ AND mlink.fid=blob.rid +@ AND mlink.fid!=mlink.pid +@ AND mlink.mid IN (SELECT x FROM ckin) +@ AND event.objid=mlink.mid +@ ORDER BY event.mtime ASC; +; + +/* +** Look at all file containing in the version "vid". Construct a +** temporary table named "fileage" that contains the file-id for each +** files, the pathname, the check-in where the file was added, and the +** mtime on that check-in. If zGlob and *zGlob then only files matching +** the given glob are computed. +*/ +int compute_fileage(int vid, const char* zGlob){ + Stmt q; + db_exec_sql(zComputeFileAgeSetup); + db_prepare(&q, zComputeFileAgeRun /*works-like:"constant"*/); + db_bind_int(&q, ":ckin", vid); + db_bind_text(&q, ":glob", zGlob && zGlob[0] ? zGlob : "*"); + db_exec(&q); + db_finalize(&q); + return 0; +} + +/* +** Render the number of days in rAge as a more human-readable time span. +** Different units (seconds, minutes, hours, days, months, years) are +** selected depending on the magnitude of rAge. +** +** The string returned is obtained from fossil_malloc() and should be +** freed by the caller. +*/ +char *human_readable_age(double rAge){ + if( rAge*86400.0<120 ){ + if( rAge*86400.0<1.0 ){ + return mprintf("current"); + }else{ + return mprintf("%d seconds", (int)(rAge*86400.0)); + } + }else if( rAge*1440.0<90 ){ + return mprintf("%.1f minutes", rAge*1440.0); + }else if( rAge*24.0<36 ){ + return mprintf("%.1f hours", rAge*24.0); + }else if( rAge<365.0 ){ + return mprintf("%.1f days", rAge); + }else{ + return mprintf("%.2f years", rAge/365.2425); + } +} + +/* +** COMMAND: test-fileage +** +** Usage: %fossil test-fileage CHECKIN +*/ +void test_fileage_cmd(void){ + int mid; + Stmt q; + const char *zGlob = find_option("glob",0,1); + db_find_and_open_repository(0,0); + verify_all_options(); + if( g.argc!=3 ) usage("CHECKIN"); + mid = name_to_typed_rid(g.argv[2],"ci"); + compute_fileage(mid, zGlob); + db_prepare(&q, + "SELECT fid, mid, julianday('now') - mtime, pathname" + " FROM fileage" + ); + while( db_step(&q)==SQLITE_ROW ){ + char *zAge = human_readable_age(db_column_double(&q,2)); + fossil_print("%8d %8d %16s %s\n", + db_column_int(&q,0), + db_column_int(&q,1), + zAge, + db_column_text(&q,3)); + fossil_free(zAge); + } + db_finalize(&q); +} + +/* +** WEBPAGE: fileage +** +** Show all files in a single check-in (identified by the name= query +** parameter) in order of increasing age. +** +** Parameters: +** name=VERSION Selects the check-in version (default=tip). +** glob=STRING Only shows files matching this glob pattern +** (e.g. *.c or *.txt). +** showid Show RID values for debugging +*/ +void fileage_page(void){ + int rid; + const char *zName; + const char *zGlob; + const char *zUuid; + const char *zNow; /* Time of check-in */ + int isBranchCI; /* name= is a branch name */ + int showId = PB("showid"); + Stmt q1, q2; + double baseTime; + login_check_credentials(); + if( !g.perm.Read ){ login_needed(g.anon.Read); return; } + zName = P("name"); + if( zName==0 ) zName = "tip"; + rid = symbolic_name_to_rid(zName, "ci"); + if( rid==0 ){ + fossil_fatal("not a valid check-in: %s", zName); + } + zUuid = db_text("", "SELECT uuid FROM blob WHERE rid=%d", rid); + isBranchCI = branch_includes_uuid(zName,zUuid); + baseTime = db_double(0.0,"SELECT mtime FROM event WHERE objid=%d", rid); + zNow = db_text("", "SELECT datetime(mtime,toLocal()) FROM event" + " WHERE objid=%d", rid); + style_submenu_element("Tree-View", "%R/tree?ci=%T&mtime=1&type=tree", zName); + + /* Generate the Branch list submenu */ + generate_branch_submenu_multichoice("name", zName); + + style_header("File Ages"); + zGlob = P("glob"); + cgi_check_for_malice(); + compute_fileage(rid,zGlob); + db_multi_exec("CREATE INDEX fileage_ix1 ON fileage(mid,pathname);"); + + if( fossil_strcmp(zName,"tip")==0 ){ + @

      Files in the %z(href("%R/info?name=tip"))latest check-in + }else if( isBranchCI ){ + @

      Files in the %z(href("%R/info?name=%T",zName))latest check-in + @ of branch %z(href("%R/timeline?r=%T",zName))%h(zName) + }else{ + @

      Files in check-in %z(href("%R/info?name=%T",zName))%h(zName) + } + if( zGlob && zGlob[0] ){ + @ that match "%h(zGlob)" + } + @ ordered by age

      + @ + @

      File ages are expressed relative to the check-in time of + @ %z(href("%R/timeline?c=%t",zNow))%s(zNow).

      + @ + @
      + @ + db_prepare(&q1, + "SELECT event.mtime, event.objid, blob.uuid,\n" + " coalesce(event.ecomment,event.comment),\n" + " coalesce(event.euser,event.user),\n" + " coalesce((SELECT value FROM tagxref\n" + " WHERE tagtype>0 AND tagid=%d\n" + " AND rid=event.objid),'trunk')\n" + " FROM event, blob\n" + " WHERE event.objid IN (SELECT mid FROM fileage)\n" + " AND blob.rid=event.objid\n" + " ORDER BY event.mtime DESC;", + TAG_BRANCH + ); + db_prepare(&q2, + "SELECT filename.name, fileage.fid\n" + " FROM fileage, filename\n" + " WHERE fileage.mid=:mid AND filename.fnid=fileage.fnid" + ); + while( db_step(&q1)==SQLITE_ROW ){ + double age = baseTime - db_column_double(&q1, 0); + int mid = db_column_int(&q1, 1); + const char *zUuid = db_column_text(&q1, 2); + const char *zComment = db_column_text(&q1, 3); + const char *zUser = db_column_text(&q1, 4); + const char *zBranch = db_column_text(&q1, 5); + char *zAge = human_readable_age(age); + @ + @ + @ + @ + fossil_free(zAge); + } + @
      AgeFilesCheck-in
      %s(zAge) + db_bind_int(&q2, ":mid", mid); + while( db_step(&q2)==SQLITE_ROW ){ + const char *zFile = db_column_text(&q2,0); + @ %z(href("%R/file?name=%T&ci=%!S",zFile,zUuid))%h(zFile) \ + if( showId ){ + int fid = db_column_int(&q2,1); + @ (%d(fid))
      + }else{ + @
      + } + } + db_reset(&q2); + @
      + @ %W(zComment) + @ (check-in: %z(href("%R/info/%!S",zUuid))%S(zUuid), + if( showId ){ + @ id: %d(mid) + } + @ user: %z(href("%R/timeline?u=%t&c=%!S&nd",zUser,zUuid))%h(zUser), + @ branch: \ + @ %z(href("%R/timeline?r=%t&c=%!S&nd",zBranch,zUuid))%h(zBranch)) + @
      + db_finalize(&q1); + db_finalize(&q2); + style_finish_page(); +} diff --git a/src/builtin.c b/src/builtin.c new file mode 100644 index 0000000..3826c68 --- /dev/null +++ b/src/builtin.c @@ -0,0 +1,949 @@ +/* +** Copyright (c) 2014 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 built-in string and BLOB resources packaged as +** byte arrays. +*/ +#include "config.h" +#include "builtin.h" +#include + +/* +** The resources provided by this file are packaged by the "mkbuiltin.c" +** utility program during the built process and stored in the +** builtin_data.h file. Include that information here: +*/ +#include "builtin_data.h" + +/* +** Return the index in the aBuiltinFiles[] array for the file +** whose name is zFilename. Or return -1 if the file is not +** found. +*/ +static int builtin_file_index(const char *zFilename){ + int lwr, upr, i, c; + lwr = 0; + upr = count(aBuiltinFiles) - 1; + while( upr>=lwr ){ + i = (upr+lwr)/2; + c = strcmp(aBuiltinFiles[i].zName,zFilename); + if( c<0 ){ + lwr = i+1; + }else if( c>0 ){ + upr = i-1; + }else{ + return i; + } + } + return -1; +} + +/* +** Return a pointer to built-in content +** +** If the filename contains "-vNNNNNNNN" just before the final file +** suffix, where each N is a random digit, then omit that part of the +** filename before doing the lookup. The extra -vNNNNNNNN was added +** to defeat overly aggressive caching by web browsers. There must be +** at least 8 digits in NNNNNNNN but more than 8 are allowed. +*/ +const unsigned char *builtin_file(const char *zFilename, int *piSize){ + int i = builtin_file_index(zFilename); + if( i>=0 ){ + if( piSize ) *piSize = aBuiltinFiles[i].nByte; + return aBuiltinFiles[i].pData; + }else{ + const char *zV = strstr(zFilename, "-v"); + if( zV!=0 ){ + for(i=0; fossil_isdigit(zV[i+2]); i++){} + if( i>=8 && zV[i+2]=='.' ){ + char *zNew = mprintf("%.*s%s", (int)(zV-zFilename), zFilename, zV+i+2); + const unsigned char *pRes = builtin_file(zNew, piSize); + fossil_free(zNew); + return pRes; + } + } + if( piSize ) *piSize = 0; + return 0; + } +} +const char *builtin_text(const char *zFilename){ + return (char*)builtin_file(zFilename, 0); +} + +/* +** COMMAND: test-builtin-list +** +** If -verbose is used, it outputs a line at the end +** with the total item count and size. +** +** List the names and sizes of all built-in resources. +*/ +void test_builtin_list(void){ + int i, size = 0;; + for(i=0; i + for(i=0; i%z(zUrl)%h(z) + } + @ + style_finish_page(); +} + +/* +** COMMAND: test-builtin-get +** +** Usage: %fossil test-builtin-get NAME ?OUTPUT-FILE? +*/ +void test_builtin_get(void){ + const unsigned char *pData; + int nByte; + Blob x; + if( g.argc!=3 && g.argc!=4 ){ + usage("NAME ?OUTPUT-FILE?"); + } + pData = builtin_file(g.argv[2], &nByte); + if( pData==0 ){ + fossil_fatal("no such built-in file: [%s]", g.argv[2]); + } + blob_init(&x, (const char*)pData, nByte); + blob_write_to_file(&x, g.argc==4 ? g.argv[3] : "-"); + blob_reset(&x); +} + +/* +** Input zList is a list of numeric identifiers for files in +** aBuiltinFiles[]. Return the concatenation of all of those files +** using mimetype zType, or as text/javascript if zType is 0. +*/ +static void builtin_deliver_multiple_js_files( + const char *zList, /* List of numeric identifiers */ + const char *zType /* Override mimetype */ +){ + Blob *pOut; + if( zType==0 ) zType = "text/javascript"; + cgi_set_content_type(zType); + pOut = cgi_output_blob(); + while( zList[0] ){ + int i = atoi(zList); + if( i>0 && i<=count(aBuiltinFiles) ){ + blob_appendf(pOut, "/* %s */\n", aBuiltinFiles[i-1].zName); + blob_append(pOut, (const char*)aBuiltinFiles[i-1].pData, + aBuiltinFiles[i-1].nByte); + } + while( zList[0] && fossil_isdigit(zList[0]) ) zList++; + while( zList[0] && !fossil_isdigit(zList[0]) ) zList++; + } + return; +} + +/* +** WEBPAGE: builtin loadavg-exempt +** +** Return one of many built-in content files. Query parameters: +** +** name=FILENAME Return the single file whose name is FILENAME. +** mimetype=TYPE Override the mimetype in the returned file to +** be TYPE. If this query parameter is omitted +** (the usual case) then the mimetype is inferred +** from the suffix on FILENAME +** m=IDLIST IDLIST is a comma-separated list of integers +** that specify multiple javascript files to be +** concatenated and returned all at once. +** id=UNIQUEID Version number of the "builtin" files. Used +** for cache control only. +** +** At least one of the name= or m= query parameters must be present. +** +** If the id= query parameter is present, then Fossil assumes that the +** result is immutable and sets a very large cache retention time (1 year). +*/ +void builtin_webpage(void){ + Blob out; + const char *zName = P("name"); + const char *zContent = 0; + int nContent = 0; + const char *zId = P("id"); + const char *zType = P("mimetype"); + int nId; + if( zName ) zContent = (const char *)builtin_file(zName, &nContent); + if( zContent==0 ){ + const char *zM = P("m"); + if( zM ){ + if( zId && (nId = (int)strlen(zId))>=8 + && strncmp(zId,fossil_exe_id(),nId)==0 + ){ + g.isConst = 1; + } + etag_check(0,0); + builtin_deliver_multiple_js_files(zM, zType); + return; + } + cgi_set_status(404, "Not Found"); + @ File "%h(zName)" not found + return; + } + if( zType==0 ){ + if( sqlite3_strglob("*.js", zName)==0 ){ + zType = "text/javascript"; + }else{ + zType = mimetype_from_name(zName); + } + } + cgi_set_content_type(zType); + if( zId + && (nId = (int)strlen(zId))>=8 + && strncmp(zId,fossil_exe_id(),nId)==0 + ){ + g.isConst = 1; + } + etag_check(0,0); + blob_init(&out, zContent, nContent); + cgi_set_content(&out); +} + +/* Variables controlling the JS cache. +*/ +static struct { + int aReq[30]; /* Indexes of all requested built-in JS files */ + int nReq; /* Number of slots in aReq[] currently used */ + int nSent; /* Number of slots in aReq[] fulfilled */ + int eDelivery; /* Delivery mechanism */ +} builtin; + +#if INTERFACE +/* Various delivery mechanisms. The 0 option is the default. +** MAINTENANCE NOTE: Review/update the builtin_set_js_delivery_mode() and +** builtin_get_js_delivery_mode_name() functions if values are changed/added. +*/ +#define JS_INLINE 0 /* inline, batched together at end of file */ +#define JS_SEPARATE 1 /* Separate HTTP request for each JS file */ +#define JS_BUNDLED 2 /* One HTTP request to load all JS files */ + /* concatenated together into a bundle */ +#endif /* INTERFACE */ + +/* +** The argument is a request to change the javascript delivery mode. +** The argument is a string which is a command-line option or CGI +** parameter. Try to match it against one of the delivery options +** and set things up accordingly. Throw an error if no match unless +** bSilent is true. +*/ +void builtin_set_js_delivery_mode(const char *zMode, int bSilent){ + if( zMode==0 ) return; + if( strcmp(zMode, "inline")==0 ){ + builtin.eDelivery = JS_INLINE; + }else + if( strcmp(zMode, "separate")==0 ){ + builtin.eDelivery = JS_SEPARATE; + }else + if( strcmp(zMode, "bundled")==0 ){ + builtin.eDelivery = JS_BUNDLED; + }else if( !bSilent ){ + fossil_fatal("unknown javascript delivery mode \"%s\" - should be" + " one of: inline separate bundled", zMode); + } +} + +/* +** Returns the current JS delivery mode: one of JS_INLINE, +** JS_SEPARATE, JS_BUNDLED. +*/ +int builtin_get_js_delivery_mode(void){ + return builtin.eDelivery; +} + +/* +** Returns the name of the current JS delivery mode for reuse with the --jsmode +** option, i.e. the other way around than builtin_set_js_delivery_mode(). +*/ +const char *builtin_get_js_delivery_mode_name(void){ + switch( builtin.eDelivery ){ + case JS_SEPARATE: { + return "separate"; + } + case JS_BUNDLED: { + return "bundled"; + } + case JS_INLINE: + /*FALLTHROUGH*/ + default: { + return "inline"; + } + } +} + +/* +** The caller wants the Javascript file named by zFilename to be +** included in the generated page. Add the file to the queue of +** requested javascript resources, if it is not there already. +** +** The current implementation queues the file to be included in the +** output later. However, the caller should not depend on that +** behavior. In the future, this routine might decide to insert +** the requested javascript inline, immedaitely, or to insert +** a \n"); + break; + } + case JS_BUNDLED: { + if( builtin.nSent+1\n", + blob_str(&aList)+1, fossil_exe_id()); + blob_reset(&aList); + break; + } + /* If there is only one JS file, fall through into the + ** JS_SEPARATE case below. */ + /*FALLTHROUGH*/ + } + case JS_SEPARATE: { + /* Each JS file as a separate resource */ + while( builtin.nSent\n", + aBuiltinFiles[i].zName, fossil_exe_id()); + } + break; + } + } +} + +/***************************************************************************** +** A virtual table for accessing the information in aBuiltinFiles[]. +*/ + +/* builtinVtab_vtab is a subclass of sqlite3_vtab which is +** underlying representation of the virtual table +*/ +typedef struct builtinVtab_vtab builtinVtab_vtab; +struct builtinVtab_vtab { + sqlite3_vtab base; /* Base class - must be first */ + /* Add new fields here, as necessary */ +}; + +/* builtinVtab_cursor is a subclass of sqlite3_vtab_cursor which will +** serve as the underlying representation of a cursor that scans +** over rows of the result +*/ +typedef struct builtinVtab_cursor builtinVtab_cursor; +struct builtinVtab_cursor { + sqlite3_vtab_cursor base; /* Base class - must be first */ + /* Insert new fields here. For this builtinVtab we only keep track + ** of the rowid */ + sqlite3_int64 iRowid; /* The rowid */ +}; + +/* +** The builtinVtabConnect() method is invoked to create a new +** builtin virtual table. +** +** Think of this routine as the constructor for builtinVtab_vtab objects. +** +** All this routine needs to do is: +** +** (1) Allocate the builtinVtab_vtab object and initialize all fields. +** +** (2) Tell SQLite (via the sqlite3_declare_vtab() interface) what the +** result set of queries against the virtual table will look like. +*/ +static int builtinVtabConnect( + sqlite3 *db, + void *pAux, + int argc, const char *const*argv, + sqlite3_vtab **ppVtab, + char **pzErr +){ + builtinVtab_vtab *pNew; + int rc; + + rc = sqlite3_declare_vtab(db, + "CREATE TABLE x(name,size,data)" + ); + if( rc==SQLITE_OK ){ + pNew = sqlite3_malloc( sizeof(*pNew) ); + *ppVtab = (sqlite3_vtab*)pNew; + if( pNew==0 ) return SQLITE_NOMEM; + memset(pNew, 0, sizeof(*pNew)); + } + return rc; +} + +/* +** This method is the destructor for builtinVtab_vtab objects. +*/ +static int builtinVtabDisconnect(sqlite3_vtab *pVtab){ + builtinVtab_vtab *p = (builtinVtab_vtab*)pVtab; + sqlite3_free(p); + return SQLITE_OK; +} + +/* +** Constructor for a new builtinVtab_cursor object. +*/ +static int builtinVtabOpen(sqlite3_vtab *p, sqlite3_vtab_cursor **ppCursor){ + builtinVtab_cursor *pCur; + pCur = sqlite3_malloc( sizeof(*pCur) ); + if( pCur==0 ) return SQLITE_NOMEM; + memset(pCur, 0, sizeof(*pCur)); + *ppCursor = &pCur->base; + return SQLITE_OK; +} + +/* +** Destructor for a builtinVtab_cursor. +*/ +static int builtinVtabClose(sqlite3_vtab_cursor *cur){ + builtinVtab_cursor *pCur = (builtinVtab_cursor*)cur; + sqlite3_free(pCur); + return SQLITE_OK; +} + + +/* +** Advance a builtinVtab_cursor to its next row of output. +*/ +static int builtinVtabNext(sqlite3_vtab_cursor *cur){ + builtinVtab_cursor *pCur = (builtinVtab_cursor*)cur; + pCur->iRowid++; + return SQLITE_OK; +} + +/* +** Return values of columns for the row at which the builtinVtab_cursor +** is currently pointing. +*/ +static int builtinVtabColumn( + sqlite3_vtab_cursor *cur, /* The cursor */ + sqlite3_context *ctx, /* First argument to sqlite3_result_...() */ + int i /* Which column to return */ +){ + builtinVtab_cursor *pCur = (builtinVtab_cursor*)cur; + const struct BuiltinFileTable *pFile = aBuiltinFiles + pCur->iRowid - 1; + switch( i ){ + case 0: /* name */ + sqlite3_result_text(ctx, pFile->zName, -1, SQLITE_STATIC); + break; + case 1: /* size */ + sqlite3_result_int(ctx, pFile->nByte); + break; + case 2: /* data */ + sqlite3_result_blob(ctx, pFile->pData, pFile->nByte, SQLITE_STATIC); + break; + } + return SQLITE_OK; +} + +/* +** Return the rowid for the current row. In this implementation, the +** rowid is the same as the output value. +*/ +static int builtinVtabRowid(sqlite3_vtab_cursor *cur, sqlite_int64 *pRowid){ + builtinVtab_cursor *pCur = (builtinVtab_cursor*)cur; + *pRowid = pCur->iRowid; + return SQLITE_OK; +} + +/* +** Return TRUE if the cursor has been moved off of the last +** row of output. +*/ +static int builtinVtabEof(sqlite3_vtab_cursor *cur){ + builtinVtab_cursor *pCur = (builtinVtab_cursor*)cur; + return pCur->iRowid>count(aBuiltinFiles); +} + +/* +** This method is called to "rewind" the builtinVtab_cursor object back +** to the first row of output. This method is always called at least +** once prior to any call to builtinVtabColumn() or builtinVtabRowid() or +** builtinVtabEof(). +*/ +static int builtinVtabFilter( + sqlite3_vtab_cursor *pVtabCursor, + int idxNum, const char *idxStr, + int argc, sqlite3_value **argv +){ + builtinVtab_cursor *pCur = (builtinVtab_cursor *)pVtabCursor; + pCur->iRowid = 1; + return SQLITE_OK; +} + +/* +** SQLite will invoke this method one or more times while planning a query +** that uses the virtual table. This routine needs to create +** a query plan for each invocation and compute an estimated cost for that +** plan. +*/ +static int builtinVtabBestIndex( + sqlite3_vtab *tab, + sqlite3_index_info *pIdxInfo +){ + pIdxInfo->estimatedCost = (double)count(aBuiltinFiles); + pIdxInfo->estimatedRows = count(aBuiltinFiles); + return SQLITE_OK; +} + +/* +** This following structure defines all the methods for the +** virtual table. +*/ +static sqlite3_module builtinVtabModule = { + /* iVersion */ 0, + /* xCreate */ 0, /* The builtin vtab is eponymous and read-only */ + /* xConnect */ builtinVtabConnect, + /* xBestIndex */ builtinVtabBestIndex, + /* xDisconnect */ builtinVtabDisconnect, + /* xDestroy */ 0, + /* xOpen */ builtinVtabOpen, + /* xClose */ builtinVtabClose, + /* xFilter */ builtinVtabFilter, + /* xNext */ builtinVtabNext, + /* xEof */ builtinVtabEof, + /* xColumn */ builtinVtabColumn, + /* xRowid */ builtinVtabRowid, + /* xUpdate */ 0, + /* xBegin */ 0, + /* xSync */ 0, + /* xCommit */ 0, + /* xRollback */ 0, + /* xFindMethod */ 0, + /* xRename */ 0, + /* xSavepoint */ 0, + /* xRelease */ 0, + /* xRollbackTo */ 0, + /* xShadowName */ 0, + /* xIntegrity */ 0 +}; + + +/* +** Register the builtin virtual table +*/ +int builtin_vtab_register(sqlite3 *db){ + int rc = sqlite3_create_module(db, "builtin", &builtinVtabModule, 0); + return rc; +} +/* End of the builtin virtual table +******************************************************************************/ + + +/* +** The first time this is called, it emits code to install and +** bootstrap the window.fossil object, using the built-in file +** fossil.bootstrap.js (not to be confused with bootstrap.js). +** +** Subsequent calls are no-ops. +** +** It emits 2 parts: +** +** 1) window.fossil core object, some of which depends on C-level +** runtime data. That part of the script is always emitted inline. If +** addScriptTag is true then it is wrapped in its own SCRIPT tag, else +** it is assumed that the caller already opened a tag. +** +** 2) Emits the static fossil.bootstrap.js using builtin_request_js(). +*/ +void builtin_emit_script_fossil_bootstrap(int addScriptTag){ + static int once = 0; + if(0==once++){ + char * zName; + /* Set up the generic/app-agnostic parts of window.fossil + ** which require C-level state... */ + if(addScriptTag!=0){ + style_script_begin(__FILE__,__LINE__); + } + CX("(function(){\n"); + CX(/*MSIE NodeList.forEach polyfill, courtesy of Mozilla: + https://developer.mozilla.org/en-US/docs/Web/API/NodeList/forEach#Polyfill + */ + "if(window.NodeList && !NodeList.prototype.forEach){" + "NodeList.prototype.forEach = Array.prototype.forEach;" + "}\n"); + CX("if(!window.fossil) window.fossil={};\n" + "window.fossil.version = %!j;\n" + /* fossil.rootPath is the top-most CGI/server path, + ** including a trailing slash. */ + "window.fossil.rootPath = %!j+'/';\n", + get_version(), g.zTop); + /* fossil.config = {...various config-level options...} */ + CX("window.fossil.config = {"); + zName = db_get("project-name", ""); + CX("projectName: %!j,\n", zName); + fossil_free(zName); + zName = db_get("short-project-name", ""); + CX("shortProjectName: %!j,\n", zName); + fossil_free(zName); + zName = db_get("project-code", ""); + CX("projectCode: %!j,\n", zName); + fossil_free(zName); + CX("/* Length of UUID hashes for display purposes. */"); + CX("hashDigits: %d, hashDigitsUrl: %d,\n", + hash_digits(0), hash_digits(1)); + CX("diffContextLines: %d,\n", + diff_context_lines(0)); + CX("editStateMarkers: {" + "/*Symbolic markers to denote certain edit states.*/" + "isNew:'[+]', isModified:'[*]', isDeleted:'[-]'},\n"); + CX("confirmerButtonTicks: 3 " + "/*default fossil.confirmer tick count.*/,\n"); + CX("attachmentSizeLimit: %d,\n", + db_get_int("attachment-size-limit",0)); + /* Inject certain info about the current skin... */ + CX("skin:{"); + /* can leak a local filesystem path: + CX("name: %!j,", skin_in_use());*/ + CX("isDark: %s" + "/*true if the current skin has the 'white-foreground' detail*/", + skin_detail_boolean("white-foreground") ? "true" : "false"); + CX("}\n"/*fossil.config.skin*/); + CX("};\n"/* fossil.config */); + if( forum_statuses()->n>1 ){ + const ForumStatusList * fsl = forum_statuses(); + int i; + CX("window.fossil.config.forumStatuses = ["); + for(i = 0; i < fsl->n; ++i){ + const ForumStatus *fs = &fsl->aStatus[i]; + if(i) CX(","); + CX("{label:%!j, value:%!j}", fs->zLabel, fs->zValue); + } + CX("];\n"); + } +#define JBOOL(COND) ((COND) ? "true" : "false") + CX("window.fossil.user = {"); + CX("name: %!j,", (g.zLogin&&*g.zLogin) ? g.zLogin : "guest"); + CX("isAdmin: %s,", JBOOL(g.perm.Admin || g.perm.Setup)); + CX("mayAttachForum: %s,", JBOOL(g.perm.AttachForum)); + CX("enableDebug: %s,", JBOOL(g.perm.Debug || g.perm.Admin)); + CX("isIndividual: %s", JBOOL(login_is_individual())); + CX("};\n"/*fossil.user*/); + { /* Workaround for cases like: + ** https://fossil-scm.org/forum/forumpost/31cc00b361496cb8 + ** Summary: custom skins which emit their own BODY need to have + ** the BODY element's CSS classes set so that various CSS selectors + ** and JS-side filters will work. */ + char *pSlash = strchr(g.zPath,'/')/*hack taken from style.c*/; + if( pSlash ) *pSlash = 0; + CX("for(const x of [%!j, \"rpage-%j\",\"cpage-%j\"]) {" + "if(x) document.body.classList.add(x);};\n", + style_get_current_feature(), g.zPath, g.zPhase+1); + if( pSlash ) *pSlash = '/'; + } + CX("if(fossil.config.skin.isDark) " + "document.body.classList.add('fossil-dark-style');\n"); + /* + ** fossil.page holds info about the current page. This is also + ** where the current page "should" store any of its own + ** page-specific state, and it is reserved for that purpose. + */ + CX("window.fossil.page = {" + "name:\"%T\"" + "};\n", g.zPath); + CX("})();\n"); + if(addScriptTag!=0){ + style_script_end(); + } + /* The remaining window.fossil bootstrap code is not dependent on + ** C-runtime state... */ + builtin_request_js("fossil.bootstrap.js"); + } +#undef JBOOL +} + +/* +** Given the NAME part of fossil.NAME.js, this function checks whether +** that module has been emitted by this function before. If it has, +** it returns -1 with no side effects. If it has not, it queues up +** (via builtin_request_js()) an emit of the module via and all of its +** known (by this function) fossil.XYZ.js dependencies (in their +** dependency order) and returns 1. If it does not find the given +** module name it returns 0. +** +** As a special case, if passed 0 then it queues up all known modules +** and returns -1. +** +** The very first time this is called, it unconditionally calls +** builtin_emit_script_fossil_bootstrap(). +** +** Any given module is only queued once, whether it is explicitly +** passed to the function or resolved as a dependency. Any attempts to +** re-queue them later are harmless no-ops. +*/ +static int builtin_emit_fossil_js_once(const char * zName){ + static int once = 0; + int i; + static struct FossilJs { + const char * zName; /* NAME part of fossil.NAME.js */ + int emitted; /* True if already emitted. */ + const char * zDeps; /* \0-delimited list of other FossilJs + ** entries: all known deps of this one. Each + ** REQUIRES an EXPLICIT trailing \0, including + ** the final one! */ + } fjs[] = { + /* This list ordering isn't strictly important. */ + {"attach", 0, "dom\0"}, + {"confirmer", 0, 0}, + {"copybutton", 0, "dom\0"}, + {"diff", 0, "dom\0fetch\0storage\0" + /* maintenance note: "diff" needs "storage" for storing the + ** sbs-sync-scroll toggle. */}, + {"dom", 0, 0}, + {"fetch", 0, 0}, + {"numbered-lines", 0, "popupwidget\0copybutton\0"}, + {"pikchr", 0, "dom\0"}, + {"popupwidget", 0, "dom\0"}, + {"storage", 0, 0}, + {"tabs", 0, "dom\0"} + }; + const int nFjs = sizeof(fjs) / sizeof(fjs[0]); + if(0==once){ + ++once; + builtin_emit_script_fossil_bootstrap(1); + } + if(0==zName){ + for( i = 0; i < nFjs; ++i ){ + builtin_emit_fossil_js_once(fjs[i].zName); + } + return -1; + } + for( i = 0; i < nFjs; ++i ){ + if(0==strcmp(zName, fjs[i].zName)){ + if(fjs[i].emitted){ + return -1; + }else{ + char nameBuffer[50]; + if(fjs[i].zDeps){ + const char * zDep = fjs[i].zDeps; + while(*zDep!=0){ + builtin_emit_fossil_js_once(zDep); + zDep += strlen(zDep)+1/*NUL delimiter*/; + } + } + sqlite3_snprintf(sizeof(nameBuffer)-1, nameBuffer, + "fossil.%s.js", fjs[i].zName); + builtin_request_js(nameBuffer); + fjs[i].emitted = 1; + return 1; + } + } + } + return 0; +} + +/* +** COMMAND: test-js-once +** +** Tester for builtin_emit_fossil_js_once(). +** +** Usage: %fossil test-js-once filename +*/ +void test_js_once(void){ + int i; + if(g.argc<2){ + usage("?FILENAME...?"); + } + if(2==g.argc){ + builtin_emit_fossil_js_once(0); + assert(builtin.nReq>8); + }else{ + for(i = 2; i < g.argc; ++i){ + builtin_emit_fossil_js_once(g.argv[i]); + } + assert(builtin.nReq>1 && "don't forget implicit fossil.bootstrap.js"); + } + for(i = 0; i < builtin.nReq; ++i){ + fossil_print("ndx#%d = %d = %s\n", i, builtin.aReq[i], + aBuiltinFiles[builtin.aReq[i]].zName); + } +} + +/* +** Convenience wrapper which calls builtin_request_js() for a series +** of builtin scripts named fossil.NAME.js. The first time it is +** called, it also calls builtin_emit_script_fossil_bootstrap() to +** initialize the window.fossil JS API. The first argument is the NAME +** part of the first API to emit. All subsequent arguments must be +** strings of the NAME part of additional fossil.NAME.js files, +** followed by a NULL argument to terminate the list. +** +** e.g. pass it ("fetch", "dom", "tabs", NULL) to load those 3 APIs (or +** pass it ("fetch","tabs",NULL), as "dom" is a dependency of "tabs", so +** it will be automatically loaded). Do not forget the trailing NULL, +** and do not pass 0 instead, since that isn't always equivalent to NULL +** in this context. +** +** If it is JS_BUNDLED then this routine queues up an emit of ALL of +** the JS fossil.XYZ.js APIs which are not strictly specific to a +** single page, and then calls builtin_fulfill_js_requests(). The idea +** is that we can get better bundle caching and reduced HTTP requests +** by including all JS, rather than creating separate bundles on a +** per-page basis. In this case, all arguments are ignored! +** +** This function has an internal mapping of the dependencies for each +** of the known fossil.XYZ.js modules and ensures that the +** dependencies also get queued (recursively) and that each module is +** queued only once. +** +** If passed a name which is not a base fossil module name then it +** will fail fatally! +** +** DO NOT use this for loading fossil.page.*.js: use +** builtin_request_js() for those. +** +** If the current JS delivery mode is *not* JS_BUNDLED then this +** function queues up a request for each given module and its known +** dependencies, but does not immediately fulfill the request, thus it +** can be called multiple times. +** +** If a given module is ever passed to this more than once, either in +** a single invocation or multiples, it is only queued for emit a +** single time (i.e. the 2nd and subsequent ones become +** no-ops). Likewise, if a module is requested but was already +** automatically queued to fulfill a dependency, the explicit request +** becomes a no-op. +** +** Bundled mode is the only mode in which this API greatly improves +** aggregate over-the-wire and HTTP request costs. For other modes, +** reducing the inclusion of fossil.XYZ APIs to their bare minimum +** provides the lowest aggregate costs. For debate and details, see +** the discussion at: +** +** https://fossil-scm.org/forum/forumpost/3fa2633f3e +** +** In practice it is normally necessary (or preferred) to call +** builtin_fulfill_js_requests() after calling this, before proceeding +** to call builtin_request_js() for page-specific JS, in order to +** improve cachability. +** +** Minor caveat: the purpose of emitting all of the fossil.XYZ JS APIs +** at once is to reduce over-the-wire transfers by enabling cross-page +** caching, but if there are other JS scripts pending via +** builtin_request_js() when this is called then they will be included +** in the JS request emitted by this routine, resulting in a different +** script URL than if they were not included. Thus, if a given page +** has its own scripts to install via builtin_request_js(), they +** should, if possible, be delayed until after this is called OR the +** page should call builtin_fulfill_js_requests() to flush the request +** queue before calling this routine. +** +** Achtung: the fossil.page.XYZ.js files are page-specific, containing +** the app-level logic for that specific page, and loading more than +** one of them in a single page will break that page. Each of those +** expects to "own" the page it is loaded in, and it should be loaded +** as late in the JS-loading process as feasible, ideally bundled (via +** builtin_request_js()) with any other app-/page-specific JS it may +** need. +** +** Example usage: +** +** builtin_fossil_js_bundle_or("dom", "fetch", NULL); +** +** In bundled mode, that will (the first time it is called) emit all +** builtin fossil JS APIs and "fulfill" the queue immediately. In +** non-bundled mode it will queue up the "dom" and "fetch" APIs to be +** emitted the next time builtin_fulfill_js_requests() is called. +*/ +NULL_SENTINEL void builtin_fossil_js_bundle_or( const char * zApi, ... ) { + static int bundled = 0; + const char *zArg; + va_list vargs; + + if(JS_BUNDLED == builtin_get_js_delivery_mode()){ + if(!bundled){ + bundled = 1; + builtin_emit_fossil_js_once(0); + builtin_fulfill_js_requests(); + } + return; + } + va_start(vargs,zApi); + for( zArg = zApi; zArg!=NULL; (zArg = va_arg (vargs, const char *))){ + if(0==builtin_emit_fossil_js_once(zArg)){ + fossil_fatal("Unknown fossil JS module: %s\n", zArg); + } + } + va_end(vargs); +} diff --git a/src/bundle.c b/src/bundle.c new file mode 100644 index 0000000..440f12e --- /dev/null +++ b/src/bundle.c @@ -0,0 +1,799 @@ +/* +** Copyright (c) 2014 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 used to implement and manage a "bundle" file. +*/ +#include "config.h" +#include "bundle.h" +#include + +/* +** SQL code used to initialize the schema of a bundle. +** +** The bblob.delta field can be an integer, a text string, or NULL. +** If an integer, then the corresponding blobid is the delta basis. +** If a text string, then that string is a SHA1 hash for the delta +** basis, which is presumably in the main repository. If NULL, then +** data contains content without delta compression. +*/ +static const char zBundleInit[] = +@ CREATE TABLE IF NOT EXISTS "%w".bconfig( +@ bcname TEXT, +@ bcvalue ANY +@ ); +@ CREATE TABLE IF NOT EXISTS "%w".bblob( +@ blobid INTEGER PRIMARY KEY, -- Blob ID +@ uuid TEXT NOT NULL, -- hash of expanded blob +@ sz INT NOT NULL, -- Size of blob after expansion +@ delta ANY, -- Delta compression basis, or NULL +@ notes TEXT, -- Description of content +@ data BLOB -- compressed content +@ ); +; + +/* +** Attach a bundle file to the current database connection using the +** attachment name zBName. +*/ +static void bundle_attach_file( + const char *zFile, /* Name of the file that contains the bundle */ + const char *zBName, /* Attachment name */ + int doInit /* Initialize a new bundle, if true */ +){ + int rc; + char *zErrMsg = 0; + char *zSql; + if( !doInit && file_size(zFile, ExtFILE)<0 ){ + fossil_fatal("no such file: %s", zFile); + } + assert( g.db ); + zSql = sqlite3_mprintf("ATTACH %Q AS %Q", zFile, zBName); + if( zSql==0 ) fossil_fatal("out of memory"); + rc = sqlite3_exec(g.db, zSql, 0, 0, &zErrMsg); + sqlite3_free(zSql); + if( rc!=SQLITE_OK || zErrMsg ){ + if( zErrMsg==0 ) zErrMsg = (char*)sqlite3_errmsg(g.db); + fossil_fatal("not a valid bundle: %s", zFile); + } + if( doInit ){ + db_multi_exec(zBundleInit /*works-like:"%w%w"*/, zBName, zBName); + }else{ + sqlite3_stmt *pStmt; + zSql = sqlite3_mprintf("SELECT bcname, bcvalue" + " FROM \"%w\".bconfig", zBName); + if( zSql==0 ) fossil_fatal("out of memory"); + rc = sqlite3_prepare(g.db, zSql, -1, &pStmt, 0); + if( rc ) fossil_fatal("not a valid bundle: %s", zFile); + sqlite3_free(zSql); + sqlite3_finalize(pStmt); + zSql = sqlite3_mprintf("SELECT blobid, uuid, sz, delta, notes, data" + " FROM \"%w\".bblob", zBName); + if( zSql==0 ) fossil_fatal("out of memory"); + rc = sqlite3_prepare(g.db, zSql, -1, &pStmt, 0); + if( rc ) fossil_fatal("not a valid bundle: %s", zFile); + sqlite3_free(zSql); + sqlite3_finalize(pStmt); + } +} + +/* +** fossil bundle ls BUNDLE ?OPTIONS? +** +** Display the content of a bundle in human-readable form. +*/ +static void bundle_ls_cmd(void){ + Stmt q; + sqlite3_int64 sumSz = 0; + sqlite3_int64 sumLen = 0; + int bDetails = find_option("details","l",0)!=0; + verify_all_options(); + if( g.argc!=4 ) usage("ls BUNDLE ?OPTIONS?"); + bundle_attach_file(g.argv[3], "b1", 0); + db_prepare(&q, + "SELECT bcname, bcvalue FROM bconfig" + " WHERE typeof(bcvalue)='text'" + " AND bcvalue NOT GLOB char(0x2a,0x0a,0x2a);" + ); + while( db_step(&q)==SQLITE_ROW ){ + fossil_print("%s: %s\n", db_column_text(&q,0), db_column_text(&q,1)); + } + db_finalize(&q); + fossil_print("%.78c\n",'-'); + if( bDetails ){ + db_prepare(&q, + "SELECT blobid, substr(uuid,1,10), coalesce(substr(delta,1,10),'')," + " sz, octet_length(data), notes" + " FROM bblob" + ); + while( db_step(&q)==SQLITE_ROW ){ + fossil_print("%4d %10s %10s %8d %8d %s\n", + db_column_int(&q,0), + db_column_text(&q,1), + db_column_text(&q,2), + db_column_int(&q,3), + db_column_int(&q,4), + db_column_text(&q,5)); + sumSz += db_column_int(&q,3); + sumLen += db_column_int(&q,4); + } + db_finalize(&q); + fossil_print("%27s %8lld %8lld\n", "Total:", sumSz, sumLen); + }else{ + db_prepare(&q, + "SELECT substr(uuid,1,16), notes FROM bblob" + ); + while( db_step(&q)==SQLITE_ROW ){ + fossil_print("%16s %s\n", + db_column_text(&q,0), + db_column_text(&q,1)); + } + db_finalize(&q); + } +} + +/* +** Implement the "fossil bundle append BUNDLE FILE..." command. Add +** the named files into the BUNDLE. Create the BUNDLE if it does not +** already exist. +*/ +static void bundle_append_cmd(void){ + Blob content, hash; + int i; + Stmt q; + + verify_all_options(); + bundle_attach_file(g.argv[3], "b1", 1); + db_prepare(&q, + "INSERT INTO bblob(blobid, uuid, sz, delta, data, notes) " + "VALUES(NULL, $uuid, $sz, NULL, $data, $filename)"); + db_begin_transaction(); + for(i=4; i0 ){ + double endTime = db_double(0.0, "SELECT mtime FROM event WHERE objid=%d", + endRid); + blob_appendf(&sql, + " AND child.rid!=%d" + " AND (SELECT mtime FROM event WHERE objid=plink.cid)<=%.17g", + endRid, endTime + ); + } + if( zBr ){ + blob_appendf(&sql, + " AND EXISTS(SELECT 1 FROM tagxref" + " WHERE tagid=%d AND tagtype>0" + " AND value=%Q and rid=plink.cid)", + TAG_BRANCH, zBr); + } + blob_appendf(&sql, ") INSERT OR IGNORE INTO \"%w\" SELECT rid FROM child;", + zTab); + db_multi_exec("%s", blob_str(&sql)/*safe-for-%s*/); + } +} + +/* +** COMMAND: test-subtree +** +** Usage: %fossil test-subtree ?OPTIONS? +** +** Show the subset of check-ins that match the supplied options. This +** command is used to test the subtree_from_options() subroutine in the +** implementation and does not really have any other practical use that +** we know of. +** +** Options: +** --branch BRANCH Include only check-ins on BRANCH +** --from TAG Start the subtree at TAG +** --to TAG End the subtree at TAG +** --checkin TAG The subtree is the single check-in TAG +** --all Include FILE and TAG artifacts +** --exclusive Include FILES exclusively on check-ins +*/ +void test_subtree_cmd(void){ + int bAll = find_option("all",0,0)!=0; + int bExcl = find_option("exclusive",0,0)!=0; + db_find_and_open_repository(0,0); + db_begin_transaction(); + db_multi_exec("CREATE TEMP TABLE tobundle(rid INTEGER PRIMARY KEY);"); + subtree_from_arguments("tobundle"); + verify_all_options(); + if( bAll ) find_checkin_associates("tobundle",bExcl); + describe_artifacts_to_stdout("IN tobundle", 0); + db_end_transaction(1); +} + +/* fossil bundle export BUNDLE ?OPTIONS? +** +** OPTIONS: +** --branch BRANCH --from TAG --to TAG +** --checkin TAG +** --standalone +*/ +static void bundle_export_cmd(void){ + int bStandalone = find_option("standalone",0,0)!=0; + int mnToBundle; /* Minimum RID in the bundle */ + Stmt q; + + /* Decode the arguments (like --branch) that specify which artifacts + ** should be in the bundle */ + db_multi_exec("CREATE TEMP TABLE tobundle(rid INTEGER PRIMARY KEY);"); + subtree_from_arguments("tobundle"); + find_checkin_associates("tobundle", 0); + verify_all_options(); + describe_artifacts("IN tobundle"); + + if( g.argc!=4 ) usage("export BUNDLE ?OPTIONS?"); + /* Create the new bundle */ + bundle_attach_file(g.argv[3], "b1", 1); + db_begin_transaction(); + + /* Add 'mtime' and 'project-code' entries to the bconfig table */ + db_multi_exec( + "INSERT INTO bconfig(bcname,bcvalue)" + " VALUES('mtime',datetime('now'));" + ); + db_multi_exec( + "INSERT INTO bconfig(bcname,bcvalue)" + " SELECT name, value FROM config" + " WHERE name IN ('project-code','parent-project-code');" + ); + + /* Directly copy content from the repository into the bundle as long + ** as the repository content is a delta from some other artifact that + ** is also in the bundle. + */ + db_multi_exec( + "REPLACE INTO bblob(blobid,uuid,sz,delta,data,notes) " + " SELECT" + " tobundle.rid," + " blob.uuid," + " blob.size," + " delta.srcid," + " blob.content," + " (SELECT summary FROM description WHERE rid=blob.rid)" + " FROM tobundle, blob, delta" + " WHERE blob.rid=tobundle.rid" + " AND delta.rid=tobundle.rid" + " AND delta.srcid IN tobundle;" + ); + + /* For all the remaining artifacts, we need to construct their deltas + ** manually. + */ + mnToBundle = db_int(0,"SELECT min(rid) FROM tobundle"); + db_prepare(&q, + "SELECT rid FROM tobundle" + " WHERE rid NOT IN (SELECT blobid FROM bblob)" + " ORDER BY +rid;" + ); + while( db_step(&q)==SQLITE_ROW ){ + Blob content; + int rid = db_column_int(&q,0); + int deltaFrom = 0; + + /* Get the raw, uncompressed content of the artifact into content */ + content_get(rid, &content); + + /* Try to find another artifact, not within the bundle, that is a + ** plausible candidate for being a delta basis for the content. Set + ** deltaFrom to the RID of that other artifact. Leave deltaFrom set + ** to zero if the content should not be delta-compressed + */ + if( !bStandalone ){ + if( db_exists("SELECT 1 FROM plink WHERE cid=%d",rid) ){ + deltaFrom = db_int(0, + "SELECT max(cid) FROM plink" + " WHERE cid<%d", mnToBundle); + }else{ + deltaFrom = db_int(0, + "SELECT max(fid) FROM mlink" + " WHERE fnid=(SELECT fnid FROM mlink WHERE fid=%d)" + " AND fid<%d", rid, mnToBundle); + } + } + + /* Try to insert the artifact as a delta + */ + if( deltaFrom ){ + Blob basis, delta; + content_get(deltaFrom, &basis); + blob_delta_create(&basis, &content, &delta); + if( blob_size(&delta)>0.9*blob_size(&content) ){ + deltaFrom = 0; + }else{ + Stmt ins; + blob_compress(&delta, &delta); + db_prepare(&ins, + "REPLACE INTO bblob(blobid,uuid,sz,delta,data,notes)" + " SELECT %d, uuid, size, (SELECT uuid FROM blob WHERE rid=%d)," + " :delta, (SELECT summary FROM description WHERE rid=blob.rid)" + " FROM blob WHERE rid=%d", rid, deltaFrom, rid); + db_bind_blob(&ins, ":delta", &delta); + db_step(&ins); + db_finalize(&ins); + } + blob_reset(&basis); + blob_reset(&delta); + } + + /* If unable to insert the artifact as a delta, insert full-text */ + if( deltaFrom==0 ){ + Stmt ins; + blob_compress(&content, &content); + db_prepare(&ins, + "REPLACE INTO bblob(blobid,uuid,sz,delta,data,notes)" + " SELECT rid, uuid, size, NULL, :content," + " (SELECT summary FROM description WHERE rid=blob.rid)" + " FROM blob WHERE rid=%d", rid); + db_bind_blob(&ins, ":content", &content); + db_step(&ins); + db_finalize(&ins); + } + blob_reset(&content); + } + db_finalize(&q); + + db_end_transaction(0); +} + + +/* +** There is a TEMP table bix(blobid,delta) containing a set of purgeitems +** that need to be transferred to the BLOB table. This routine does +** all items that have srcid=iSrc. The pBasis blob holds the content +** of the source document if iSrc>0. +*/ +static void bundle_import_elements(int iSrc, Blob *pBasis, int isPriv){ + Stmt q; + static Bag busy; + assert( pBasis!=0 || iSrc==0 ); + if( iSrc>0 ){ + if( bag_find(&busy, iSrc) ){ + fossil_fatal("delta loop while uncompressing bundle artifacts"); + } + bag_insert(&busy, iSrc); + } + db_prepare(&q, + "SELECT uuid, data, bblob.delta, bix.blobid" + " FROM bix, bblob" + " WHERE bix.delta=%d" + " AND bix.blobid=bblob.blobid;", + iSrc + ); + while( db_step(&q)==SQLITE_ROW ){ + Blob h1, c1, c2; + int rid; + blob_zero(&h1); + db_column_blob(&q, 0, &h1); + blob_zero(&c1); + db_column_blob(&q, 1, &c1); + blob_uncompress(&c1, &c1); + blob_zero(&c2); + if( db_column_type(&q,2)==SQLITE_TEXT && db_column_bytes(&q,2)>=HNAME_MIN ){ + Blob basis; + rid = db_int(0,"SELECT rid FROM blob WHERE uuid=%Q", + db_column_text(&q,2)); + content_get(rid, &basis); + blob_delta_apply(&basis, &c1, &c2); + blob_reset(&basis); + blob_reset(&c1); + }else if( pBasis ){ + blob_delta_apply(pBasis, &c1, &c2); + blob_reset(&c1); + }else{ + c2 = c1; + } + if( hname_verify_hash(&c2, blob_buffer(&h1), blob_size(&h1))==0 ){ + fossil_fatal("artifact hash error on %b", &h1); + } + rid = content_put_ex(&c2, blob_str(&h1), 0, 0, isPriv); + if( rid==0 ){ + fossil_fatal("%s", g.zErrMsg); + }else{ + if( !isPriv ) content_make_public(rid); + content_get(rid, &c1); + manifest_crosslink(rid, &c1, MC_NO_ERRORS); + db_multi_exec("INSERT INTO got(rid) VALUES(%d)",rid); + } + bundle_import_elements(db_column_int(&q,3), &c2, isPriv); + blob_reset(&c2); + } + db_finalize(&q); + if( iSrc>0 ) bag_remove(&busy, iSrc); +} + +/* +** Extract an item from content from the bundle +*/ +static void bundle_extract_item( + int blobid, /* ID of the item to extract */ + Blob *pOut /* Write the content into this blob */ +){ + Stmt q; + Blob x, basis, h1; + static Bag busy; + + db_prepare(&q, "SELECT uuid, delta, data FROM bblob" + " WHERE blobid=%d", blobid); + if( db_step(&q)!=SQLITE_ROW ){ + db_finalize(&q); + fossil_fatal("no such item: %d", blobid); + } + if( bag_find(&busy, blobid) ) fossil_fatal("delta loop"); + blob_zero(&x); + db_column_blob(&q, 2, &x); + blob_uncompress(&x, &x); + if( db_column_type(&q,1)==SQLITE_INTEGER ){ + bundle_extract_item(db_column_int(&q,1), &basis); + blob_delta_apply(&basis, &x, pOut); + blob_reset(&basis); + blob_reset(&x); + }else if( db_column_type(&q,1)==SQLITE_TEXT ){ + int rid = db_int(0, "SELECT rid FROM blob WHERE uuid=%Q", + db_column_text(&q,1)); + if( rid==0 ){ + fossil_fatal("cannot find delta basis %s", db_column_text(&q,1)); + } + content_get(rid, &basis); + db_column_blob(&q, 2, &x); + blob_delta_apply(&basis, &x, pOut); + blob_reset(&basis); + blob_reset(&x); + }else{ + *pOut = x; + } + blob_zero(&h1); + db_column_blob(&q, 0, &h1); + if( hname_verify_hash(pOut, blob_buffer(&h1), blob_size(&h1))==0 ){ + fossil_fatal("incorrect hash for artifact %b", &h1); + } + blob_reset(&h1); + bag_remove(&busy, blobid); + db_finalize(&q); +} + +/* fossil bundle cat BUNDLE HASH... +** +** Write elements of a bundle on standard output +*/ +static void bundle_cat_cmd(void){ + int i; + Blob x; + verify_all_options(); + if( g.argc<5 ) usage("cat BUNDLE HASH..."); + bundle_attach_file(g.argv[3], "b1", 0); + blob_zero(&x); + for(i=4; i=%d" + " AND NOT EXISTS(SELECT 1 FROM blob WHERE uuid=bblob.delta)", + HNAME_MIN); + if( zMissingDeltas && zMissingDeltas[0] ){ + fossil_fatal("delta basis artifacts not found in repository: %s", + zMissingDeltas); + } + + db_begin_transaction(); + db_multi_exec( + "CREATE TEMP TABLE bix(" + " blobid INTEGER PRIMARY KEY," + " delta INTEGER" + ");" + "CREATE INDEX bixdelta ON bix(delta);" + "INSERT INTO bix(blobid,delta)" + " SELECT blobid," + " CASE WHEN typeof(delta)=='integer'" + " THEN delta ELSE 0 END" + " FROM bblob" + " WHERE NOT EXISTS(SELECT 1 FROM blob WHERE uuid=bblob.uuid AND size>=0);" + "CREATE TEMP TABLE got(rid INTEGER PRIMARY KEY ON CONFLICT IGNORE);" + ); + manifest_crosslink_begin(); + bundle_import_elements(0, 0, isPriv); + manifest_crosslink_end(0); + describe_artifacts_to_stdout("IN got", "Imported content:"); + db_end_transaction(0); +} + +/* fossil bundle purge BUNDLE +** +** Try to undo a prior "bundle import BUNDLE". +** +** If the --force option is omitted, then this will only work if +** there have been no check-ins or tags added that use the import. +** +** This routine never removes content that is not already in the bundle +** so the bundle serves as a backup. The purge can be undone using +** "fossil bundle import BUNDLE". +*/ +static void bundle_purge_cmd(void){ + int bForce = find_option("force",0,0)!=0; + int bTest = find_option("test",0,0)!=0; /* Undocumented --test option */ + const char *zFile = g.argv[3]; + verify_all_options(); + if ( g.argc!=4 ) usage("purge BUNDLE ?OPTIONS?"); + bundle_attach_file(zFile, "b1", 0); + db_begin_transaction(); + + /* Find all check-ins of the bundle */ + db_multi_exec( + "CREATE TEMP TABLE ok(rid INTEGER PRIMARY KEY);" + "INSERT OR IGNORE INTO ok SELECT blob.rid FROM bblob, blob, plink" + " WHERE bblob.uuid=blob.uuid" + " AND plink.cid=blob.rid;" + ); + + /* Check to see if new check-ins have been committed to check-ins in + ** the bundle. Do not allow the purge if that is true and if --force + ** is omitted. + */ + if( !bForce ){ + Stmt q; + int n = 0; + db_prepare(&q, + "SELECT cid FROM plink WHERE pid IN ok AND cid NOT IN ok" + ); + while( db_step(&q)==SQLITE_ROW ){ + whatis_rid(db_column_int(&q,0),0,0); + fossil_print("%.78c\n", '-'); + n++; + } + db_finalize(&q); + if( n>0 ){ + fossil_fatal("check-ins above are derived from check-ins in the bundle."); + } + } + + /* Find all files associated with those check-ins that are used + ** nowhere else. */ + find_checkin_associates("ok", 1); + + /* Check to see if any associated files are not in the bundle. Issue + ** an error if there are any, unless --force is used. + */ + if( !bForce ){ + db_multi_exec( + "CREATE TEMP TABLE err1(rid INTEGER PRIMARY KEY);" + "INSERT INTO err1 " + " SELECT blob.rid FROM ok CROSS JOIN blob" + " WHERE blob.rid=ok.rid" + " AND blob.uuid NOT IN (SELECT uuid FROM bblob);" + ); + if( db_changes() ){ + describe_artifacts_to_stdout("IN err1", 0); + fossil_fatal("artifacts above associated with bundle check-ins " + " are not in the bundle"); + }else{ + db_multi_exec("DROP TABLE err1;"); + } + } + + if( bTest ){ + describe_artifacts_to_stdout( + "IN (SELECT blob.rid FROM ok, blob, bblob" + " WHERE blob.rid=ok.rid AND blob.uuid=bblob.uuid)", + "Purged artifacts found in the bundle:"); + describe_artifacts_to_stdout( + "IN (SELECT blob.rid FROM ok, blob" + " WHERE blob.rid=ok.rid " + " AND blob.uuid NOT IN (SELECT uuid FROM bblob))", + "Purged artifacts NOT in the bundle:"); + describe_artifacts_to_stdout( + "IN (SELECT blob.rid FROM bblob, blob" + " WHERE blob.uuid=bblob.uuid " + " AND blob.rid NOT IN ok)", + "Artifacts in the bundle but not purged:"); + }else{ + purge_artifact_list("ok",0,0); + } + db_end_transaction(0); +} + +/* +** COMMAND: bundle* +** +** Usage: %fossil bundle SUBCOMMAND ARGS... +** +** > fossil bundle append BUNDLE FILE... +** +** Add files named on the command line to BUNDLE. This subcommand has +** little practical use and is mostly intended for testing. +** +** > fossil bundle cat BUNDLE HASH... +** +** Extract one or more artifacts from the bundle and write them +** consecutively on standard output. This subcommand was designed +** for testing and introspection of bundles and is not something +** commonly used. +** +** > fossil bundle export BUNDLE ?OPTIONS? +** +** Generate a new bundle, in the file named BUNDLE, that contains a +** subset of the check-ins in the repository (usually a single branch) +** described by the --branch, --from, --to, and/or --checkin options, +** at least one of which is required. If BUNDLE already exists, the +** specified content is added to the bundle. +** +** --branch BRANCH Package all check-ins on BRANCH +** --from TAG1 --to TAG2 Package check-ins between TAG1 and TAG2 +** --checkin TAG Package the single check-in TAG +** --standalone Do no use delta-encoding against +** artifacts not in the bundle +** +** > fossil bundle extend BUNDLE +** +** The BUNDLE must already exist. This subcommand adds to the bundle +** any check-ins that are descendants of check-ins already in the bundle, +** and any tags that apply to artifacts in the bundle. +** +** > fossil bundle import BUNDLE ?--publish? +** +** Import all content from BUNDLE into the repository. By default, the +** imported files are private and will not sync. Use the --publish +** option to make the import public. +** +** > fossil bundle ls BUNDLE +** +** List the contents of BUNDLE on standard output +** +** > fossil bundle purge BUNDLE +** +** Remove from the repository all files that are used exclusively +** by check-ins in BUNDLE. This has the effect of undoing a +** "fossil bundle import". +** +** See also: [[publish]] +*/ +void bundle_cmd(void){ + const char *zSubcmd; + int n; + if( g.argc<4 ) usage("SUBCOMMAND BUNDLE ?OPTIONS?"); + zSubcmd = g.argv[2]; + db_find_and_open_repository(0,0); + n = (int)strlen(zSubcmd); + if( strncmp(zSubcmd, "append", n)==0 ){ + bundle_append_cmd(); + }else if( strncmp(zSubcmd, "cat", n)==0 ){ + bundle_cat_cmd(); + }else if( strncmp(zSubcmd, "export", n)==0 ){ + bundle_export_cmd(); + }else if( strncmp(zSubcmd, "extend", n)==0 ){ + fossil_fatal("not yet implemented"); + }else if( strncmp(zSubcmd, "import", n)==0 ){ + bundle_import_cmd(); + }else if( strncmp(zSubcmd, "ls", n)==0 ){ + bundle_ls_cmd(); + }else if( strncmp(zSubcmd, "purge", n)==0 ){ + bundle_purge_cmd(); + }else{ + fossil_fatal("unknown subcommand for bundle: %s", zSubcmd); + } +} diff --git a/src/cache.c b/src/cache.c new file mode 100644 index 0000000..01a51c9 --- /dev/null +++ b/src/cache.c @@ -0,0 +1,511 @@ +/* +** Copyright (c) 2014 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@sqlite.org +** +******************************************************************************* +** +** This file implements a cache for expense operations such as +** /zip and /tarball. +*/ +#include "config.h" +#include +#include "cache.h" + +/* +** Construct the name of the repository cache file +*/ +static char *cacheName(void){ + int i; + int n; + + if( g.zRepositoryName==0 ) return 0; + n = (int)strlen(g.zRepositoryName); + for(i=n-1; i>=0; i--){ + if( g.zRepositoryName[i]=='/' ){ i = n; break; } + if( g.zRepositoryName[i]=='.' ) break; + } + if( i<0 ) i = n; + return mprintf("%.*s.cache", i, g.zRepositoryName); +} + +/* +** Attempt to open the cache database, if such a database exists. +** Make sure the cache table exists within that database. +*/ +static sqlite3 *cacheOpen(int bForce){ + char *zDbName; + sqlite3 *db = 0; + int rc; + i64 sz; + + zDbName = cacheName(); + if( zDbName==0 ) return 0; + if( bForce==0 ){ + sz = file_size(zDbName, ExtFILE); + if( sz<=0 ){ + fossil_free(zDbName); + return 0; + } + } + rc = sqlite3_open(zDbName, &db); + fossil_free(zDbName); + if( rc ){ + sqlite3_close(db); + return 0; + } + sqlite3_busy_timeout(db, 5000); + if( sqlite3_table_column_metadata(db,0,"blob","key",0,0,0,0,0)!=SQLITE_OK ){ + rc = sqlite3_exec(db, + "PRAGMA page_size=8192;" + "CREATE TABLE IF NOT EXISTS blob(id INTEGER PRIMARY KEY, data BLOB);" + "CREATE TABLE IF NOT EXISTS cache(" + "key TEXT PRIMARY KEY," /* Key used to access the cache */ + "id INT REFERENCES blob," /* The cache content */ + "sz INT," /* Size of content in bytes */ + "tm INT," /* Last access time (unix timestamp) */ + "nref INT" /* Number of uses */ + ");" + "CREATE TRIGGER IF NOT EXISTS cacheDel AFTER DELETE ON cache BEGIN" + " DELETE FROM blob WHERE id=OLD.id;" + "END;", + 0, 0, 0 + ); + if( rc!=SQLITE_OK ){ + sqlite3_close(db); + return 0; + } + } + return db; +} + +/* +** Attempt to construct a prepared statement for the cache database. +*/ +static sqlite3_stmt *cacheStmt(sqlite3 *db, const char *zSql){ + sqlite3_stmt *pStmt = 0; + int rc; + + rc = sqlite3_prepare_v2(db, zSql, -1, &pStmt, 0); + if( rc ){ + sqlite3_finalize(pStmt); + pStmt = 0; + } + return pStmt; +} + +/* +** This routine implements an SQL function that renders a large integer +** compactly: ex: 12.3MB +*/ +static void cache_sizename( + sqlite3_context *context, + int argc, + sqlite3_value **argv +){ + char zBuf[30]; + double v, x; + assert( argc==1 ); + v = sqlite3_value_double(argv[0]); + x = v<0.0 ? -v : v; + if( x>=1e9 ){ + sqlite3_snprintf(sizeof(zBuf), zBuf, "%.1fGB", v/1e9); + }else if( x>=1e6 ){ + sqlite3_snprintf(sizeof(zBuf), zBuf, "%.1fMB", v/1e6); + }else if( x>=1e3 ){ + sqlite3_snprintf(sizeof(zBuf), zBuf, "%.1fKB", v/1e3); + }else{ + sqlite3_snprintf(sizeof(zBuf), zBuf, "%gB", v); + } + sqlite3_result_text(context, zBuf, -1, SQLITE_TRANSIENT); +} + +/* +** Register the sizename() SQL function with the SQLite database +** connection. +*/ +static void cache_register_sizename(sqlite3 *db){ + sqlite3_create_function(db, "sizename", 1, SQLITE_UTF8, 0, + cache_sizename, 0, 0); +} + +/* +** Attempt to write pContent into the cache. If the cache file does +** not exist, then this routine is a no-op. Older cache entries might +** be deleted. +*/ +void cache_write(Blob *pContent, const char *zKey){ + sqlite3 *db; + sqlite3_stmt *pStmt; + int rc = 0; + int nKeep; + + db = cacheOpen(0); + if( db==0 ) return; + sqlite3_busy_timeout(db, 10000); + sqlite3_exec(db, "BEGIN IMMEDIATE", 0, 0, 0); + pStmt = cacheStmt(db, "INSERT INTO blob(data) VALUES(?1)"); + if( pStmt==0 ) goto cache_write_end; + sqlite3_bind_blob(pStmt, 1, blob_buffer(pContent), blob_size(pContent), + SQLITE_STATIC); + if( sqlite3_step(pStmt)!=SQLITE_DONE ) goto cache_write_end; + sqlite3_finalize(pStmt); + pStmt = cacheStmt(db, + "INSERT OR IGNORE INTO cache(key,sz,tm,nref,id)" + "VALUES(?1,?2,strftime('%s','now'),1,?3)" + ); + if( pStmt==0 ) goto cache_write_end; + sqlite3_bind_text(pStmt, 1, zKey, -1, SQLITE_STATIC); + sqlite3_bind_int(pStmt, 2, blob_size(pContent)); + sqlite3_bind_int(pStmt, 3, sqlite3_last_insert_rowid(db)); + if( sqlite3_step(pStmt)!=SQLITE_DONE) goto cache_write_end; + rc = sqlite3_changes(db); + + /* If the write was successful, truncate the cache to keep at most + ** max-cache-entry entries in the cache. + ** + ** The cache entry replacement algorithm is approximately LRU + ** (least recently used). However, each access of an entry buys + ** that entry an extra hour of grace, so that more commonly accessed + ** entries are held in cache longer. The extra "grace" allotted to + ** an entry is limited to 2 days worth. + */ + if( rc ){ + nKeep = db_get_int("max-cache-entry",10); + sqlite3_finalize(pStmt); + pStmt = cacheStmt(db, + "DELETE FROM cache WHERE rowid IN (" + "SELECT rowid FROM cache" + " ORDER BY (tm + 3600*min(nRef,48)) DESC" + " LIMIT -1 OFFSET ?1)"); + if( pStmt ){ + sqlite3_bind_int(pStmt, 1, nKeep); + sqlite3_step(pStmt); + } + } + +cache_write_end: + sqlite3_finalize(pStmt); + sqlite3_exec(db, rc ? "COMMIT" : "ROLLBACK", 0, 0, 0); + sqlite3_close(db); +} + +/* +** SETTING: max-cache-entry width=10 default=10 +** +** This is the maximum number of entries to allow in the web-cache +** for tarballs, ZIP-archives, and SQL-archives. +*/ + +/* +** Attempt to read content out of the cache with the given zKey. Return +** non-zero on success and zero if unable to locate the content. +** +** Possible reasons for returning zero: +** (1) This server does not implement a cache +** (2) The requested element is not in the cache +*/ +int cache_read(Blob *pContent, const char *zKey){ + sqlite3 *db; + sqlite3_stmt *pStmt; + int rc = 0; + + db = cacheOpen(0); + if( db==0 ) return 0; + sqlite3_busy_timeout(db, 10000); + sqlite3_exec(db, "BEGIN IMMEDIATE", 0, 0, 0); + pStmt = cacheStmt(db, + "SELECT blob.data FROM cache, blob" + " WHERE cache.key=?1 AND cache.id=blob.id"); + if( pStmt==0 ) goto cache_read_done; + sqlite3_bind_text(pStmt, 1, zKey, -1, SQLITE_STATIC); + if( sqlite3_step(pStmt)==SQLITE_ROW ){ + blob_append(pContent, sqlite3_column_blob(pStmt, 0), + sqlite3_column_bytes(pStmt, 0)); + rc = 1; + sqlite3_reset(pStmt); + pStmt = cacheStmt(db, + "UPDATE cache SET nref=nref+1, tm=strftime('%s','now')" + " WHERE key=?1"); + if( pStmt ){ + sqlite3_bind_text(pStmt, 1, zKey, -1, SQLITE_STATIC); + sqlite3_step(pStmt); + } + } + sqlite3_finalize(pStmt); +cache_read_done: + sqlite3_exec(db, "COMMIT", 0, 0, 0); + sqlite3_close(db); + return rc; +} + +/* +** Create a cache database for the current repository if no such +** database already exists. +*/ +void cache_initialize(void){ + sqlite3_close(cacheOpen(1)); +} + +/* +** COMMAND: cache* abbrv-subcom +** +** Usage: %fossil cache SUBCOMMAND +** +** Manage the cache used for potentially expensive web pages such as +** /zip and /tarball. SUBCOMMAND can be: +** +** clear Remove all entries from the cache. +** +** init Create the cache file if it does not already exist. +** +** list|ls List the keys and content sizes and other stats for +** all entries currently in the cache. +** +** size ?N? Query or set the maximum number of entries in the cache. +** +** status Show a summary of the cache status. +** +** The cache is stored in a file that is distinct from the repository +** but that is held in the same directory as the repository. The cache +** file can be deleted in order to completely disable the cache. +*/ +void cache_cmd(void){ + const char *zCmd; + int nCmd; + sqlite3 *db; + sqlite3_stmt *pStmt; + + db_find_and_open_repository(0,0); + zCmd = g.argc>=3 ? g.argv[2] : ""; + nCmd = (int)strlen(zCmd); + if( nCmd<=1 ){ + fossil_fatal("Usage: %s cache SUBCOMMAND", g.argv[0]); + } + if( strncmp(zCmd, "init", nCmd)==0 ){ + db = cacheOpen(0); + sqlite3_close(db); + if( db ){ + fossil_print("cache already exists in file %z\n", cacheName()); + }else{ + db = cacheOpen(1); + sqlite3_close(db); + if( db ){ + fossil_print("cache created in file %z\n", cacheName()); + }else{ + fossil_fatal("unable to create cache file %z", cacheName()); + } + } + }else if( strncmp(zCmd, "clear", nCmd)==0 ){ + db = cacheOpen(0); + if( db ){ + sqlite3_exec(db, "DELETE FROM cache; DELETE FROM blob; VACUUM;",0,0,0); + sqlite3_close(db); + fossil_print("cache cleared\n"); + }else{ + fossil_print("nothing to clear; cache does not exist\n"); + } + }else if( strncmp(zCmd, "list", nCmd)==0 + || strncmp(zCmd, "ls", nCmd)==0 + || strncmp(zCmd, "status", nCmd)==0 + ){ + db = cacheOpen(0); + if( db==0 ){ + fossil_print("cache does not exist\n"); + }else{ + int nEntry = 0; + char *zDbName = cacheName(); + cache_register_sizename(db); + pStmt = cacheStmt(db, + "SELECT key, sizename(sz), nRef, datetime(tm,'unixepoch')" + " FROM cache" + " ORDER BY tm DESC" + ); + if( pStmt ){ + while( sqlite3_step(pStmt)==SQLITE_ROW ){ + if( zCmd[0]=='l' ){ + fossil_print("%s %4d %8s %s\n", + sqlite3_column_text(pStmt, 3), + sqlite3_column_int(pStmt, 2), + sqlite3_column_text(pStmt, 1), + sqlite3_column_text(pStmt, 0)); + } + nEntry++; + } + sqlite3_finalize(pStmt); + } + sqlite3_close(db); + fossil_print( + "Filename: %s\n" + "Entries: %d\n" + "max-cache-entry: %d\n" + "Cache-file Size: %,lld\n", + zDbName, + nEntry, + db_get_int("max-cache-entry",10), + file_size(zDbName, ExtFILE) + ); + fossil_free(zDbName); + } + }else if( strncmp(zCmd, "size", nCmd)==0 ){ + if( g.argc>=4 ){ + int n = atoi(g.argv[3]); + if( n>=5 ) db_set_int("max-cache-entry",n,0); + } + fossil_print("max-cache-entry: %d\n", db_get_int("max-cache-entry",10)); + }else{ + fossil_fatal("Unknown subcommand \"%s\"." + " Should be one of: clear init list size status", zCmd); + } +} + +/* +** Given a cache key, find the check-in hash and return it as a separate +** string. The returned string is obtained from fossil_malloc() and must +** be freed by the caller. +** +** Return NULL if not found. +** +** The key is usually in a format like these: +** +** /tarball/HASH/NAME +** /zip/HASH/NAME +** /sqlar/HASH/NAME +*/ +static char *cache_hash_of_key(const char *zKey){ + int i; + if( zKey==0 ) return 0; + if( zKey[0]!='/' ) return 0; + zKey++; + while( zKey[0] && zKey[0]!='/' ) zKey++; + if( zKey[0]==0 ) return 0; + zKey++; + for(i=0; zKey[i] && zKey[i]!='/'; i++){} + if( !validate16(zKey, i) ) return 0; + return fossil_strndup(zKey, i); +} + + +/* +** WEBPAGE: cachestat +** +** Show information about the webpage cache. Requires Setup privilege. +*/ +void cache_page(void){ + sqlite3 *db = 0; + sqlite3_stmt *pStmt; + int doInit; + char *zDbName = cacheName(); + int nEntry = 0; + int mxEntry = 0; + char zBuf[100]; + + login_check_credentials(); + if( !g.perm.Setup ){ login_needed(0); return; } + style_set_current_feature("cache"); + style_header("Web Cache Status"); + style_submenu_element("Refresh","%R/cachestat"); + doInit = P("init")!=0 && cgi_csrf_safe(2); + db = cacheOpen(doInit); + if( db!=0 ){ + if( P("clear")!=0 && cgi_csrf_safe(2) ){ + sqlite3_exec(db, "DELETE FROM cache; DELETE FROM blob; VACUUM;",0,0,0); + } + cache_register_sizename(db); + pStmt = cacheStmt(db, + "SELECT key, sz, nRef, datetime(tm,'unixepoch')" + " FROM cache" + " ORDER BY (tm + 3600*min(nRef,48)) DESC" + ); + if( pStmt ){ + while( sqlite3_step(pStmt)==SQLITE_ROW ){ + const unsigned char *zName = sqlite3_column_text(pStmt,0); + char *zHash = cache_hash_of_key((const char*)zName); + if( nEntry==0 ){ + @

      Current Cache Entries:

      + @
        + } + @
      1. %z(href("%R/cacheget?key=%T",zName))%h(zName)
        + @ size: %,lld(sqlite3_column_int64(pStmt,1)), + @ hit-count: %d(sqlite3_column_int(pStmt,2)), + @ last-access: %s(sqlite3_column_text(pStmt,3))Z \ + if( zHash ){ + @ → %z(href("%R/timeline?c=%S",zHash))check-in info\ + fossil_free(zHash); + } + @

      2. + nEntry++; + } + sqlite3_finalize(pStmt); + if( nEntry ){ + @
      + } + } + } + @

      About The Web-Cache

      + @

      + @ The web-cache is a separate database file that holds cached copies + @ tarballs, ZIP archives, and other pages that are expensive to compute + @ and are likely to be reused. + @

      + login_insert_csrf_secret(); + @
        + if( db==0 ){ + @
      • Web-cache is currently disabled. + @ + }else{ + bigSizeName(sizeof(zBuf), zBuf, file_size(zDbName, ExtFILE)); + mxEntry = db_get_int("max-cache-entry",10); + @
      • Filename of the cache database: %h(zDbName) + @
      • Size of the cache database: %s(zBuf) + @
      • Maximum number of entries: %d(mxEntry) + @
      • Number of cache entries used: %d(nEntry) + @
      • Change the max-cache-entry setting on the + @ Settings page to adjust the + @ maximum number of entries in the cache. + @
      • + @
      • Disable the cache by manually deleting the cache database file. + } + @
      + @
      + fossil_free(zDbName); + if( db ) sqlite3_close(db); + style_finish_page(); +} + +/* +** WEBPAGE: cacheget +** +** Usage: /cacheget?key=KEY +** +** Download a single entry for the cache, identified by KEY. +** This page is normally a hyperlink from the /cachestat page. +** Requires Admin privilege. +*/ +void cache_getpage(void){ + const char *zKey; + Blob content; + + login_check_credentials(); + if( !g.perm.Setup ){ login_needed(0); return; } + zKey = PD("key",""); + blob_zero(&content); + if( cache_read(&content, zKey)==0 ){ + style_set_current_feature("cache"); + style_header("Cache Download Error"); + @ The cache does not contain any entry with this key: "%h(zKey)" + style_finish_page(); + return; + } + cgi_set_content(&content); + cgi_set_content_type("application/x-compressed"); +} diff --git a/src/capabilities.c b/src/capabilities.c new file mode 100644 index 0000000..ac4886a --- /dev/null +++ b/src/capabilities.c @@ -0,0 +1,481 @@ +/* +** Copyright (c) 2018 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 used managing user capability strings. +*/ +#include "config.h" +#include "capabilities.h" +#include + +#if INTERFACE +/* +** A capability string object holds all defined capabilities in a +** vector format that is subject to boolean operations. +*/ +struct CapabilityString { + unsigned char x[128]; +}; +#endif + +/* +** Add capabilities to a CapabilityString. If pIn is NULL, then create +** a new capability string. +** +** Call capability_free() on the allocated CapabilityString object to +** deallocate. +*/ +CapabilityString *capability_add(CapabilityString *pIn, const char *zCap){ + int c; + int i; + if( pIn==0 ){ + pIn = fossil_malloc( sizeof(*pIn) ); + memset(pIn, 0, sizeof(*pIn)); + } + if( zCap ){ + for(i=0; (c = zCap[i])!=0; i++){ + if( c>='0' && c<='z' ) pIn->x[c] = 1; + } + } + return pIn; +} + +/* +** Remove capabilities from a CapabilityString. +*/ +CapabilityString *capability_remove(CapabilityString *pIn, const char *zCap){ + int c; + int i; + if( pIn==0 ){ + pIn = fossil_malloc( sizeof(*pIn) ); + memset(pIn, 0, sizeof(*pIn)); + } + if( zCap ){ + for(i=0; (c = zCap[i])!=0; i++){ + if( c>='0' && c<='z' ) pIn->x[c] = 0; + } + } + return pIn; +} + +/* +** Return true if any of the capabilities in zNeeded are found in pCap +*/ +int capability_has_any(CapabilityString *p, const char *zNeeded){ + if( p==0 ) return 0; + if( zNeeded==0 ) return 0; + while( zNeeded[0] ){ + int c = zNeeded[0]; + if( fossil_isalnum(c) && p->x[c] ) return 1; + zNeeded++; + } + return 0; +} + +/* +** Delete a CapabilityString object. +*/ +void capability_free(CapabilityString *p){ + fossil_free(p); +} + +/* +** Expand the capability string by including all capabilities for +** special users "nobody" and "anonymous". Also include "reader" +** if "u" is present and "developer" if "v" is present. +*/ +void capability_expand(CapabilityString *pIn){ + static char *zNobody = 0; + static char *zAnon = 0; + static char *zReader = 0; + static char *zDev = 0; + static char *zAdmin = "bcdefghijklmnopqrtwz234567ACD"; + int doneV = 0; + + if( pIn==0 ){ + fossil_free(zNobody); zNobody = 0; + fossil_free(zAnon); zAnon = 0; + fossil_free(zReader); zReader = 0; + fossil_free(zDev); zDev = 0; + return; + } + if( zNobody==0 ){ + zNobody = db_text(0, "SELECT cap FROM user WHERE login='nobody'"); + zAnon = db_text(0, "SELECT cap FROM user WHERE login='anonymous'"); + zReader = db_text(0, "SELECT cap FROM user WHERE login='reader'"); + zDev = db_text(0, "SELECT cap FROM user WHERE login='developer'"); + } + pIn = capability_add(pIn, zAnon); + pIn = capability_add(pIn, zNobody); + if( pIn->x['a'] || pIn->x['s'] ){ + pIn = capability_add(pIn, zAdmin); + } + if( pIn->x['v'] ){ + pIn = capability_add(pIn, zDev); + doneV = 1; + } + if( pIn->x['u'] ){ + pIn = capability_add(pIn, zReader); + if( pIn->x['v'] && !doneV ){ + pIn = capability_add(pIn, zDev); + } + } +} + +/* +** Render a capability string in canonical string format. Space to hold +** the returned string is obtained from fossil_malloc() can should be freed +** by the caller. +*/ +char *capability_string(CapabilityString *p){ + Blob out; + int i; + int j = 0; + char buf[100]; + blob_init(&out, 0, 0); + for(i='a'; i<='z'; i++){ + if( p->x[i] ) buf[j++] = i; + } + for(i='0'; i<='9'; i++){ + if( p->x[i] ) buf[j++] = i; + } + for(i='A'; i<='Z'; i++){ + if( p->x[i] ) buf[j++] = i; + } + buf[j] = 0; + return fossil_strdup(buf); +} + +/* +** The next two routines implement an aggregate SQL function that +** takes multiple capability strings and in the end returns their +** union. Example usage: +** +** SELECT capunion(cap) FROM user WHERE login IN ('nobody','anonymous'); +*/ +void capability_union_step( + sqlite3_context *context, + int argc, + sqlite3_value **argv +){ + CapabilityString *p; + const char *zIn; + + zIn = (const char*)sqlite3_value_text(argv[0]); + if( zIn==0 ) return; + p = (CapabilityString*)sqlite3_aggregate_context(context, sizeof(*p)); + p = capability_add(p, zIn); +} +void capability_union_finalize(sqlite3_context *context){ + CapabilityString *p; + p = sqlite3_aggregate_context(context, 0); + if( p ){ + char *zOut = capability_string(p); + sqlite3_result_text(context, zOut, -1, fossil_free); + } +} + +/* +** The next routines takes the raw USER.CAP field and expands it with +** capabilities from special users. Example: +** +** SELECT fullcap(cap) FROM user WHERE login=?1 +*/ +void capability_fullcap( + sqlite3_context *context, + int argc, + sqlite3_value **argv +){ + CapabilityString *p; + const char *zIn; + char *zOut; + + zIn = (const char*)sqlite3_value_text(argv[0]); + if( zIn==0 ) zIn = ""; + p = capability_add(0, zIn); + capability_expand(p); + zOut = capability_string(p); + sqlite3_result_text(context, zOut, -1, fossil_free); + capability_free(p); +} + +#if INTERFACE +/* +** Capabilities are grouped into "classes" as follows: +*/ +#define CAPCLASS_CODE 0x0001 +#define CAPCLASS_WIKI 0x0002 +#define CAPCLASS_TKT 0x0004 +#define CAPCLASS_FORUM 0x0008 +#define CAPCLASS_DATA 0x0010 +#define CAPCLASS_ALERT 0x0020 +#define CAPCLASS_OTHER 0x0040 +#define CAPCLASS_SUPER 0x0080 +#define CAPCLASS_ALL 0xffff +#endif /* INTERFACE */ + + +/* +** The following structure holds descriptions of the various capabilities. +*/ +static struct Caps { + char cCap; /* The capability letter */ + unsigned short eClass; /* The "class" for this capability */ + unsigned nUser; /* Number of users with this capability */ + char *zAbbrev; /* Abbreviated mnemonic name */ + char *zOneLiner; /* One-line summary */ +} aCap[] = { + { 'a', CAPCLASS_SUPER, 0, + "Admin", "Create and delete users" }, + { 'b', CAPCLASS_WIKI|CAPCLASS_TKT, 0, + "Attach", "Add attachments to wiki or tickets" }, + { 'c', CAPCLASS_TKT, 0, + "Append-Tkt", "Append to existing tickets" }, + /* + ** d unused since fork from CVSTrac; + ** see https://fossil-scm.org/forum/forumpost/43c78f4bef + */ + { 'e', CAPCLASS_DATA, 0, + "View-PII", "View sensitive info such as email addresses" }, + { 'f', CAPCLASS_WIKI, 0, + "New-Wiki", "Create new wiki pages" }, + { 'g', CAPCLASS_DATA, 0, + "Clone", "Clone the repository" }, + { 'h', CAPCLASS_OTHER, 0, + "Hyperlinks", "Show hyperlinks to detailed repository history" }, + { 'i', CAPCLASS_CODE, 0, + "Check-In", "Check-in code changes" }, + { 'j', CAPCLASS_WIKI, 0, + "Read-Wiki", "View wiki pages" }, + { 'k', CAPCLASS_WIKI, 0, + "Write-Wiki", "Edit wiki pages" }, + { 'l', CAPCLASS_WIKI|CAPCLASS_SUPER, 0, + "Mod-Wiki", "Moderator for wiki pages" }, + { 'm', CAPCLASS_WIKI, 0, + "Append-Wiki", "Append to wiki pages" }, + { 'n', CAPCLASS_TKT, 0, + "New-Tkt", "Create new tickets" }, + { 'o', CAPCLASS_CODE, 0, + "Check-Out", "Check out code" }, + { 'p', CAPCLASS_OTHER, 0, + "Password", "Change your own password" }, + { 'q', CAPCLASS_TKT|CAPCLASS_SUPER, 0, + "Mod-Tkt", "Moderate tickets" }, + { 'r', CAPCLASS_TKT, 0, + "Read-Tkt", "View tickets" }, + { 's', CAPCLASS_SUPER, 0, + "Superuser", "Setup and configure the repository" }, + { 't', CAPCLASS_TKT, 0, + "Reports", "Create new ticket report formats" }, + { 'u', CAPCLASS_OTHER, 0, + "Reader", "Inherit all the capabilities of the \"reader\" user" }, + { 'v', CAPCLASS_OTHER, 0, + "Developer", "Inherit all capabilities of the \"developer\" user" }, + { 'w', CAPCLASS_TKT, 0, + "Write-Tkt", "Edit tickets" }, + { 'x', CAPCLASS_DATA, 0, + "Private", "Push and/or pull private branches" }, + { 'y', CAPCLASS_SUPER, 0, + "Write-UV", "Push unversioned content" }, + { 'z', CAPCLASS_CODE, 0, + "Zip-Download", "Download a ZIP archive, tarball, or SQL archive" }, + { '2', CAPCLASS_FORUM, 0, + "Forum-Read", "Read forum posts by others" }, + { '3', CAPCLASS_FORUM, 0, + "Forum-Write", "Create new forum messages" }, + { '4', CAPCLASS_FORUM, 0, + "Forum-Trusted", "Create forum messages that bypass moderation" }, + { '5', CAPCLASS_FORUM|CAPCLASS_SUPER, 0, + "Forum-Mod", "Moderator for forum messages" }, + { '6', CAPCLASS_FORUM|CAPCLASS_SUPER, 0, + "Forum-Admin", "Grant capability '4' to other users" }, + { '7', CAPCLASS_ALERT, 0, + "Alerts", "Sign up for email alerts" }, + { 'A', CAPCLASS_ALERT|CAPCLASS_SUPER, 0, + "Announce", "Send announcements to all subscribers" }, + { 'B', CAPCLASS_FORUM|CAPCLASS_SUPER, 0, + "Forum-Attach", "Add attachment to Forum posts" }, + { 'C', CAPCLASS_FORUM, 0, + "Chat", "Read and/or writes messages in the chatroom" }, + { 'D', CAPCLASS_OTHER, 0, + "Debug", "Enable debugging features" }, +}; + +/* +** Populate the aCap[].nUser values based on the current content +** of the USER table. +*/ +void capabilities_count(void){ + int i; + static int done = 0; + Stmt q; + if( done ) return; + db_prepare(&q, "SELECT fullcap(cap) FROM user"); + while( db_step(&q)==SQLITE_ROW ){ + const char *zCap = db_column_text(&q, 0); + if( zCap==0 || zCap[0]==0 ) continue; + for(i=0; i<(int)(sizeof(aCap)/sizeof(aCap[0])); i++){ + if( strchr(zCap, aCap[i].cCap) ) aCap[i].nUser++; + } + } + db_finalize(&q); + done = 1; +} + + +/* +** Generate HTML that lists all of the capability letters together with +** a brief summary of what each letter means. +*/ +void capabilities_table(unsigned mClass){ + int i; + if( g.perm.Admin ) capabilities_count(); + @ + @ + for(i=0; i<(int)(sizeof(aCap)/sizeof(aCap[0])); i++){ + int n; + if( (aCap[i].eClass & mClass)==0 ) continue; + @ + @ \ + n = aCap[i].nUser; + if( n && g.perm.Admin ){ + @ \ + } + @ + } + @ + @
      %c(aCap[i].cCap)%h(aCap[i].zAbbrev)%h(aCap[i].zOneLiner)\ + @ %d(n) user%s(n>1?"s":"")
      +} + +/* +** Generate a "capability summary table" that shows the major capabilities +** against the various user categories. +*/ +void capability_summary(void){ + Stmt q; + CapabilityString *pCap; + char *zSelfCap; + char *zPubPages = db_get("public-pages",0); + int hasPubPages = zPubPages && zPubPages[0]; + + pCap = capability_add(0, db_get("default-perms","u")); + capability_expand(pCap); + zSelfCap = capability_string(pCap); + capability_free(pCap); + + db_prepare(&q, + "WITH t(id,seq) AS (VALUES('nobody',1),('anonymous',2),('reader',3)," + "('developer',4))" + " SELECT id, CASE WHEN user.login='nobody' THEN user.cap" + " ELSE fullcap(user.cap) END,seq,1" + " FROM t LEFT JOIN user ON t.id=user.login" + " UNION ALL" + " SELECT 'Public Pages', %Q, 100, %d" + " UNION ALL" + " SELECT 'New User Default', %Q, 110, 1" + " UNION ALL" + " SELECT 'Regular User', fullcap(capunion(cap)), 200, count(*) FROM user" + " WHERE cap NOT GLOB '*[as]*' AND login NOT IN (SELECT id FROM t)" + " UNION ALL" + " SELECT 'Administrator', fullcap(capunion(cap)), 300, count(*) FROM user" + " WHERE cap GLOB '*[as]*'" + " ORDER BY 3 ASC", + zSelfCap, hasPubPages, zSelfCap + ); + @ + @ + while( db_step(&q)==SQLITE_ROW ){ + const char *zId = db_column_text(&q, 0); + const char *zCap = db_column_text(&q, 1); + int n = db_column_int(&q, 3); + int eType; + static const char *const azType[] = { "off", "read", "write" }; + static const char *const azClass[] = + { "capsumOff", "capsumRead", "capsumWrite" }; + + if( n==0 ) continue; + + /* Code */ + if( db_column_int(&q,2)<10 ){ + @ + }else if( n>1 ){ + @ + }else{ + @ + } + if( sqlite3_strglob("*[asi]*",zCap)==0 ){ + eType = 2; + }else if( sqlite3_strglob("*[oz]*",zCap)==0 ){ + eType = 1; + }else{ + eType = 0; + } + @ + + /* Forum */ + if( sqlite3_strglob("*[as3456]*",zCap)==0 ){ + eType = 2; + }else if( sqlite3_strglob("*2*",zCap)==0 ){ + eType = 1; + }else{ + eType = 0; + } + @ + + /* Ticket */ + if( sqlite3_strglob("*[ascnqtw]*",zCap)==0 ){ + eType = 2; + }else if( sqlite3_strglob("*r*",zCap)==0 ){ + eType = 1; + }else{ + eType = 0; + } + @ + + /* Wiki */ + if( sqlite3_strglob("*[asdfklm]*",zCap)==0 ){ + eType = 2; + }else if( sqlite3_strglob("*j*",zCap)==0 ){ + eType = 1; + }else{ + eType = 0; + } + @ + + /* Chat */ + if( sqlite3_strglob("*C*",zCap)==0 ){ + eType = 2; + }else{ + eType = 0; + } + @ + + /* Unversioned */ + if( sqlite3_strglob("*y*",zCap)==0 ){ + eType = 2; + }else if( sqlite3_strglob("*[ioas]*",zCap)==0 ){ + eType = 1; + }else{ + eType = 0; + } + @ + + } + db_finalize(&q); + @
       CodeForumTicketsWikiChat\ + @ Unversioned Content
      "%h(zId)"
      %d(n) %h(zId)s
      %h(zId)%s(azType[eType])%s(azType[eType])%s(azType[eType])%s(azType[eType])%s(azType[eType])%s(azType[eType])
      +} diff --git a/src/captcha.c b/src/captcha.c new file mode 100644 index 0000000..8e768ce --- /dev/null +++ b/src/captcha.c @@ -0,0 +1,926 @@ +/* +** Copyright (c) 2009 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 to a simple text-based CAPTCHA. Though easily +** defeated by a sophisticated attacker, this CAPTCHA does at least make +** scripting attacks more difficult. +*/ +#include "config.h" +#include +#include "captcha.h" + +#if INTERFACE +#define CAPTCHA 2 /* Which captcha rendering to use */ +#endif + +/* +** Convert a hex digit into a value between 0 and 15 +*/ +int hex_digit_value(char c){ + if( c>='0' && c<='9' ){ + return c - '0'; + }else if( c>='a' && c<='f' ){ + return c - 'a' + 10; + }else if( c>='A' && c<='F' ){ + return c - 'A' + 10; + }else{ + return 0; + } +} + +#if CAPTCHA==1 +/* +** A 4x6 pixel bitmap font for hexadecimal digits +*/ +static const unsigned int aFont1[] = { + 0x699996, + 0x262227, + 0x69124f, + 0xf16196, + 0x26af22, + 0xf8e196, + 0x68e996, + 0xf12244, + 0x696996, + 0x699716, + 0x699f99, + 0xe9e99e, + 0x698896, + 0xe9999e, + 0xf8e88f, + 0xf8e888, +}; + +/* +** Render an 8-character hexadecimal string as ascii art. +** Space to hold the result is obtained from malloc() and should be freed +** by the caller. +*/ +char *captcha_render(const char *zPw){ + char *z = fossil_malloc( 9*12*3*strlen(zPw) + 8 ); + int i, j, k, m; + + k = 0; + for(i=0; i<6; i++){ + for(j=0; zPw[j]; j++){ + unsigned char v = hex_digit_value(zPw[j]); + v = (aFont1[v] >> ((5-i)*4)) & 0xf; + for(m=8; m>=1; m = m>>1){ + if( v & m ){ + z[k++] = 0xe2; + z[k++] = 0x96; + z[k++] = 0x88; + z[k++] = 0xe2; + z[k++] = 0x96; + z[k++] = 0x88; + }else{ + z[k++] = ' '; + z[k++] = ' '; + } + } + z[k++] = ' '; + z[k++] = ' '; + } + z[k++] = '\n'; + } + z[k] = 0; + return z; +} +#endif /* CAPTCHA==1 */ + +#if CAPTCHA==2 +/* +** A 5x7 pixel bitmap font for hexadecimal digits +*/ +static const unsigned char aFont2[] = { + /* 0 */ 0x0e, 0x13, 0x15, 0x19, 0x11, 0x11, 0x0e, + /* 1 */ 0x02, 0x06, 0x0A, 0x02, 0x02, 0x02, 0x02, + /* 2 */ 0x0e, 0x11, 0x01, 0x02, 0x04, 0x08, 0x1f, + /* 3 */ 0x0e, 0x11, 0x01, 0x06, 0x01, 0x11, 0x0e, + /* 4 */ 0x02, 0x06, 0x0A, 0x12, 0x1f, 0x02, 0x02, + /* 5 */ 0x1f, 0x10, 0x1e, 0x01, 0x01, 0x11, 0x0e, + /* 6 */ 0x0e, 0x11, 0x10, 0x1e, 0x11, 0x11, 0x0e, + /* 7 */ 0x1f, 0x01, 0x02, 0x04, 0x08, 0x08, 0x08, + /* 8 */ 0x0e, 0x11, 0x11, 0x0e, 0x11, 0x11, 0x0e, + /* 9 */ 0x0e, 0x11, 0x11, 0x0f, 0x01, 0x11, 0x0e, + /* A */ 0x0e, 0x11, 0x11, 0x11, 0x1f, 0x11, 0x11, + /* B */ 0x1e, 0x11, 0x11, 0x1e, 0x11, 0x11, 0x1e, + /* C */ 0x0e, 0x11, 0x10, 0x10, 0x10, 0x11, 0x0e, + /* D */ 0x1c, 0x12, 0x11, 0x11, 0x11, 0x12, 0x1c, + /* E */ 0x1f, 0x10, 0x10, 0x1c, 0x10, 0x10, 0x1f, + /* F */ 0x1f, 0x10, 0x10, 0x1e, 0x10, 0x10, 0x10, +}; + +/* +** Render an 8-character hexadecimal string as ascii art. +** Space to hold the result is obtained from malloc() and should be freed +** by the caller. +*/ +char *captcha_render(const char *zPw){ + char *z = fossil_malloc( 224*strlen(zPw) + 9 ); + int i, j, k, m; + + k = 0; + for(i=0; i<7; i++){ + for(j=0; zPw[j]; j++){ + unsigned char v = hex_digit_value(zPw[j]); + v = aFont2[v*7+i]; + for(m=16; m>=1; m = m>>1){ + if( v & m ){ + z[k++] = 0xe2; + z[k++] = 0x96; + z[k++] = 0x88; + z[k++] = 0xe2; + z[k++] = 0x96; + z[k++] = 0x88; + }else{ + z[k++] = 0xe2; + z[k++] = 0x96; + z[k++] = 0x91; + z[k++] = 0xe2; + z[k++] = 0x96; + z[k++] = 0x91; + } + } + z[k++] = ' '; + z[k++] = ' '; + } + z[k++] = '\n'; + } + z[k] = 0; + return z; +} +#endif /* CAPTCHA==2 */ + + +#if CAPTCHA==3 +static const char *const azFont3[] = { + /* 0 */ + " __ ", + " / \\ ", + "| () |", + " \\__/ ", + + /* 1 */ + " _ ", + "/ |", + "| |", + "|_|", + + /* 2 */ + " ___ ", + "|_ )", + " / / ", + "/___|", + + /* 3 */ + " ____", + "|__ /", + " |_ \\", + "|___/", + + /* 4 */ + " _ _ ", + "| | | ", + "|_ _|", + " |_| ", + + /* 5 */ + " ___ ", + "| __|", + "|__ \\", + "|___/", + + /* 6 */ + " __ ", + " / / ", + "/ _ \\", + "\\___/", + + /* 7 */ + " ____ ", + "|__ |", + " / / ", + " /_/ ", + + /* 8 */ + " ___ ", + "( _ )", + "/ _ \\", + "\\___/", + + /* 9 */ + " ___ ", + "/ _ \\", + "\\_, /", + " /_/ ", + + /* A */ + " ", + " /\\ ", + " / \\ ", + "/_/\\_\\", + + /* B */ + " ___ ", + "| _ )", + "| _ \\", + "|___/", + + /* C */ + " ___ ", + " / __|", + "| (__ ", + " \\___|", + + /* D */ + " ___ ", + "| \\ ", + "| |) |", + "|___/ ", + + /* E */ + " ___ ", + "| __|", + "| _| ", + "|___|", + + /* F */ + " ___ ", + "| __|", + "| _| ", + "|_| ", +}; + +/* +** Render an 8-digit hexadecimal string as ascii arg. +** Space to hold the result is obtained from malloc() and should be freed +** by the caller. +*/ +char *captcha_render(const char *zPw){ + char *z = fossil_malloc( 7*4*strlen(zPw) + 5 ); + int i, j, k, m; + const char *zChar; + + k = 0; + for(i=0; i<4; i++){ + for(j=0; zPw[j]; j++){ + unsigned char v = hex_digit_value(zPw[j]); + zChar = azFont3[4*v + i]; + for(m=0; zChar[m]; m++){ + z[k++] = zChar[m]; + } + } + z[k++] = '\n'; + } + z[k] = 0; + return z; +} +#endif /* CAPTCHA==3 */ + +#if CAPTCHA==4 +static const char *const azFont4[] = { + /* 0 */ + " ___ ", + " / _ \\ ", + "| | | |", + "| | | |", + "| |_| |", + " \\___/ ", + + /* 1 */ + " __ ", + "/_ |", + " | |", + " | |", + " | |", + " |_|", + + /* 2 */ + " ___ ", + "|__ \\ ", + " ) |", + " / / ", + " / /_ ", + "|____|", + + /* 3 */ + " ____ ", + "|___ \\ ", + " __) |", + " |__ < ", + " ___) |", + "|____/ ", + + /* 4 */ + " _ _ ", + "| || | ", + "| || |_ ", + "|__ _|", + " | | ", + " |_| ", + + /* 5 */ + " _____ ", + "| ____|", + "| |__ ", + "|___ \\ ", + " ___) |", + "|____/ ", + + /* 6 */ + " __ ", + " / / ", + " / /_ ", + "| '_ \\ ", + "| (_) |", + " \\___/ ", + + /* 7 */ + " ______ ", + "|____ |", + " / / ", + " / / ", + " / / ", + " /_/ ", + + /* 8 */ + " ___ ", + " / _ \\ ", + "| (_) |", + " > _ < ", + "| (_) |", + " \\___/ ", + + /* 9 */ + " ___ ", + " / _ \\ ", + "| (_) |", + " \\__, |", + " / / ", + " /_/ ", + + /* A */ + " ", + " /\\ ", + " / \\ ", + " / /\\ \\ ", + " / ____ \\ ", + "/_/ \\_\\", + + /* B */ + " ____ ", + "| _ \\ ", + "| |_) |", + "| _ < ", + "| |_) |", + "|____/ ", + + /* C */ + " _____ ", + " / ____|", + "| | ", + "| | ", + "| |____ ", + " \\_____|", + + /* D */ + " _____ ", + "| __ \\ ", + "| | | |", + "| | | |", + "| |__| |", + "|_____/ ", + + /* E */ + " ______ ", + "| ____|", + "| |__ ", + "| __| ", + "| |____ ", + "|______|", + + /* F */ + " ______ ", + "| ____|", + "| |__ ", + "| __| ", + "| | ", + "|_| ", +}; + +/* +** Render an 8-digit hexadecimal string as ascii arg. +** Space to hold the result is obtained from malloc() and should be freed +** by the caller. +*/ +char *captcha_render(const char *zPw){ + char *z = fossil_malloc( 10*6*strlen(zPw) + 7 ); + int i, j, k, m; + const char *zChar; + unsigned char x; + int y; + + k = 0; + for(i=0; i<6; i++){ + x = 0; + for(j=0; zPw[j]; j++){ + unsigned char v = hex_digit_value(zPw[j]); + x = (x<<4) + v; + switch( x ){ + case 0x7a: + case 0xfa: + y = 3; + break; + case 0x47: + y = 2; + break; + case 0xf6: + case 0xa9: + case 0xa4: + case 0xa1: + case 0x9a: + case 0x76: + case 0x61: + case 0x67: + case 0x69: + case 0x41: + case 0x42: + case 0x43: + case 0x4a: + y = 1; + break; + default: + y = 0; + break; + } + zChar = azFont4[6*v + i]; + while( y && zChar[0]==' ' ){ y--; zChar++; } + while( y && z[k-1]==' ' ){ y--; k--; } + for(m=0; zChar[m]; m++){ + z[k++] = zChar[m]; + } + } + z[k++] = '\n'; + } + z[k] = 0; + return z; +} +#endif /* CAPTCHA==4 */ + +/* +** COMMAND: test-captcha +** +** Render an ASCII-art captcha for numbers given on the command line. +*/ +void test_captcha(void){ + int i; + sqlite3_uint64 v; + char *z; + + for(i=2; i