|
336 | 336 | ;; --------------------------------------------------------------------------- |
337 | 337 |
|
338 | 338 | (defn- attachment-basename |
339 | | - "Return the basename of an attachment filename (handles absolute paths)." |
| 339 | + "Return the basename of an attachment filename (handles absolute paths). |
| 340 | + Hostile or degenerate filenames (nil, \"\", \".\", \"..\") fall back to |
| 341 | + a stable name derived from a hash of the raw filename, so a crafted |
| 342 | + attachment can neither crash the export nor escape its directory." |
340 | 343 | [att] |
341 | | - (.getName (io/file (:attachment/filename att)))) |
| 344 | + (let [filename (:attachment/filename att) |
| 345 | + base (some-> filename io/file .getName)] |
| 346 | + (if (or (nil? base) (#{"" "." ".."} base)) |
| 347 | + (str "attachment-" (mid-hash (str filename)) ".txt") |
| 348 | + base))) |
342 | 349 |
|
343 | 350 | (defn- close-flag [report] |
344 | 351 | (if (:report/closed report) |
|
677 | 684 | Outgoing relations (:rel/_from) cover all kinds; incoming :related-to |
678 | 685 | (:rel/_to) is added because the symmetric kind canonicalises to the |
679 | 686 | smaller-eid side, so the other report only sees it via incoming." |
680 | | - [report src-type] |
| 687 | + [report source-map] |
681 | 688 | (let [self-eid (:db/id report) |
| 689 | + ;; Go through archive-url so linked reports get the same |
| 690 | + ;; treatment as the report itself: https-only validation of the |
| 691 | + ;; sender-controlled Archived-At header and :archive-format-string |
| 692 | + ;; substitution, keyed on the linked report's own source. |
682 | 693 | archive (fn [other-r] |
683 | | - (when-not (#{:alias :mailbox} src-type) |
684 | | - (archived-at (:report/email other-r)))) |
| 694 | + (archive-url other-r (:report/email other-r) source-map)) |
685 | 695 | format-rel (fn [from-side? rel] |
686 | 696 | (let [other (if from-side? (:rel/to rel) (:rel/from rel)) |
687 | 697 | a (archive other) |
|
720 | 730 | from-name (or (get (ctx-author-names) (str/lower-case from)) |
721 | 731 | (:email/author-name email)) |
722 | 732 | arch (archive-url report email source-map) |
723 | | - src-type (get-in source-map [source-name :source-type]) |
724 | | - relations (group-relations report src-type) |
| 733 | + relations (group-relations report source-map) |
725 | 734 | role (sender-role from source-name source-map maintainers-map) |
726 | 735 | awaiting? (awaiting-reply? report source-name source-map maintainers-map)] |
727 | 736 | (-> {:type (name (:report/type report)) |
|
1566 | 1575 |
|
1567 | 1576 | (defn dump-root-index! |
1568 | 1577 | "Generate public/index.html listing all exported sources. |
1569 | | - Reads each source's reports/meta.json for summary counts." |
1570 | | - [source-names] |
| 1578 | + Reads each source's reports/meta.json for summary counts. |
| 1579 | + Feed links honor each source's effective :export-formats so the |
| 1580 | + index never points at files the export does not produce." |
| 1581 | + [source-names source-map] |
1571 | 1582 | (let [rows (for [src-name source-names |
1572 | 1583 | :let [slug (slugify src-name) |
1573 | 1584 | base-dir (str "public/" slug) |
1574 | 1585 | meta (load-source-meta base-dir)] |
1575 | 1586 | :when meta] |
1576 | 1587 | {:name src-name |
1577 | 1588 | :slug slug |
| 1589 | + :formats (resolve-export-formats src-name source-map) |
1578 | 1590 | :total (or (:total meta) 0) |
1579 | 1591 | :open (or (:open-count meta) 0) |
1580 | 1592 | :closed (or (:closed-count meta) 0) |
1581 | 1593 | :list-archive (:list-archive meta)}) |
1582 | 1594 | row-html |
1583 | | - (fn [{:keys [name slug total open closed list-archive]}] |
1584 | | - (str "<tr>" |
1585 | | - "<td><a href=\"" slug "/index.html\">" (xml-escape name) "</a>" |
1586 | | - (when list-archive |
1587 | | - (str " <a class=\"archive\" href=\"" (xml-escape list-archive) |
1588 | | - "\" title=\"List archive\">↗</a>")) |
1589 | | - "</td>" |
1590 | | - "<td class=\"num\">" open "</td>" |
1591 | | - "<td class=\"num\">" closed "</td>" |
1592 | | - "<td class=\"num\">" total "</td>" |
1593 | | - "<td class=\"num feeds\">" |
1594 | | - "<a href=\"" slug "/reports/all.xml\">RSS</a> · " |
1595 | | - "<a href=\"" slug "/reports/all.json\">JSON</a>" |
1596 | | - "</td>" |
1597 | | - "</tr>\n")) |
| 1595 | + (fn [{:keys [name slug formats total open closed list-archive]}] |
| 1596 | + (let [feeds (cond-> [] |
| 1597 | + (formats "rss") |
| 1598 | + (conj (str "<a href=\"" slug "/reports/all.xml\">RSS</a>")) |
| 1599 | + (formats "json") |
| 1600 | + (conj (str "<a href=\"" slug "/reports/all.json\">JSON</a>")))] |
| 1601 | + (str "<tr>" |
| 1602 | + "<td><a href=\"" slug "/index.html\">" (xml-escape name) "</a>" |
| 1603 | + (when list-archive |
| 1604 | + (str " <a class=\"archive\" href=\"" (xml-escape list-archive) |
| 1605 | + "\" title=\"List archive\">↗</a>")) |
| 1606 | + "</td>" |
| 1607 | + "<td class=\"num\">" open "</td>" |
| 1608 | + "<td class=\"num\">" closed "</td>" |
| 1609 | + "<td class=\"num\">" total "</td>" |
| 1610 | + "<td class=\"num feeds\">" (str/join " · " feeds) "</td>" |
| 1611 | + "</tr>\n"))) |
1598 | 1612 | page |
1599 | 1613 | (str |
1600 | 1614 | "<!DOCTYPE html>\n<html lang=\"en\">\n" |
|
1713 | 1727 | (log/error "Invalid --min-priority:" min-priority "(must be 1, 2, or 3)") |
1714 | 1728 | (System/exit 1)) |
1715 | 1729 | (when (and min-status (not (<= 1 min-status 7))) |
1716 | | - (log/error "Invalid --min-status:" min-status "(must be 1–7)") |
| 1730 | + (log/error "Invalid --min-status:" min-status "(must be 1-7)") |
1717 | 1731 | (System/exit 1)) |
1718 | 1732 | (let [;; Watermark for save-last-export!: taken *before* the DB snapshot, |
1719 | 1733 | ;; otherwise daemon transactions racing with this export would fall |
|
1876 | 1890 | (if incremental? " (incremental)" ""))) |
1877 | 1891 | (try |
1878 | 1892 | (delete-dir! (io/file staging)) |
1879 | | - ;; Seed staging with the previous export so an |
1880 | | - ;; incremental run keeps the files it does not |
1881 | | - ;; rewrite (per-type reports/, per-mid patches/, |
1882 | | - ;; text/, events/); aggregates are always |
1883 | | - ;; rebuilt. Seed on EVERY incremental run: |
1884 | | - ;; :since is passed whenever incremental?, so |
1885 | | - ;; unseeded per-mid files would be lost in the |
1886 | | - ;; swap. |
1887 | | - (when incremental? |
1888 | | - (doseq [sub ["reports" "patches" "text" "events"]] |
1889 | | - (copy-dir! (io/file final-dir sub) |
1890 | | - (io/file staging sub)))) |
| 1893 | + ;; Single-format runs: FULL copy of final-dir, |
| 1894 | + ;; or the swap would wipe the other formats. |
| 1895 | + ;; "all": incremental seeds only the subdirs it |
| 1896 | + ;; does not rewrite; a full run starts from an |
| 1897 | + ;; empty staging so stale files are purged. |
| 1898 | + (if (not= format "all") |
| 1899 | + (copy-dir! (io/file final-dir) |
| 1900 | + (io/file staging)) |
| 1901 | + (when incremental? |
| 1902 | + (doseq [sub ["reports" "patches" "text" "events"]] |
| 1903 | + (copy-dir! (io/file final-dir sub) |
| 1904 | + (io/file staging sub))))) |
1891 | 1905 | ;; Preserve the previous HTML shells when not |
1892 | 1906 | ;; regenerating them: they are top-level files |
1893 | 1907 | ;; not covered by the subdir seed above, and |
|
1916 | 1930 | ;; (the :when meta filter drops never-exported sources anyway). |
1917 | 1931 | (when (and (#{"all" "root"} format) |
1918 | 1932 | (or (= format "root") (seq exported-srcs))) |
1919 | | - (dump-root-index! (mapv :name (:sources config)))) |
| 1933 | + (dump-root-index! (mapv :name (:sources config)) source-map)) |
1920 | 1934 | ;; Cron notification: one stderr line iff real work was |
1921 | 1935 | ;; published ("Wrote ..." progress goes to stdout). Names |
1922 | 1936 | ;; the changed report types per source (pre-filter counts); |
|
1940 | 1954 | s)) |
1941 | 1955 | exported-srcs)) |
1942 | 1956 | (when-not incremental? " [full re-export]"))))))) |
1943 | | - (save-last-export! run-started)))) |
| 1957 | + ;; Advance the incremental watermark only after a full, |
| 1958 | + ;; unfiltered run: a partial run (single format, -n, or a |
| 1959 | + ;; priority/status/topics filter) does not publish everything, |
| 1960 | + ;; so moving the watermark would hide those changes from the |
| 1961 | + ;; next incremental export. --closed-retention is fine: it |
| 1962 | + ;; only drops old closed reports, and day-crossed? forces a |
| 1963 | + ;; daily full run to keep retention output converged. |
| 1964 | + (when (and (= format "all") |
| 1965 | + (nil? source-name) |
| 1966 | + (nil? min-priority) |
| 1967 | + (nil? min-status) |
| 1968 | + (nil? topics-filter)) |
| 1969 | + (save-last-export! run-started))))) |
1944 | 1970 | (finally |
1945 | 1971 | (d/close conn)))) |
0 commit comments