[
  {
    "path": ".github/workflows/build.yml",
    "content": "name: build\non: [ push, pull_request ]\n\njobs:\n  build-dub:\n    strategy:\n      fail-fast: false\n      matrix:\n        os: [ ubuntu-22.04 ]\n        dc: [ dmd-2.110.0 ]\n\n    runs-on: ${{ matrix.os }}\n    steps:\n      - name: Install dependencies\n        run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev\n\n      - name: Prepare compiler\n        uses: dlang-community/setup-dlang@v1\n        with:\n          compiler: ${{ matrix.dc }}\n\n      - uses: actions/checkout@v2\n\n      - name: Build\n        run: |\n          dub build\n\n  build-nix:\n    timeout-minutes: 60\n    runs-on: ubuntu-24.04\n\n    steps:\n      - name: Checkout repository\n        uses: actions/checkout@v4\n\n      - name: Install Nix\n        uses: DeterminateSystems/nix-installer-action@v18\n        with:\n          extra-conf: |\n            extra-experimental-features = nix-command flakes\n\n      - name: Setup Nix cache\n        uses: DeterminateSystems/magic-nix-cache-action@v11\n\n      - name: Build DFeed with Nix\n        run: nix build --show-trace --print-build-logs\n\n      - name: Run flake checks\n        run: nix flake check --show-trace --print-build-logs\n"
  },
  {
    "path": ".gitignore",
    "content": "# Temporary files\n*.exe\n*.o\n*.obj\n*.def\n!ws2_32x.def\n*.ksp\n*.pdb\n*.rsp\n*.map\n*.mem\n*.suo\n*.ilk\n*.min.*\n*.jar\n/dfeed.json\n\n/core\n/bad-zlib.z\n/bad-base64.txt\n/feed-error.xml\n/so-error.txt\n\n# Data, logs and configuration\n/data/\n/logs/\n/build.local\n/site/\n\n# Binaries\n/dfeed\n/dfeed_web\n/mldownload\n/nntpdownload\n/rebuilddb\n/rebuildthreads\n/sanitizedb\n/sendspamfeedback\n/unban\n/bayes-checkall\n/bayes-checkdatum\n/bayes-prepdata\n/bayes-train\n/*-test-application\n/src/dfeed/progs/*\n!/src/dfeed/progs/*.d\nresult\n\n# Deimos\n/deimos/openssl/\n"
  },
  {
    "path": ".gitmodules",
    "content": "[submodule \"ae\"]\n\tpath = lib/ae\n\turl = https://github.com/CyberShadow/ae\n[submodule \"dcaptcha\"]\n\tpath = lib/dcaptcha\n\turl = https://github.com/CyberShadow/dcaptcha\n[submodule \"deimos-openssl\"]\n\tpath = lib/deimos-openssl\n\turl = https://github.com/D-Programming-Deimos/openssl\n"
  },
  {
    "path": "README.md",
    "content": "DFeed\n=====\n\nDFeed is a multi-protocol news aggregator and forum system:\n\n- NNTP client\n- Mailing list archive\n- Web-based forum interface\n- ATOM feed aggregator\n- IRC bot\n\n## Demo Instance\n\nA demo instance is available here: https://dfeed-demo.cy.md/\n\n## Directory Structure\n\n- `src/` - Application source code\n- `site-defaults/` - Default configuration and web templates (tracked in repo)\n- `site/` - Site-specific overrides (gitignored, your customizations go here)\n\nFiles in `site/` override files in `site-defaults/`. This allows you to customize\nyour installation without modifying tracked files.\n\n## Quick Start\n\n```bash\ngit clone --recursive https://github.com/CyberShadow/DFeed.git\ncd DFeed\n```\n\n### Building with Dub (executable only)\n\n```bash\ndub build\n```\n\n### Building with Nix (executable and minified resources)\n\n```bash\nnix build .\n```\n\n### Configuration\n\nCreate your site-specific configuration in `site/`:\n\n```bash\nmkdir -p site/config/sources/nntp\ncp site-defaults/config/site.ini.sample site/config/site.ini\n# Edit site/config/site.ini with your settings\n\n# Add an NNTP source:\necho \"host = your.nntp.server\" > site/config/sources/nntp/myserver.ini\n\n# Configure web interface:\necho \"listen.port = 80\" > site/config/web.ini\n```\n\n### Running\n\n```bash\n./dfeed\n```\n\nOn first start, DFeed downloads messages from configured NNTP servers.\nAccess the web interface at http://localhost:8080/.\n\n## Site-Specific Deployments\n\nFor an example of a complete site-specific setup, see the\n[dlang.org forum configuration](https://github.com/CyberShadow/d-programming-language.org/tree/dfeed/dfeed).\n"
  },
  {
    "path": "agpl-3.0.txt",
    "content": "                    GNU AFFERO GENERAL PUBLIC LICENSE\n                       Version 3, 19 November 2007\n\n Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>\n Everyone is permitted to copy and distribute verbatim copies\n of this license document, but changing it is not allowed.\n\n                            Preamble\n\n  The GNU Affero General Public License is a free, copyleft license for\nsoftware and other kinds of works, specifically designed to ensure\ncooperation with the community in the case of network server software.\n\n  The licenses for most software and other practical works are designed\nto take away your freedom to share and change the works.  By contrast,\nour General Public Licenses are intended to guarantee your freedom to\nshare and change all versions of a program--to make sure it remains free\nsoftware for all its users.\n\n  When we speak of free software, we are referring to freedom, not\nprice.  Our General Public Licenses are designed to make sure that you\nhave the freedom to distribute copies of free software (and charge for\nthem if you wish), that you receive source code or can get it if you\nwant it, that you can change the software or use pieces of it in new\nfree programs, and that you know you can do these things.\n\n  Developers that use our General Public Licenses protect your rights\nwith two steps: (1) assert copyright on the software, and (2) offer\nyou this License which gives you legal permission to copy, distribute\nand/or modify the software.\n\n  A secondary benefit of defending all users' freedom is that\nimprovements made in alternate versions of the program, if they\nreceive widespread use, become available for other developers to\nincorporate.  Many developers of free software are heartened and\nencouraged by the resulting cooperation.  However, in the case of\nsoftware used on network servers, this result may fail to come about.\nThe GNU General Public License permits making a modified version and\nletting the public access it on a server without ever releasing its\nsource code to the public.\n\n  The GNU Affero General Public License is designed specifically to\nensure that, in such cases, the modified source code becomes available\nto the community.  It requires the operator of a network server to\nprovide the source code of the modified version running there to the\nusers of that server.  Therefore, public use of a modified version, on\na publicly accessible server, gives the public access to the source\ncode of the modified version.\n\n  An older license, called the Affero General Public License and\npublished by Affero, was designed to accomplish similar goals.  This is\na different license, not a version of the Affero GPL, but Affero has\nreleased a new version of the Affero GPL which permits relicensing under\nthis license.\n\n  The precise terms and conditions for copying, distribution and\nmodification follow.\n\n                       TERMS AND CONDITIONS\n\n  0. Definitions.\n\n  \"This License\" refers to version 3 of the GNU Affero General Public License.\n\n  \"Copyright\" also means copyright-like laws that apply to other kinds of\nworks, such as semiconductor masks.\n\n  \"The Program\" refers to any copyrightable work licensed under this\nLicense.  Each licensee is addressed as \"you\".  \"Licensees\" and\n\"recipients\" may be individuals or organizations.\n\n  To \"modify\" a work means to copy from or adapt all or part of the work\nin a fashion requiring copyright permission, other than the making of an\nexact copy.  The resulting work is called a \"modified version\" of the\nearlier work or a work \"based on\" the earlier work.\n\n  A \"covered work\" means either the unmodified Program or a work based\non the Program.\n\n  To \"propagate\" a work means to do anything with it that, without\npermission, would make you directly or secondarily liable for\ninfringement under applicable copyright law, except executing it on a\ncomputer or modifying a private copy.  Propagation includes copying,\ndistribution (with or without modification), making available to the\npublic, and in some countries other activities as well.\n\n  To \"convey\" a work means any kind of propagation that enables other\nparties to make or receive copies.  Mere interaction with a user through\na computer network, with no transfer of a copy, is not conveying.\n\n  An interactive user interface displays \"Appropriate Legal Notices\"\nto the extent that it includes a convenient and prominently visible\nfeature that (1) displays an appropriate copyright notice, and (2)\ntells the user that there is no warranty for the work (except to the\nextent that warranties are provided), that licensees may convey the\nwork under this License, and how to view a copy of this License.  If\nthe interface presents a list of user commands or options, such as a\nmenu, a prominent item in the list meets this criterion.\n\n  1. Source Code.\n\n  The \"source code\" for a work means the preferred form of the work\nfor making modifications to it.  \"Object code\" means any non-source\nform of a work.\n\n  A \"Standard Interface\" means an interface that either is an official\nstandard defined by a recognized standards body, or, in the case of\ninterfaces specified for a particular programming language, one that\nis widely used among developers working in that language.\n\n  The \"System Libraries\" of an executable work include anything, other\nthan the work as a whole, that (a) is included in the normal form of\npackaging a Major Component, but which is not part of that Major\nComponent, and (b) serves only to enable use of the work with that\nMajor Component, or to implement a Standard Interface for which an\nimplementation is available to the public in source code form.  A\n\"Major Component\", in this context, means a major essential component\n(kernel, window system, and so on) of the specific operating system\n(if any) on which the executable work runs, or a compiler used to\nproduce the work, or an object code interpreter used to run it.\n\n  The \"Corresponding Source\" for a work in object code form means all\nthe source code needed to generate, install, and (for an executable\nwork) run the object code and to modify the work, including scripts to\ncontrol those activities.  However, it does not include the work's\nSystem Libraries, or general-purpose tools or generally available free\nprograms which are used unmodified in performing those activities but\nwhich are not part of the work.  For example, Corresponding Source\nincludes interface definition files associated with source files for\nthe work, and the source code for shared libraries and dynamically\nlinked subprograms that the work is specifically designed to require,\nsuch as by intimate data communication or control flow between those\nsubprograms and other parts of the work.\n\n  The Corresponding Source need not include anything that users\ncan regenerate automatically from other parts of the Corresponding\nSource.\n\n  The Corresponding Source for a work in source code form is that\nsame work.\n\n  2. Basic Permissions.\n\n  All rights granted under this License are granted for the term of\ncopyright on the Program, and are irrevocable provided the stated\nconditions are met.  This License explicitly affirms your unlimited\npermission to run the unmodified Program.  The output from running a\ncovered work is covered by this License only if the output, given its\ncontent, constitutes a covered work.  This License acknowledges your\nrights of fair use or other equivalent, as provided by copyright law.\n\n  You may make, run and propagate covered works that you do not\nconvey, without conditions so long as your license otherwise remains\nin force.  You may convey covered works to others for the sole purpose\nof having them make modifications exclusively for you, or provide you\nwith facilities for running those works, provided that you comply with\nthe terms of this License in conveying all material for which you do\nnot control copyright.  Those thus making or running the covered works\nfor you must do so exclusively on your behalf, under your direction\nand control, on terms that prohibit them from making any copies of\nyour copyrighted material outside their relationship with you.\n\n  Conveying under any other circumstances is permitted solely under\nthe conditions stated below.  Sublicensing is not allowed; section 10\nmakes it unnecessary.\n\n  3. Protecting Users' Legal Rights From Anti-Circumvention Law.\n\n  No covered work shall be deemed part of an effective technological\nmeasure under any applicable law fulfilling obligations under article\n11 of the WIPO copyright treaty adopted on 20 December 1996, or\nsimilar laws prohibiting or restricting circumvention of such\nmeasures.\n\n  When you convey a covered work, you waive any legal power to forbid\ncircumvention of technological measures to the extent such circumvention\nis effected by exercising rights under this License with respect to\nthe covered work, and you disclaim any intention to limit operation or\nmodification of the work as a means of enforcing, against the work's\nusers, your or third parties' legal rights to forbid circumvention of\ntechnological measures.\n\n  4. Conveying Verbatim Copies.\n\n  You may convey verbatim copies of the Program's source code as you\nreceive it, in any medium, provided that you conspicuously and\nappropriately publish on each copy an appropriate copyright notice;\nkeep intact all notices stating that this License and any\nnon-permissive terms added in accord with section 7 apply to the code;\nkeep intact all notices of the absence of any warranty; and give all\nrecipients a copy of this License along with the Program.\n\n  You may charge any price or no price for each copy that you convey,\nand you may offer support or warranty protection for a fee.\n\n  5. Conveying Modified Source Versions.\n\n  You may convey a work based on the Program, or the modifications to\nproduce it from the Program, in the form of source code under the\nterms of section 4, provided that you also meet all of these conditions:\n\n    a) The work must carry prominent notices stating that you modified\n    it, and giving a relevant date.\n\n    b) The work must carry prominent notices stating that it is\n    released under this License and any conditions added under section\n    7.  This requirement modifies the requirement in section 4 to\n    \"keep intact all notices\".\n\n    c) You must license the entire work, as a whole, under this\n    License to anyone who comes into possession of a copy.  This\n    License will therefore apply, along with any applicable section 7\n    additional terms, to the whole of the work, and all its parts,\n    regardless of how they are packaged.  This License gives no\n    permission to license the work in any other way, but it does not\n    invalidate such permission if you have separately received it.\n\n    d) If the work has interactive user interfaces, each must display\n    Appropriate Legal Notices; however, if the Program has interactive\n    interfaces that do not display Appropriate Legal Notices, your\n    work need not make them do so.\n\n  A compilation of a covered work with other separate and independent\nworks, which are not by their nature extensions of the covered work,\nand which are not combined with it such as to form a larger program,\nin or on a volume of a storage or distribution medium, is called an\n\"aggregate\" if the compilation and its resulting copyright are not\nused to limit the access or legal rights of the compilation's users\nbeyond what the individual works permit.  Inclusion of a covered work\nin an aggregate does not cause this License to apply to the other\nparts of the aggregate.\n\n  6. Conveying Non-Source Forms.\n\n  You may convey a covered work in object code form under the terms\nof sections 4 and 5, provided that you also convey the\nmachine-readable Corresponding Source under the terms of this License,\nin one of these ways:\n\n    a) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by the\n    Corresponding Source fixed on a durable physical medium\n    customarily used for software interchange.\n\n    b) Convey the object code in, or embodied in, a physical product\n    (including a physical distribution medium), accompanied by a\n    written offer, valid for at least three years and valid for as\n    long as you offer spare parts or customer support for that product\n    model, to give anyone who possesses the object code either (1) a\n    copy of the Corresponding Source for all the software in the\n    product that is covered by this License, on a durable physical\n    medium customarily used for software interchange, for a price no\n    more than your reasonable cost of physically performing this\n    conveying of source, or (2) access to copy the\n    Corresponding Source from a network server at no charge.\n\n    c) Convey individual copies of the object code with a copy of the\n    written offer to provide the Corresponding Source.  This\n    alternative is allowed only occasionally and noncommercially, and\n    only if you received the object code with such an offer, in accord\n    with subsection 6b.\n\n    d) Convey the object code by offering access from a designated\n    place (gratis or for a charge), and offer equivalent access to the\n    Corresponding Source in the same way through the same place at no\n    further charge.  You need not require recipients to copy the\n    Corresponding Source along with the object code.  If the place to\n    copy the object code is a network server, the Corresponding Source\n    may be on a different server (operated by you or a third party)\n    that supports equivalent copying facilities, provided you maintain\n    clear directions next to the object code saying where to find the\n    Corresponding Source.  Regardless of what server hosts the\n    Corresponding Source, you remain obligated to ensure that it is\n    available for as long as needed to satisfy these requirements.\n\n    e) Convey the object code using peer-to-peer transmission, provided\n    you inform other peers where the object code and Corresponding\n    Source of the work are being offered to the general public at no\n    charge under subsection 6d.\n\n  A separable portion of the object code, whose source code is excluded\nfrom the Corresponding Source as a System Library, need not be\nincluded in conveying the object code work.\n\n  A \"User Product\" is either (1) a \"consumer product\", which means any\ntangible personal property which is normally used for personal, family,\nor household purposes, or (2) anything designed or sold for incorporation\ninto a dwelling.  In determining whether a product is a consumer product,\ndoubtful cases shall be resolved in favor of coverage.  For a particular\nproduct received by a particular user, \"normally used\" refers to a\ntypical or common use of that class of product, regardless of the status\nof the particular user or of the way in which the particular user\nactually uses, or expects or is expected to use, the product.  A product\nis a consumer product regardless of whether the product has substantial\ncommercial, industrial or non-consumer uses, unless such uses represent\nthe only significant mode of use of the product.\n\n  \"Installation Information\" for a User Product means any methods,\nprocedures, authorization keys, or other information required to install\nand execute modified versions of a covered work in that User Product from\na modified version of its Corresponding Source.  The information must\nsuffice to ensure that the continued functioning of the modified object\ncode is in no case prevented or interfered with solely because\nmodification has been made.\n\n  If you convey an object code work under this section in, or with, or\nspecifically for use in, a User Product, and the conveying occurs as\npart of a transaction in which the right of possession and use of the\nUser Product is transferred to the recipient in perpetuity or for a\nfixed term (regardless of how the transaction is characterized), the\nCorresponding Source conveyed under this section must be accompanied\nby the Installation Information.  But this requirement does not apply\nif neither you nor any third party retains the ability to install\nmodified object code on the User Product (for example, the work has\nbeen installed in ROM).\n\n  The requirement to provide Installation Information does not include a\nrequirement to continue to provide support service, warranty, or updates\nfor a work that has been modified or installed by the recipient, or for\nthe User Product in which it has been modified or installed.  Access to a\nnetwork may be denied when the modification itself materially and\nadversely affects the operation of the network or violates the rules and\nprotocols for communication across the network.\n\n  Corresponding Source conveyed, and Installation Information provided,\nin accord with this section must be in a format that is publicly\ndocumented (and with an implementation available to the public in\nsource code form), and must require no special password or key for\nunpacking, reading or copying.\n\n  7. Additional Terms.\n\n  \"Additional permissions\" are terms that supplement the terms of this\nLicense by making exceptions from one or more of its conditions.\nAdditional permissions that are applicable to the entire Program shall\nbe treated as though they were included in this License, to the extent\nthat they are valid under applicable law.  If additional permissions\napply only to part of the Program, that part may be used separately\nunder those permissions, but the entire Program remains governed by\nthis License without regard to the additional permissions.\n\n  When you convey a copy of a covered work, you may at your option\nremove any additional permissions from that copy, or from any part of\nit.  (Additional permissions may be written to require their own\nremoval in certain cases when you modify the work.)  You may place\nadditional permissions on material, added by you to a covered work,\nfor which you have or can give appropriate copyright permission.\n\n  Notwithstanding any other provision of this License, for material you\nadd to a covered work, you may (if authorized by the copyright holders of\nthat material) supplement the terms of this License with terms:\n\n    a) Disclaiming warranty or limiting liability differently from the\n    terms of sections 15 and 16 of this License; or\n\n    b) Requiring preservation of specified reasonable legal notices or\n    author attributions in that material or in the Appropriate Legal\n    Notices displayed by works containing it; or\n\n    c) Prohibiting misrepresentation of the origin of that material, or\n    requiring that modified versions of such material be marked in\n    reasonable ways as different from the original version; or\n\n    d) Limiting the use for publicity purposes of names of licensors or\n    authors of the material; or\n\n    e) Declining to grant rights under trademark law for use of some\n    trade names, trademarks, or service marks; or\n\n    f) Requiring indemnification of licensors and authors of that\n    material by anyone who conveys the material (or modified versions of\n    it) with contractual assumptions of liability to the recipient, for\n    any liability that these contractual assumptions directly impose on\n    those licensors and authors.\n\n  All other non-permissive additional terms are considered \"further\nrestrictions\" within the meaning of section 10.  If the Program as you\nreceived it, or any part of it, contains a notice stating that it is\ngoverned by this License along with a term that is a further\nrestriction, you may remove that term.  If a license document contains\na further restriction but permits relicensing or conveying under this\nLicense, you may add to a covered work material governed by the terms\nof that license document, provided that the further restriction does\nnot survive such relicensing or conveying.\n\n  If you add terms to a covered work in accord with this section, you\nmust place, in the relevant source files, a statement of the\nadditional terms that apply to those files, or a notice indicating\nwhere to find the applicable terms.\n\n  Additional terms, permissive or non-permissive, may be stated in the\nform of a separately written license, or stated as exceptions;\nthe above requirements apply either way.\n\n  8. Termination.\n\n  You may not propagate or modify a covered work except as expressly\nprovided under this License.  Any attempt otherwise to propagate or\nmodify it is void, and will automatically terminate your rights under\nthis License (including any patent licenses granted under the third\nparagraph of section 11).\n\n  However, if you cease all violation of this License, then your\nlicense from a particular copyright holder is reinstated (a)\nprovisionally, unless and until the copyright holder explicitly and\nfinally terminates your license, and (b) permanently, if the copyright\nholder fails to notify you of the violation by some reasonable means\nprior to 60 days after the cessation.\n\n  Moreover, your license from a particular copyright holder is\nreinstated permanently if the copyright holder notifies you of the\nviolation by some reasonable means, this is the first time you have\nreceived notice of violation of this License (for any work) from that\ncopyright holder, and you cure the violation prior to 30 days after\nyour receipt of the notice.\n\n  Termination of your rights under this section does not terminate the\nlicenses of parties who have received copies or rights from you under\nthis License.  If your rights have been terminated and not permanently\nreinstated, you do not qualify to receive new licenses for the same\nmaterial under section 10.\n\n  9. Acceptance Not Required for Having Copies.\n\n  You are not required to accept this License in order to receive or\nrun a copy of the Program.  Ancillary propagation of a covered work\noccurring solely as a consequence of using peer-to-peer transmission\nto receive a copy likewise does not require acceptance.  However,\nnothing other than this License grants you permission to propagate or\nmodify any covered work.  These actions infringe copyright if you do\nnot accept this License.  Therefore, by modifying or propagating a\ncovered work, you indicate your acceptance of this License to do so.\n\n  10. Automatic Licensing of Downstream Recipients.\n\n  Each time you convey a covered work, the recipient automatically\nreceives a license from the original licensors, to run, modify and\npropagate that work, subject to this License.  You are not responsible\nfor enforcing compliance by third parties with this License.\n\n  An \"entity transaction\" is a transaction transferring control of an\norganization, or substantially all assets of one, or subdividing an\norganization, or merging organizations.  If propagation of a covered\nwork results from an entity transaction, each party to that\ntransaction who receives a copy of the work also receives whatever\nlicenses to the work the party's predecessor in interest had or could\ngive under the previous paragraph, plus a right to possession of the\nCorresponding Source of the work from the predecessor in interest, if\nthe predecessor has it or can get it with reasonable efforts.\n\n  You may not impose any further restrictions on the exercise of the\nrights granted or affirmed under this License.  For example, you may\nnot impose a license fee, royalty, or other charge for exercise of\nrights granted under this License, and you may not initiate litigation\n(including a cross-claim or counterclaim in a lawsuit) alleging that\nany patent claim is infringed by making, using, selling, offering for\nsale, or importing the Program or any portion of it.\n\n  11. Patents.\n\n  A \"contributor\" is a copyright holder who authorizes use under this\nLicense of the Program or a work on which the Program is based.  The\nwork thus licensed is called the contributor's \"contributor version\".\n\n  A contributor's \"essential patent claims\" are all patent claims\nowned or controlled by the contributor, whether already acquired or\nhereafter acquired, that would be infringed by some manner, permitted\nby this License, of making, using, or selling its contributor version,\nbut do not include claims that would be infringed only as a\nconsequence of further modification of the contributor version.  For\npurposes of this definition, \"control\" includes the right to grant\npatent sublicenses in a manner consistent with the requirements of\nthis License.\n\n  Each contributor grants you a non-exclusive, worldwide, royalty-free\npatent license under the contributor's essential patent claims, to\nmake, use, sell, offer for sale, import and otherwise run, modify and\npropagate the contents of its contributor version.\n\n  In the following three paragraphs, a \"patent license\" is any express\nagreement or commitment, however denominated, not to enforce a patent\n(such as an express permission to practice a patent or covenant not to\nsue for patent infringement).  To \"grant\" such a patent license to a\nparty means to make such an agreement or commitment not to enforce a\npatent against the party.\n\n  If you convey a covered work, knowingly relying on a patent license,\nand the Corresponding Source of the work is not available for anyone\nto copy, free of charge and under the terms of this License, through a\npublicly available network server or other readily accessible means,\nthen you must either (1) cause the Corresponding Source to be so\navailable, or (2) arrange to deprive yourself of the benefit of the\npatent license for this particular work, or (3) arrange, in a manner\nconsistent with the requirements of this License, to extend the patent\nlicense to downstream recipients.  \"Knowingly relying\" means you have\nactual knowledge that, but for the patent license, your conveying the\ncovered work in a country, or your recipient's use of the covered work\nin a country, would infringe one or more identifiable patents in that\ncountry that you have reason to believe are valid.\n\n  If, pursuant to or in connection with a single transaction or\narrangement, you convey, or propagate by procuring conveyance of, a\ncovered work, and grant a patent license to some of the parties\nreceiving the covered work authorizing them to use, propagate, modify\nor convey a specific copy of the covered work, then the patent license\nyou grant is automatically extended to all recipients of the covered\nwork and works based on it.\n\n  A patent license is \"discriminatory\" if it does not include within\nthe scope of its coverage, prohibits the exercise of, or is\nconditioned on the non-exercise of one or more of the rights that are\nspecifically granted under this License.  You may not convey a covered\nwork if you are a party to an arrangement with a third party that is\nin the business of distributing software, under which you make payment\nto the third party based on the extent of your activity of conveying\nthe work, and under which the third party grants, to any of the\nparties who would receive the covered work from you, a discriminatory\npatent license (a) in connection with copies of the covered work\nconveyed by you (or copies made from those copies), or (b) primarily\nfor and in connection with specific products or compilations that\ncontain the covered work, unless you entered into that arrangement,\nor that patent license was granted, prior to 28 March 2007.\n\n  Nothing in this License shall be construed as excluding or limiting\nany implied license or other defenses to infringement that may\notherwise be available to you under applicable patent law.\n\n  12. No Surrender of Others' Freedom.\n\n  If conditions are imposed on you (whether by court order, agreement or\notherwise) that contradict the conditions of this License, they do not\nexcuse you from the conditions of this License.  If you cannot convey a\ncovered work so as to satisfy simultaneously your obligations under this\nLicense and any other pertinent obligations, then as a consequence you may\nnot convey it at all.  For example, if you agree to terms that obligate you\nto collect a royalty for further conveying from those to whom you convey\nthe Program, the only way you could satisfy both those terms and this\nLicense would be to refrain entirely from conveying the Program.\n\n  13. Remote Network Interaction; Use with the GNU General Public License.\n\n  Notwithstanding any other provision of this License, if you modify the\nProgram, your modified version must prominently offer all users\ninteracting with it remotely through a computer network (if your version\nsupports such interaction) an opportunity to receive the Corresponding\nSource of your version by providing access to the Corresponding Source\nfrom a network server at no charge, through some standard or customary\nmeans of facilitating copying of software.  This Corresponding Source\nshall include the Corresponding Source for any work covered by version 3\nof the GNU General Public License that is incorporated pursuant to the\nfollowing paragraph.\n\n  Notwithstanding any other provision of this License, you have\npermission to link or combine any covered work with a work licensed\nunder version 3 of the GNU General Public License into a single\ncombined work, and to convey the resulting work.  The terms of this\nLicense will continue to apply to the part which is the covered work,\nbut the work with which it is combined will remain governed by version\n3 of the GNU General Public License.\n\n  14. Revised Versions of this License.\n\n  The Free Software Foundation may publish revised and/or new versions of\nthe GNU Affero General Public License from time to time.  Such new versions\nwill be similar in spirit to the present version, but may differ in detail to\naddress new problems or concerns.\n\n  Each version is given a distinguishing version number.  If the\nProgram specifies that a certain numbered version of the GNU Affero General\nPublic License \"or any later version\" applies to it, you have the\noption of following the terms and conditions either of that numbered\nversion or of any later version published by the Free Software\nFoundation.  If the Program does not specify a version number of the\nGNU Affero General Public License, you may choose any version ever published\nby the Free Software Foundation.\n\n  If the Program specifies that a proxy can decide which future\nversions of the GNU Affero General Public License can be used, that proxy's\npublic statement of acceptance of a version permanently authorizes you\nto choose that version for the Program.\n\n  Later license versions may give you additional or different\npermissions.  However, no additional obligations are imposed on any\nauthor or copyright holder as a result of your choosing to follow a\nlater version.\n\n  15. Disclaimer of Warranty.\n\n  THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY\nAPPLICABLE LAW.  EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT\nHOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM \"AS IS\" WITHOUT WARRANTY\nOF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,\nTHE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR\nPURPOSE.  THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM\nIS WITH YOU.  SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF\nALL NECESSARY SERVICING, REPAIR OR CORRECTION.\n\n  16. Limitation of Liability.\n\n  IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING\nWILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS\nTHE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY\nGENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE\nUSE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF\nDATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD\nPARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),\nEVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF\nSUCH DAMAGES.\n\n  17. Interpretation of Sections 15 and 16.\n\n  If the disclaimer of warranty and limitation of liability provided\nabove cannot be given local legal effect according to their terms,\nreviewing courts shall apply local law that most closely approximates\nan absolute waiver of all civil liability in connection with the\nProgram, unless a warranty or assumption of liability accompanies a\ncopy of the Program in return for a fee.\n\n                     END OF TERMS AND CONDITIONS\n\n            How to Apply These Terms to Your New Programs\n\n  If you develop a new program, and you want it to be of the greatest\npossible use to the public, the best way to achieve this is to make it\nfree software which everyone can redistribute and change under these terms.\n\n  To do so, attach the following notices to the program.  It is safest\nto attach them to the start of each source file to most effectively\nstate the exclusion of warranty; and each file should have at least\nthe \"copyright\" line and a pointer to where the full notice is found.\n\n    <one line to give the program's name and a brief idea of what it does.>\n    Copyright (C) <year>  <name of author>\n\n    This program is free software: you can redistribute it and/or modify\n    it under the terms of the GNU Affero General Public License as published by\n    the Free Software Foundation, either version 3 of the License, or\n    (at your option) any later version.\n\n    This program is distributed in the hope that it will be useful,\n    but WITHOUT ANY WARRANTY; without even the implied warranty of\n    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n    GNU Affero General Public License for more details.\n\n    You should have received a copy of the GNU Affero General Public License\n    along with this program.  If not, see <http://www.gnu.org/licenses/>.\n\nAlso add information on how to contact you by electronic and paper mail.\n\n  If your software can interact with users remotely through a computer\nnetwork, you should also make sure that it provides a way for users to\nget its source.  For example, if your program is a web application, its\ninterface could display a \"Source\" link that leads users to an archive\nof the code.  There are many ways you could offer source, and different\nsolutions will be better for different programs; see section 13 for the\nspecific requirements.\n\n  You should also get your employer (if you work as a programmer) or school,\nif any, to sign a \"copyright disclaimer\" for the program, if necessary.\nFor more information on this, and how to apply and follow the GNU AGPL, see\n<http://www.gnu.org/licenses/>.\n"
  },
  {
    "path": "dub.sdl",
    "content": "name \"dfeed\"\ndescription \"D news aggregator, newsgroup client, web newsreader and IRC bot\"\nauthors \"Vladimir Panteleev <vladimir@thecybershadow.net>\"\nhomepage \"https://github.com/CyberShadow/DFeed\"\nlicense \"Affero GPL v3\"\n\n---------------------------\n\n# Main package is the DFeed program itself.\nsourcePaths\nsourceFiles \"src/dfeed/progs/dfeed.d\"\nmainSourceFile \"src/dfeed/progs/dfeed.d\"\ndependency \"dfeed:lib\" version=\"*\" path=\".\"\ntargetType \"executable\"\n\n---------------------------\n\n# All modules.\nsubPackage {\n\tname \"lib\"\n\texcludedSourceFiles \"src/dfeed/progs/*.d\"\n\ttargetType \"sourceLibrary\"\n\tdependency \"ae\" version=\"==0.0.3666\"\n\tdependency \"ae:zlib\" version=\"==0.0.3666\"\n\tdependency \"ae:sqlite\" version=\"==0.0.3666\"\n\tdependency \"ae:openssl\" version=\"==0.0.3666\"\n\tdependency \"dcaptcha\" version=\"==1.0.1\"\n}\n\n---------------------------\n\n# NNTP downloader program.\nsubPackage {\n\tname \"nntpdownload\"\n\tsourcePaths\n\tsourceFiles \"src/dfeed/progs/nntpdownload.d\"\n\tdependency \"dfeed:lib\" version=\"*\" path=\".\"\n\ttargetType \"executable\"\n}\n\n---------------------------\n\n# Spam feedback program\nsubPackage {\n\tname \"sendspamfeedback\"\n\tsourcePaths\n\tsourceFiles \"src/dfeed/progs/sendspamfeedback.d\"\n\tdependency \"dfeed:lib\" version=\"*\" path=\".\"\n\ttargetType \"executable\"\n}\n"
  },
  {
    "path": "dub.selections.json",
    "content": "{\n\t\"fileVersion\": 1,\n\t\"versions\": {\n\t\t\"ae\": \"0.0.3666\",\n\t\t\"dcaptcha\": \"1.0.1\",\n\t\t\"openssl\": \"3.3.0\"\n\t}\n}\n"
  },
  {
    "path": "flake.nix",
    "content": "{\n  description = \"DFeed - D news aggregator, newsgroup client, web newsreader and IRC bot\";\n\n  inputs = {\n    nixpkgs.url = \"github:NixOS/nixpkgs/nixos-unstable\";\n    flake-utils.url = \"github:numtide/flake-utils\";\n    self.submodules = true;\n  };\n\n  outputs = { self, nixpkgs, flake-utils }:\n    flake-utils.lib.eachDefaultSystem (system:\n      let\n        pkgs = nixpkgs.legacyPackages.${system};\n\n        # Helper to download compressors (for minification)\n        htmlcompressor = pkgs.fetchurl {\n          url = \"https://storage.googleapis.com/google-code-archive-downloads/v2/code.google.com/htmlcompressor/htmlcompressor-1.5.3.jar\";\n          sha256 = \"1ydh1hqndnvw0d8kws5339mj6qn2yhjd8djih27423nv1hrlx2c8\";\n        };\n\n        yuicompressor = pkgs.fetchurl {\n          url = \"https://github.com/yui/yuicompressor/releases/download/v2.4.8/yuicompressor-2.4.8.jar\";\n          sha256 = \"1qjxlak9hbl9zd3dl5ks0w4zx5z64wjsbk7ic73r1r45fasisdrh\";\n        };\n\n        # Filter source to only include files needed for D build\n        dfeedSrc = pkgs.lib.cleanSourceWith {\n          src = self;\n          filter = path: type:\n            let\n              baseName = baseNameOf path;\n              relPath = pkgs.lib.removePrefix (toString self + \"/\") (toString path);\n            in\n              # Include D source directories\n              pkgs.lib.hasPrefix \"src/\" relPath ||\n              pkgs.lib.hasPrefix \"lib/\" relPath ||\n              # Include root-level build files\n              baseName == \"dub.sdl\" ||\n              baseName == \"dub.selections.json\" ||\n              # Allow traversing directories\n              type == \"directory\";\n        };\n\n        # Shared test configuration generation\n        generateTestConfig = ''\n          # Create test environment\n          mkdir -p site/config/apis data/db\n\n          # Create minimal site.ini\n          cat > site/config/site.ini << 'SITEINI'\n          name = DFeed Test Instance\n          host = localhost\n          proto = http\n          SITEINI\n\n          # Create web.ini with test port\n          cat > site/config/web.ini << 'WEBINI'\n          [listen]\n          port = 8080\n          WEBINI\n\n          # Disable StopForumSpam in sandbox (no network access)\n          cat > site/config/apis/stopforumspam.ini << 'SPAMINI'\n          enabled = false\n          SPAMINI\n\n          # Configure user authentication with a test salt\n          cat > site/config/user.ini << 'USERINI'\n          salt = test-salt-for-playwright-tests-only\n          USERINI\n\n          # Configure test group with dummy captcha and disabled rate limiting for testing\n          cat > site/config/groups.ini << 'GROUPSINI'\n          [sets.test]\n          name=Test\n          shortName=Test\n          visible=true\n\n          [groups.test]\n          internalName=test\n          publicName=Test Forum\n          navName=Test\n          urlName=test\n          groupSet=test\n          description=A test forum for trying out posting\n          sinkType=local\n          announce=false\n          captcha=dummy\n          postThrottleRejectCount=0\n          postThrottleCaptchaCount=0\n          GROUPSINI\n\n          # Database is automatically created and migrated by dfeed\n        '';\n\n        # Shared server startup/shutdown logic\n        startServer = ''\n          # Start dfeed server in background\n          ${self.packages.${system}.default}/bin/dfeed --no-sources &\n          DFEED_PID=$!\n\n          # Wait for server to be ready (up to 30 seconds)\n          echo \"Waiting for DFeed server to start...\"\n          for i in $(seq 1 30); do\n            if curl -s http://localhost:8080/ > /dev/null 2>&1; then\n              echo \"Server is ready!\"\n              break\n            fi\n            if ! kill -0 $DFEED_PID 2>/dev/null; then\n              echo \"Server process died unexpectedly\"\n              exit 1\n            fi\n            sleep 1\n          done\n\n          # Verify server is actually responding\n          if ! curl -s http://localhost:8080/ > /dev/null 2>&1; then\n            echo \"Server failed to start within 30 seconds\"\n            kill $DFEED_PID 2>/dev/null || true\n            exit 1\n          fi\n        '';\n\n        stopServer = ''\n          # Stop server\n          kill $DFEED_PID 2>/dev/null || true\n          wait $DFEED_PID 2>/dev/null || true\n        '';\n\n        # Reference site-defaults separately (not part of D source)\n        siteDefaultsSrc = \"${self}/site-defaults\";\n\n      in\n      {\n        packages.default = pkgs.stdenv.mkDerivation {\n          pname = \"dfeed\";\n          version = \"unstable\";\n\n          src = dfeedSrc;\n\n          # Don't strip debug symbols (we build with -g)\n          dontStrip = true;\n\n          nativeBuildInputs = with pkgs; [\n            dmd\n            dtools  # Provides rdmd\n            jre_minimal  # For htmlcompressor and yuicompressor\n            git\n            which\n          ];\n\n          buildInputs = with pkgs; [\n            curl\n            sqlite\n            openssl\n          ];\n\n          # Setup build environment\n          preConfigure = ''\n            # Make compressors available\n            cp ${htmlcompressor} htmlcompressor-1.5.3.jar\n            cp ${yuicompressor} yuicompressor-2.4.8.jar\n\n            # Copy site-defaults for minification (not part of D source)\n            cp -r ${siteDefaultsSrc} site-defaults\n            chmod -R u+w site-defaults\n          '';\n\n          buildPhase = ''\n            runHook preBuild\n\n            # Set rdmd to use dmd by default\n            export DCOMPILER=dmd\n\n            # Detect OpenSSL version\n            if [ -f lib/deimos-openssl/scripts/generate_version.d ]; then\n              echo \"Generating OpenSSL version detection...\"\n              rdmd --compiler=dmd lib/deimos-openssl/scripts/generate_version.d\n            fi\n\n            # Set up D compiler flags\n            flags=(\n              -m64\n              -g\n              -Isrc\n              -Ilib\n              -L-lcurl\n              -L-lsqlite3\n              -L-lssl\n              -L-lcrypto\n            )\n\n            # Add version flag for OpenSSL auto-detection\n            if [ -f lib/deimos-openssl/scripts/generate_version.d ]; then\n              flags+=(-version=DeimosOpenSSLAutoDetect)\n            fi\n\n            # Build all programs\n            for fn in src/dfeed/progs/*.d; do\n              name=$(basename \"$fn\" .d)\n              echo \"Building $name...\"\n              rdmd --compiler=dmd --build-only -of\"$name\" \"''${flags[@]}\" \"$fn\"\n            done\n\n            # Minify site-defaults resources (if not already minified)\n            HTMLTOOL=\"java -jar htmlcompressor-1.5.3.jar --compress-css\"\n            JSTOOL=\"java -jar yuicompressor-2.4.8.jar --type js\"\n            CSSTOOL=\"java -jar yuicompressor-2.4.8.jar --type css\"\n\n            for htt in site-defaults/web/*.htt; do\n              min=\"''${htt%.htt}.min.htt\"\n              if [ ! -f \"$min\" ] || [ \"$htt\" -nt \"$min\" ]; then\n                echo \"Minifying $htt...\"\n                $HTMLTOOL < \"$htt\" > \"$min\" || cp \"$htt\" \"$min\"\n              fi\n            done\n\n            for css in site-defaults/web/static/css/*.css; do\n              [[ \"$css\" == *.min.css ]] && continue\n              min=\"''${css%.css}.min.css\"\n              if [ ! -f \"$min\" ] || [ \"$css\" -nt \"$min\" ]; then\n                echo \"Minifying $css...\"\n                $CSSTOOL < \"$css\" > \"$min\" || cp \"$css\" \"$min\"\n              fi\n            done\n\n            for js in site-defaults/web/static/js/*.js; do\n              [[ \"$js\" == *.min.js ]] && continue\n              min=\"''${js%.js}.min.js\"\n              if [ ! -f \"$min\" ] || [ \"$js\" -nt \"$min\" ]; then\n                echo \"Minifying $js...\"\n                $JSTOOL < \"$js\" > \"$min\" || cp \"$js\" \"$min\"\n              fi\n            done\n\n            runHook postBuild\n          '';\n\n          installPhase = ''\n            runHook preInstall\n\n            mkdir -p $out/bin\n            mkdir -p $out/share/dfeed\n\n            # Install binaries\n            for prog in dfeed nntpdownload sendspamfeedback unban; do\n              if [ -f \"$prog\" ]; then\n                install -Dm755 \"$prog\" $out/bin/\"$prog\"\n              fi\n            done\n\n            # Install site-defaults (generic resources)\n            cp -r site-defaults $out/share/dfeed/\n\n            runHook postInstall\n          '';\n\n          meta = with pkgs.lib; {\n            description = \"D news aggregator, newsgroup client, web newsreader and IRC bot\";\n            homepage = \"https://github.com/CyberShadow/DFeed\";\n            license = licenses.agpl3Plus;\n            platforms = platforms.linux;\n            maintainers = [ ];\n          };\n        };\n\n        # Generate screenshots from Playwright tests\n        packages.screenshots = pkgs.stdenv.mkDerivation {\n          pname = \"dfeed-screenshots\";\n          version = \"unstable\";\n\n          src = self;\n\n          nativeBuildInputs = with pkgs; [\n            playwright-test\n            curl\n            sqlite\n            # Fonts for proper rendering in screenshots\n            liberation_ttf\n            dejavu_fonts\n            freefont_ttf\n          ];\n\n          HOME = \"/tmp/playwright-home\";\n          FONTCONFIG_FILE = pkgs.makeFontsConf {\n            fontDirectories = with pkgs; [\n              liberation_ttf\n              dejavu_fonts\n              freefont_ttf\n            ];\n          };\n\n          buildPhase = ''\n            runHook preBuild\n\n            ${generateTestConfig}\n\n            ${startServer}\n\n            cd tests\n            playwright test --project=screenshots --reporter=list || true\n            cd ..\n\n            ${stopServer}\n\n            runHook postBuild\n          '';\n\n          installPhase = ''\n            mkdir -p $out\n            cp tests/screenshot-*.png $out/ 2>/dev/null || echo \"No screenshots found\"\n            ls -la $out/\n          '';\n        };\n\n        # Development shell for working on the project\n        devShells.default = pkgs.mkShell {\n          buildInputs = with pkgs; [\n            dmd\n            dtools\n            dub\n            curl\n            sqlite\n            openssl\n            jre_minimal\n            gnumake\n            git\n          ];\n\n          shellHook = ''\n            echo \"DFeed development environment\"\n            echo \"DMD version: $(dmd --version | head -1)\"\n          '';\n        };\n\n        # Checks to run with 'nix flake check'\n        checks = {\n          # Verify that the package builds successfully\n          build = self.packages.${system}.default;\n\n          # Run Playwright end-to-end tests\n          playwright = pkgs.stdenv.mkDerivation {\n            pname = \"dfeed-playwright-tests\";\n            version = \"unstable\";\n\n            src = self;\n\n            nativeBuildInputs = with pkgs; [\n              playwright-test\n              curl\n              sqlite\n            ];\n\n            # Playwright needs writable home for cache\n            HOME = \"/tmp/playwright-home\";\n\n            buildPhase = ''\n              runHook preBuild\n\n              ${generateTestConfig}\n\n              ${startServer}\n\n              # Run Playwright tests\n              cd tests\n              playwright test --project=default --reporter=list || TEST_RESULT=$?\n              cd ..\n\n              ${stopServer}\n\n              # Check test result\n              if [ \"''${TEST_RESULT:-0}\" != \"0\" ]; then\n                echo \"Playwright tests failed\"\n                exit 1\n              fi\n\n              runHook postBuild\n            '';\n\n            installPhase = ''\n              mkdir -p $out\n              echo \"Playwright tests passed\" > $out/result\n            '';\n          };\n\n          # Run D unittests\n          unittests = pkgs.stdenv.mkDerivation {\n            pname = \"dfeed-unittests\";\n            version = \"unstable\";\n\n            src = self;\n\n            nativeBuildInputs = with pkgs; [\n              dmd\n              dtools\n            ];\n\n            buildInputs = with pkgs; [\n              curl\n              sqlite\n              openssl\n            ];\n\n            buildPhase = ''\n              runHook preBuild\n\n              export DCOMPILER=dmd\n\n              # Detect OpenSSL version\n              if [ -f lib/deimos-openssl/scripts/generate_version.d ]; then\n                echo \"Generating OpenSSL version detection...\"\n                rdmd --compiler=dmd lib/deimos-openssl/scripts/generate_version.d\n              fi\n\n              # Compile library with unittests\n              echo \"Compiling and running unittests...\"\n              dmd -unittest -main -i -Isrc -Ilib -L-lcurl -L-lsqlite3 -L-lssl -L-lcrypto \\\n                $(find src -name \"*.d\" | grep -v \"src/dfeed/progs/\") \\\n                -version=DeimosOpenSSLAutoDetect \\\n                -od=unittest-obj -of=unittest-runner\n\n              runHook postBuild\n            '';\n\n            checkPhase = ''\n              echo \"Running unittests...\"\n              ./unittest-runner\n            '';\n\n            installPhase = ''\n              mkdir -p $out\n              echo \"Unittests passed\" > $out/result\n            '';\n\n            doCheck = true;\n          };\n        };\n      }\n    );\n}\n"
  },
  {
    "path": "makejson",
    "content": "#!/bin/bash\nset -eu\n\ngit ls-files | grep '^src/.*\\.d$' | xargs dmd -Xfdfeed.json -o-\n"
  },
  {
    "path": "schema_v1.sql",
    "content": "-- Initial version of the database schema.\n-- See src/dfeed/database.d for updates since this initial revision.\n\n-- Table `Groups`\nCREATE TABLE [Groups] (\n[Group] VARCHAR(50)  NULL,\n[ArtNum] INTEGER  NULL,\n[ID] VARCHAR(50)  NULL\n, Time INTEGER);\n\n-- Table `Posts`\nCREATE TABLE [Posts] (\n[ID] VARCHAR(50)  NULL,\n[Message] TEXT  NULL,\n[Author] VARCHAR(255)  NULL,\n[Subject] VARCHAR(255)  NULL,\n[Time] INTEGER  NULL,\n[ParentID] VARCHAR(50)  NULL,\n[ThreadID] VARCHAR(50)  NULL\n, [AuthorEmail] VARCHAR(50));\n\n-- Table `Threads`\nCREATE TABLE [Threads] (\n[Group] VARCHAR(50)  NULL,\n[ID] VARCHAR(50)  NULL,\n[LastUpdated] INTEGER  NULL\n, LastPost VARCHAR(50), [Created] INTEGER NULL);\n\n-- Index `PostThreadID` on table `Posts`\nCREATE INDEX [PostThreadID] ON [Posts](\n[ThreadID]  ASC\n);\n\n-- Index `ThreadGroup` on table `Threads`\nCREATE INDEX [ThreadGroup] ON [Threads] ( [Group] );\n\n-- Index `GroupTime` on table `Groups`\nCREATE INDEX GroupTime ON Groups (`Group`, Time DESC);\n\n-- Index `ThreadOrder` on table `Threads`\nCREATE INDEX ThreadOrder ON Threads ([Group], [LastUpdated] DESC);\n\n-- Index `GroupID` on table `Groups`\nCREATE UNIQUE INDEX [GroupID] ON [Groups](\n[Group]  ASC,\n[ID]  ASC\n);\n\n-- Index `PostID` on table `Posts`\nCREATE UNIQUE INDEX [PostID] ON \"Posts\"(\n[ID]  ASC\n);\n\n-- Index `ThreadID` on table `Threads`\nCREATE INDEX \"ThreadID\" ON \"Threads\" ( ID );\n\n-- Index `PostParentID` on table `Posts`\nCREATE INDEX PostParentID ON Posts ( ParentID );\n\n-- Table `Users`\nCREATE TABLE [Users] ( [Username] VARCHAR(50), [Password] VARCHAR(50), [Session] VARCHAR(50) , [Level] INTEGER NOT NULL DEFAULT 0, [Created] INTEGER);\n\n-- Index `UserName` on table `Users`\nCREATE UNIQUE INDEX [UserName] ON [Users] ( [Username] );\n\n-- Table `UserSettings`\nCREATE TABLE [UserSettings] ( [User] VARCHAR(50), [Name] VARCHAR(50), [Value] TEXT );\n\n-- Index `UserSetting` on table `UserSettings`\nCREATE UNIQUE INDEX [UserSetting] on [UserSettings] ( [User], [Name] );\n\n-- Index `GroupArtNum` on table `Groups`\nCREATE INDEX [GroupArtNum] ON [Groups] ( [Group], [ArtNum] );\n\n-- Index `PostTime` on table `Posts`\nCREATE INDEX [PostTime] ON [Posts] ( [Time] DESC );\n\n-- Table `Drafts`\nCREATE TABLE [Drafts] ([UserID] VARCHAR(20) NOT NULL, [ID] VARCHAR(20) NOT NULL, [PostID] VARCHAR(20) NULL, [Status] INTEGER NOT NULL, [ClientVars] TEXT NOT NULL, [ServerVars] TEXT NULL, [Time] INTEGER NOT NULL);\n\n-- Index `DraftID` on table `Drafts`\nCREATE UNIQUE INDEX [DraftID] ON [Drafts] ([ID]);\n\n-- Index `DraftUserID` on table `Drafts`\nCREATE INDEX [DraftUserID] ON [Drafts] ([UserID], [Status]);\n\n-- Index `DraftPostID` on table `Drafts`\nCREATE UNIQUE INDEX [DraftPostID] ON [Drafts] ([PostID]);\n\n-- Table `Subscriptions`\nCREATE TABLE [Subscriptions] (\n[ID] VARCHAR(20) NOT NULL PRIMARY KEY,\n[Username] VARCHAR(50) NOT NULL,\n[Data] TEXT NULL\n);\n\n-- Table `ReplyTriggers`\nCREATE TABLE [ReplyTriggers] ([Email] VARCHAR(50) NOT NULL, [SubscriptionID] VARCHAR(20) NOT NULL);\n\n-- Index `ReplyTriggerSubscripion` on table `ReplyTriggers`\nCREATE UNIQUE INDEX [ReplyTriggerSubscripion] ON [ReplyTriggers] ([SubscriptionID]);\n\n-- Index `ReplyTriggerEmail` on table `ReplyTriggers`\nCREATE INDEX [ReplyTriggerEmail] ON [ReplyTriggers] ([Email]);\n\n-- Table `ThreadTriggers`\nCREATE TABLE [ThreadTriggers] ([ThreadID] VARCHAR(50) NOT NULL, [SubscriptionID] VARCHAR(20) NOT NULL);\n\n-- Index `ThreadTriggerSubscription` on table `ThreadTriggers`\nCREATE UNIQUE INDEX [ThreadTriggerSubscription] ON [ThreadTriggers] ([SubscriptionID]);\n\n-- Index `ThreadTriggerThreadID` on table `ThreadTriggers`\nCREATE INDEX [ThreadTriggerThreadID] ON [ThreadTriggers] ([ThreadID]);\n\n-- Table `ContentTriggers`\nCREATE TABLE [ContentTriggers] ([SubscriptionID] VARCHAR(20) NOT NULL PRIMARY KEY);\n\n-- Table `SubscriptionPosts`\nCREATE TABLE [SubscriptionPosts] (\n[SubscriptionID] VARCHAR(20) NOT NULL,\n[MessageID] VARCHAR(50) NOT NULL,\n[MessageRowID] INTEGER NOT NULL,\n[Time] INTEGER NOT NULL\n);\n\n-- Index `SubscriptionPostID` on table `SubscriptionPosts`\nCREATE INDEX [SubscriptionPostID] ON [SubscriptionPosts] ([SubscriptionID], [Time] DESC);\n\n-- Table `PostSearch`\nCREATE VIRTUAL TABLE [PostSearch] USING fts4([Time], [ThreadMD5], [Group], [Author], [AuthorEmail], [Subject], [Content], [NewThread], order=desc);\n\n-- Index `ThreadCreated` on table `Threads`\nCREATE INDEX [ThreadCreated] ON [Threads] ([Created] DESC);\n\n-- Index `PostAuthorEmail` on table `Posts`\nCREATE INDEX [PostAuthorEmail] ON [Posts] ([AuthorEmail]);\n\n-- Table `Flags`\nCREATE TABLE [Flags] ([PostID] VARCHAR(50), [Username] VARCHAR(50), [Date] INTEGER);\n\n-- Index `UserFlags` on table `Flags`\nCREATE INDEX [UserFlags] ON [Flags] ([Username], [PostID]);\n\n"
  },
  {
    "path": "site-defaults/config/apis/akismet.ini.sample",
    "content": "# Akismet (http://akismet.com/) key for spam checks.\n#key = abcdefghijkl\n"
  },
  {
    "path": "site-defaults/config/apis/bitly.ini.sample",
    "content": "# bitly.com API credentials. Used for shortening links for IRC.\n#login = your_username\n#apiKey = R_0123456789abcdef0123456789abcdef\n"
  },
  {
    "path": "site-defaults/config/apis/mailhide.ini.sample",
    "content": "# Google MailHide API. Not currently used.\n#publicKey  = ABCDEFGHIJKLMNOPQRSTUVWX==\n#privateKey = 0123456789abcdef0123456789abcdef\n"
  },
  {
    "path": "site-defaults/config/apis/openai.ini.sample",
    "content": "# OpenAI API configuration for spam detection\n# Sign up at https://platform.openai.com/ to get an API key\n\n# API key (required)\n#apiKey = sk-proj-...\n\n# Model to use (default: gpt-4o-mini)\n# Supported models: gpt-4o-mini, gpt-4o, gpt-4-turbo, gpt-3.5-turbo\n# Note: We use logprobs for confidence assessment, so reasoning models (o1/o3) are not supported\n#model = gpt-4o-mini\n"
  },
  {
    "path": "site-defaults/config/apis/projecthoneypot.ini.sample",
    "content": "# Project Honey Pot (https://www.projecthoneypot.org/) API key for spam checks.\n#key=abcdefghijkl\n"
  },
  {
    "path": "site-defaults/config/apis/recaptcha.ini.sample",
    "content": "# reCAPTCHA API \t\npublicKey  = ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn\nprivateKey = ABCDEFGHIJKMLMOPQRSTUVWXYZabcdefghijklmn\n"
  },
  {
    "path": "site-defaults/config/apis/stopforumspam.ini",
    "content": "# StopForumSpam spam checker configuration.\n# This service checks poster IP addresses against a database of known spammers.\n# See: https://www.stopforumspam.com/\n\n# Whether to enable StopForumSpam checking.\n# Set to false to disable (e.g., in environments without network access).\nenabled = true\n"
  },
  {
    "path": "site-defaults/config/backup.ini.sample",
    "content": "# Incremental daily backups using xdelta3\n# Copy this file to backup.ini to enable.\n\n# Time of day to run the backup.\n# Hour (0-23):\nhour = 7\n# Minute (0-59):\nminute = 0\n"
  },
  {
    "path": "site-defaults/config/groups.ini",
    "content": "# Default DFeed groups configuration\n# For testing and as an example. Site deployments should create their own.\n\n[sets.general]\nname=General\nshortName=General\nvisible=true\n\n[groups.general]\ninternalName=general\npublicName=General Discussion\nnavName=General\nurlName=general\ngroupSet=general\ndescription=General discussion forum\nsinkType=local\nannounce=false\ncaptcha=none\n\n[sets.test]\nname=Test\nshortName=Test\nvisible=true\n\n[groups.test]\ninternalName=test\npublicName=Test Forum\nnavName=Test\nurlName=test\ngroupSet=test\ndescription=A test forum for trying out posting\nsinkType=local\nannounce=false\ncaptcha=none\n"
  },
  {
    "path": "site-defaults/config/groups.ini.sample",
    "content": "# The groups, as displayed on the web interface's index page.\n\n# Groups are arranged in sets.\n[sets.example-set]\n\n# Long name (shown on index page)\nname=Example set\n\n# Short name (shown in navigation)\nshortName=Example set\n\n# Example group.\n[groups.example]\n\n# Name used by the mailing lists, NNTP servers, etc.\ninternalName=example\n\n# Name visible on web pages.\npublicName=Example\n\n# urlName is what appears in URLs.\nurlName=example\n\n# description is displayed on the index page.\ndescription=Example group\n\n# ID (section name) of the set this group appears in.\ngroupSet=example-set\n\n# alsoVia is an optional set of links for other ways to access this group.\nalsoVia.nntp.name=NNTP\nalsoVia.nntp.url=news://news.example.com/example\n\n# How posted messages are propagated.\n# sinkType can be smtp or nntp.\n#\n# The corresponding configuration file from\n# config/sinks/<sinkType>/<sinkName>.ini\n# will be consulted.\nsinkType=smtp\nsinkName=example\n\n# Whether to show a warning that a subscription is required\n# when attempting to post to a mailing list (sinkType==smtp).\n# Enabled by default.\nsubscriptionRequired=true\n\n# Whether new threads in the post are considered very important, and\n# will be announced to e.g. Twitter (if configured).\nannounce=false\n\n# CAPTCHA mechanism to use for challenging users who post\n# messages which triggered a spam filter.\n# Valid values are:\n# - none - no CAPTCHA, messages go directly to the moderation queue\n# - recaptcha - Google's reCAPTCHA service, requires configuring keys\n# - dcaptcha - randomly generated D Programming Language questions\ncaptcha=none\n"
  },
  {
    "path": "site-defaults/config/sinks/irc/irc.ini.sample",
    "content": "# IRC sink parameters.\n\n# IRC server to connect to.\n#server = irc.libera.chat\n\n# Port. Defaults to 6667.\n#port = 6667\n\n# Nickname to use.\n#nick = DFeed\\My\\Test\n\n# Primary channel - only \"important\" announcements will be posted here.\n#channel  = dfeed.test\n\n# Secondary (feed) channel, for all announcements.\n#channel2 = dfeed.test2\n"
  },
  {
    "path": "site-defaults/config/sinks/twitter/twitter.ini.sample",
    "content": "# Twitter sink parameters.\n\n# OAuth parameters.\noauth.consumerKey = abcdefghijklmnopqrstuvwxy\noauth.consumerSecret = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX\noauthAccessToken = 123456789012345678-abcdefghijklmnopqrstuvwxyzABCDE\noauthAccessTokenSecret = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRS\n\n# How to format the tweet, as std.string.format arguments.\n# Arguments passed are, in order: subject, author name, URL\nformatString = %s by %s: %s #dlang\n"
  },
  {
    "path": "site-defaults/config/site.ini.sample",
    "content": "# The name of the website. Used in emails.\n\nname = Example Forum\n\n# The canonical domain name for your instance.\n# This is used in a number of places:\n# - Requests coming to other domain names will be redirected to the canonical one.\n# - Message IDs for new messages will use this setting for the hostname part.\n# - External URLs to web resources (e.g. in IRC announcements) will use this setting for the host part.\n# Default is \"localhost\".\n\nhost = forum.example.com\n\n# Default HTTP URI protocol (http or https).\n# Used as above. When set to https, will redirect detected http requests to https.\n\nproto = http\n\n# A description of this DFeed instance to be shown on the help page (in the About section).\n\nabout = <p>This is an example DFeed instance.</p>\n\n# OpenGraph image URL for social media previews.\n# If not set, no image will be used in OpenGraph metadata.\n\nogImage =\n\n# List of email addresses of site moderators.\n# These will be used for notifications when a post is flagged.\n# E.g.: moderators=[\"John Doe <johndoe@example.tld>\", \"Jane Doe <janedoe@example.tld>\"]\n\nmoderators=[]\n"
  },
  {
    "path": "site-defaults/config/sources/feeds/example.ini.sample",
    "content": "# Atom feed example configuration.\n\n# Feed title - used in IRC announcements.\n#name = Example\n\n# Direct URL to the ATOM XML file.\n#url = http://www.example.com/feeds/atom.xml\n\n# Action (verb used in IRC announcements).\n# The announcements is formatted like \"<author> <verb> <title>\".\n# If left empty, as a special case, the author and verb is not included.\n# Defaults to \"posted\".\n#action = posted\n"
  },
  {
    "path": "site-defaults/config/sources/github/github.ini.sample",
    "content": "# GitHub hook secret value\n#secret = \n"
  },
  {
    "path": "site-defaults/config/sources/mailman/example.ini.sample",
    "content": "# Mailman HTTP archives.\nbaseURL = http://lists.example.com/pipermail/\nlists = general,announcements\n"
  },
  {
    "path": "site-defaults/config/sources/mailrelay/example.ini.sample",
    "content": "# Messages coming from other sources (e.g. email, for mailing lists)\n# can be piped to the address/port configured here.\n# DFeed will accept one message per connection, and process it as usual.\n#addr = 127.0.0.1\n#port = 12345\n\n# Exim configuration:\n# Create an alias file with the content:\n# | nc -q 0 127.0.0.1 12345\n\n# qmail/vpopmail configuration: \n# For address foo@bar.com, create the file\n# ~vpopmail/domains/bar.com/.qmail-foo\n# with the content:\n# | nc -q 0 127.0.0.1 12345\n"
  },
  {
    "path": "site-defaults/config/sources/nntp/example.ini.sample",
    "content": "# NNTP source.\nhost = news.example.com\n\n# Whether posting is allowed.\n# This SHOULD be configured in groups.ini, but it can be disabled here as well.\n# Note that currently this will work only as a last-minute check,\n# i.e. the user will be notified only as they attempt to send the\n# message.\npostingAllowed = true\n\n# Command to delete messages from the source NNTP server.\n# If specified, an option will appear to delete messages from the\n# source in the post moderation UI.\n# The command is invoked with the first argument being the message ID,\n# and the following arguments being the group:article-number tuples\n# (as they appear in the Xref header).\n#deleteCommand = /path/to/script.sh\n"
  },
  {
    "path": "site-defaults/config/sources/smtp/example.ini.sample",
    "content": "# Mailing list \"source\" for sent messages.\n\n# Mailing list domain name (part after @ for email addresses).\ndomain = example.com\n\n# MX server (telnet to this host and port 25 should succeed).\nserver = mail.example.com\n\n# Port (default = 25).\nport = 25\n\n# URL base for list info\nlistInfo = http://lists.example.com/cgi-bin/mailman/\n"
  },
  {
    "path": "site-defaults/config/sources/socket/socket.ini.sample",
    "content": "# This is a special source, currently used for\n# instant MediaWiki edit notifications,\n# but can be further expanded as needed.\n# See socket.d for details.\n#port = 12345\n#password = abcdefghij\n"
  },
  {
    "path": "site-defaults/config/sources/stackoverflow/so.ini.sample",
    "content": "# StackOverflow questions feed.\n\n# Comma-separated list of tags to watch.\ntags = d\n\n# Optional - API key.\n# Requests will be rate-limited without one.\n#key = abcdefghijklmnopqrstuvwx\n"
  },
  {
    "path": "site-defaults/config/user.ini.sample",
    "content": "# A unique string used in hashing user passwords.\n# salt = insert random string here\n"
  },
  {
    "path": "site-defaults/config/web.ini.sample",
    "content": "# HTTP header to use for the client's real IP address when behind a reverse proxy.\n# Common values: \"X-Forwarded-For\", \"X-Real-IP\"\n# If not set, the direct connection IP is used.\n#remoteIPHeader = X-Forwarded-For\n\n# Optional additional cookie-less domain used for static resources.\n# Should not be a subdomain of the main domain.\n#staticDomain = example-static.com\n\n# Whether this instance should be indexed by search engines\n# (or crawled by other web spiders).\n# This affects what is served in robots.txt.\n# The default is false.\n#indexable = true\n\n# API secret for programmatic access (e.g., for moderation APIs).\n# If not set, API endpoints will be disabled.\n#apiSecret = your-secret-here\n\n# Widget configuration for the home page.\n\n# Group to use for the \"Latest announcements\" widget.\n# If not set, the widget will not be displayed.\n#announceGroup = announce\n\n# Groups to exclude from the \"Active discussions\" widget.\n# Use an array format: [\"group1\", \"group2\"]\n#activeDiscussionExclude = [\"announce\"]\n\n# HTTP socket parameters.\n[listen]\n\n# Network address to bind to. By default, binds on all interfaces.\n# addr = 127.0.0.1\n\n# Port to listen on.\nport = 8080\n"
  },
  {
    "path": "site-defaults/web/help-english.htt",
    "content": "<!-- HTML -->\n<h2 id=\"view-modes\">View modes</h2>\n\n<p>\n  You can browse the forum using one of several view modes:\n  <ul>\n    <li><b>Basic</b> - A forum-like view with paged linear threads.</li>\n    <li><b>Threaded</b> - Threaded group overview with single post display, similar to mailing list archives.</li>\n    <li><b>Horizontal-split</b> - JavaScript-powered interface with a split view, similar to a usenet client.</li>\n    <li><b>Vertical-split</b> - A view with a list pane at the top and a message pane at the bottom, resembling a desktop mail client.</li>\n  </ul>\n\n  The view mode can be changed on the <a href=\"/settings\">settings page</a>.\n</p>\n\n<h2 id=\"keynav\">Keyboard navigation</h2>\n\n<p>\n  Keyboard shortcuts are available for all view modes (in thread and post listing pages, as well as the forum index).\n  If JavaScript is enabled, press <kbd>?</kbd> to view a list of shortcuts.\n</p>\n\n<p>\n  If you wish, you can disable keyboard shortcuts on the <a href=\"/settings\">settings page</a>.\n</p>\n\n<h2 id=\"read-post-history\">Read post history</h2>\n\n<p>\n  The posts you've viewed are saved to a compressed cookie, or on the server if you're logged in.\n  Viewing a thread in basic view will mark all displayed posts as \"read\".\n  Posts can be marked as \"unread\" using the <kbd>u</kbd> keyboard shortcut.\n</p>\n<p>\n  To avoid losing read post history, consider registering an account to avoid cookie limitations / expiration / accidental deletion.\n</p>\n\n<h2 id=\"accounts\">Accounts</h2>\n\n<p>\n  You do not need an account to browse or post to this forum.\n  Preferences and read post history are stored in browser cookies for unregistered users.\n</p>\n\n<p>\n  You can register an account to keep them on the server instead.\n  Registering an account will transfer all variables from cookies to the server database.\n</p>\n\n<p>\n  Creating an account will also allow you to create subscriptions, and be notified by IRC or email of replies to your posts, or other events.\n</p>\n\n<h2 id=\"email\">Email address</h2>\n\n<p>\n  When posting, you need to indicate an email address.\n  It doesn't need to be a valid one; this software will not send anything to the specified address.\n  The email address will be made public to other users of the news server / mailing list you are posting to.\n  Therefore, please be aware that malicious robots may be able to collect your address and send spam to it.\n</p>\n\n<p>\n  The email address is also used to display an avatar (see below).\n</p>\n\n<h2 id=\"markdown\">Markdown formatting</h2>\n\n<p>\n  You may optionally use Markdown formatting when authoring posts.\n  The specific variant of Markdown used is <a href=\"https://github.github.com/gfm/#what-is-github-flavored-markdown-\">GitHub Flavored Markdown</a>.\n</p>\n\n<p>\n  The following is a quick guide for some available syntax:\n</p>\n\n<style>\n#forum-content table.help-table {\n\tborder-spacing: initial;\n\tmargin: 16px 0;\n\twidth: auto;\n}\n#forum-content table.help-table th {\n\tbackground-color: #F5F5F5;\n}\n#forum-content table.help-table th,\n#forum-content table.help-table td {\n\tborder: 1px solid #E6E6E6;\n\tpadding: 0.1em 0.3em;\n}\n</style>\n\n<table class=\"help-table\">\n <tr><th>Formatting</th><th>What you type</th><th>What you get</th></tr>\n <tr><td>Bold text</td><td><code>**sample text**</code></td><td> <b>sample text</b> </td></tr>\n <tr><td>Italic text</td><td><code>*sample text*</code></td><td> <i>sample text</i> </td></tr>\n <tr><td>Links</td><td><code>[GitHub](https://github.com/)</code></td><td> <a href=\"https://github.com/\">GitHub</a> </td></tr>\n <tr><td>Lists</td><td><code>- First item<br>- Second item</code></td><td> <ul><li>First item</li><li>Second item</li></ul></td></tr>\n <tr><td>Syntax<br>highlighting</td><td><code>```python<br>print(\"Hello world\")<br>```</code></td><td> <pre>print(<span style=\"color: red\">\"Hello world\"</span>)</pre> </td></tr>\n <tr><td>Tables</td><td><code>| A | B |<br>|---|---|<br>| 1 | 2 |<br>| 3 | 4 | </code></td><td> <table><tr><th>A</th><th>B</th></tr><tr><td>1</td><td>2</td></tr><tr><td>3</td><td>4</td></tr> </table> </td></tr>\n</table>\n\n<p>\n\tFor more information, consult <a href=\"https://guides.github.com/features/mastering-markdown/\">GitHub's documentation</a>\n\tor <a href=\"https://github.github.com/gfm/\">the full specification</a>,\n\tthough please note that not all GitHub extensions are enabled on this forum.\n</p>\n\n<p>\n\tMarkdown rendering may be completely disabled from the <a href=\"/settings\">settings page</a>.\n</p>\n\n<h2 id=\"avatars\">Avatars</h2>\n\n<p>\n  The forum will display avatars associated with users' email addresses.\n  If the email address is registered with <a href=\"http://en.gravatar.com/\">Gravatar</a>, the associated avatar is shown.\n  Otherwise, an <a href=\"https://en.wikipedia.org/wiki/Identicon\">Identicon</a> generated from a hash of the email address is displayed as a fallback.\n</p>\n\n<p>\n  To use a custom avatar on this forum,\n    <a href=\"http://en.gravatar.com/site/signup/\">register an account at Gravatar</a>,\n    associate an email address with an image,\n    and use that email address when posting to this forum.\n  Additionally, you can create a Gravatar profile, which will be accessible by clicking on your avatar.\n</p>\n\n<h2 id=\"profiles\">User profiles and signatures</h2>\n\n<p>\n  Since messages can come from a variety of sources, this forum does not have customizable user profiles.\n  Instead, you can create a <a href=\"http://en.gravatar.com/\">Gravatar</a> profile, as described in the <a href=\"#avatars\">Avatars</a> section above.\n  Click a user's avatar to go to their Gravatar profile page, assuming they have created one.\n</p>\n\n<p id=\"signatures\">\n  For similar reasons, this forum does not allow configuring a signature.\n  Signatures are not as useful in messages on the web today, and often devolve to a low signal-to-noise ratio.\n  Instead, you can put relevant information on your Gravatar profile, or on your website (and link to it from your Gravatar profile).\n</p>\n\n<h2 id=\"canonical\">Canonical links</h2>\n\n<p>\n  If you use the default (basic) view and would like to get a link to a particular post, please use the \"Permalink\" item located in the left sidebar\n  (by right-clicking it and selecting \"Copy link location\", or your browser's equivalent).\n  If you copy the contents of your browser's address bar, the resulting link may be excessively long, and may not work as well for users who have selected a different view mode.\n  A canonical link has the form <tt>https://<i>domain</i>/post/<i>message-id@goes-here</i></tt>, and does not contain <tt>/thread/</tt> or an URL fragment (<tt>#</tt> or any text following it).\n</p>\n\n<p>\n  To get the canonical link to a thread, just use the first post's canonical link.\n  If you use the \"threaded\" or \"horizontal-split\" view mode, you can simply copy the URL from your address bar.\n  Each post's title is also a canonical link to the post in question in any view mode.\n</p>\n\n<h2 id=\"drafts\">Drafts</h2>\n\n<p>\n  When you click \"Save and preview\", a draft of your message will be saved on the server.\n  If JavaScript is enabled, this will also occur periodically as you are typing the message.\n</p>\n\n<p>\n  If you accidentally close the browser tab with the message, you can restore it by opening a posting form\n  (by clicking \"Create thread\" or replying to a post).\n  A notice will appear before the form if there are any unsent drafts.\n  To discard a draft, click the \"Discard draft\" button at the bottom of the posting form.\n</p>\n\n<h2 id=\"about\">About</h2>\n\n<p>\n  This website is powered by DFeed, an NNTP / mailing list web frontend / forum software, news aggregator and IRC bot.\n  DFeed was written mostly by <a href=\"https://thecybershadow.net/\">Vladimir Panteleev</a>.\n  The source code is available under the <a href=\"http://www.gnu.org/licenses/agpl-3.0.html\">GNU Affero General Public License</a>\n    on GitHub: <a href=\"https://github.com/CyberShadow/DFeed\">https://github.com/CyberShadow/DFeed</a>\n</p>\n\n<?about?>\n\n<h2 id=\"contributing\">Contributing</h2>\n\n<p>\n  This forum software is open-source, and written in the D programming language.\n  Contributions are welcome. You can help improve this software by reporting bugs, giving feedback, and submitting pull requests.\n  Patches for fixes, improvements, documentation, unit tests, refactoring, etc. are all welcome.\n</p>\n<p>\n  To start working on DFeed, clone <a href=\"https://github.com/CyberShadow/DFeed\">the GitHub project</a>, and check the instructions in <a href=\"https://github.com/CyberShadow/DFeed/blob/master/README.md\">README.md</a> to get started.\n</p>\n\n"
  },
  {
    "path": "site-defaults/web/help-turkish.htt",
    "content": "<!-- HTML -->\n<h2 id=\"view-modes\">Görünüm seçenekleri</h2>\n\n<p>\n  Bu forumu farklı biçimlerde görüntüleyebilirsiniz:\n  <ul>\n    <li><b>Temel</b> - Konuların art arda sayfalar halinde listelendiği forum benzeri görünüm.</li>\n    <li><b>Gönderi listesi</b> - Konu gönderilerinin tek satırda gösterildiği genel görünüm.</li>\n    <li><b>Yatay bölünmüş</b> - Usenet programlarına benzer biçimde bölünmüş JavaScript destekli arayüz.</li>\n    <li><b>Dikey bölünmüş</b> - E-posta programlarına benzer biçimde üstte liste, altta ileti olan görünüm.</li>\n  </ul>\n\n  Görünüm, <a href=\"/settings\">ayarlar sayfasında</a> değiştirilebilir.\n</p>\n\n<h2 id=\"keynav\">Klavye kısayolları</h2>\n\n<p>\n  Tüm görünüm seçenekleri için klavye kısayolları mevcuttur (konu ve gönderi listelerinde ve forum dizininde).\n  Kısayolların listesini görüntülemek için <kbd>?</kbd> tuşuna basın. (JavaScript gerektirir.)\n</p>\n\n<p>\n  Klavye kısayollarını <a href=\"/settings\">ayarlar sayfasında</a> devre dışı bırakabilirsiniz.\n</p>\n\n<h2 id=\"read-post-history\">Okuma geçmişi</h2>\n\n<p>\n  Daha önce görüntülemiş olduğunuz gönderiler oturum açmışsanız sunucuya, açmamışsanız bir tarayıcı çerezine kaydedilir.\n  Bir konu temel görünümde görüntülendiğinde, görüntülenen tüm gönderiler \"okundu\" olarak işaretlenir.\n  Gönderiler <kbd>u</kbd> klavye kısayolu kullanılarak tekrar \"okunmamış\" olarak işaretlenebilir.\n</p>\n<p>\n  Bir hesap açmanız, okuma geçmişinizin çerezlerin uzunluk ve süre sınırlamaları veya yanlışlıkla silinmeleri nedeniyle kaybedilmesini önleyecektir.\n</p>\n\n<h2 id=\"accounts\">Kullanıcı hesapları</h2>\n\n<p>\n  Bu forumu okumak veya mesaj göndermek için hesap açmanıza gerek yok.\n  O durumda ayarlarınız ve okuma geçmişiniz bir tarayıcı çerezinde saklanır.\n</p>\n\n<p>\n  Bir hesap açtığınızda ise ayarlarınız ve okuma geçmişiniz sunucu tarafında saklanır.\n  Yeni bir hesabın açılması, tüm değişkenleri çerezlerden sunucu veritabanına aktaracaktır.\n</p>\n\n<p>\n  Bir hesabınızın olması, abonelikler oluşturabilmenize ve gönderilerinize verilen yanıtlar hakkında IRC veya e-posta yoluyla bilgilendirilmenize de olanak tanır.\n</p>\n\n<h2 id=\"email\">E-posta</h2>\n\n<p>\n  Mesaj gönderirken bir e-posta adresi belirtmeniz gerekmektedir. Ancak, bu adresin geçerli olması şart değildir çünkü bu forum sitesi belirtilen adrese hiçbir şey göndermez.\n  Belirttiğiniz e-posta adresi, mesaj gönderdiğiniz haber grubu veya e-posta listesi sunucusunun diğer kullanıcıları tarafından görülecektir.\n  Bu nedenle, verdiğiniz adresi kötü niyetli robotların da görebileceklerini ve bu adrese spam gönderebileceklerini unutmayın.\n</p>\n\n<p>\n  Belirttiğiniz e-posta adresi avatar görüntülemek için de kullanılır.\n</p>\n\n<h2 id=\"avatars\">Avatarlar</h2>\n\n<p>\n  Forum, kullanıcıların e-posta adresleriyle ilişkili avatarlar gösterir.\n  Kullanılan e-posta adresi <a href=\"http://tr.gravatar.com/\">Gravatar'da</a> kayıtlıysa oradaki avatar gösterilir.\n  Değilse, avatar yerine e-posta adresinden otomatik olarak oluşturulmuş bir <a href=\"https://en.wikipedia.org/wiki/Identicon\">Identicon</a> gösterilir.\n</p>\n\n<p>\n  Bu forumda avatar kullanmak için\n    <a href=\"http://tr.gravatar.com/site/signup/\">Gravatar'da bir hesap açın,</a>\n    orada bir e-posta adresini bir resimle ilişkilendirin,\n    ve bu foruma mesaj gönderirken o e-posta adresini kullanın.\n  Ek olarak, avatarınıza tıklandığında erişilen bir Gravatar profili de oluşturabilirsiniz.\n</p>\n\n<h2 id=\"profiles\">Kullanıcı profilleri ve bilgileri</h2>\n\n<p>\n  Mesajlar farklı kaynaklardan gelebildiğinden bu forum kullanıcı profili içermez.\n  Bunun yerine, yukarıdaki <a href=\"http://tr.gravatar.com/\">Avatarlar</a> bölümünde açıklandığı gibi bir <a href=\"#avatars\">Gravatar</a> profili oluşturabilirsiniz.\n  Gravatar profili olan kullanıcıların profillerine avatarlarına tıklayarak erişebilirsiniz.\n</p>\n\n<p id=\"signatures\">\n  Bu forum kullanıcı bilgilerini de benzer nedenlerden dolayı içermez.\n  Bunun yerine, ilgili bilgileri Gravatar profilinize veya kendi sitenize koyabilirsiniz ve Gravatar profilinizden sitenize bağlantı verebilirsiniz.\n</p>\n\n<h2 id=\"canonical\">Kalıcı bağlantılar</h2>\n\n<p>\n  Belirli bir gönderiye bağlantı almak istiyorsanız temel görünümü kullanırken sol kenar çubuğunda bulunan \"Kalıcı Bağlantı\"yı kullanın\n  (sağ tıklayıp tarayıcınızın bağlantı kopyalama olanağından yararlanarak).\n  Bunun yerine tarayıcınızın adres çubuğundaki bağlantıyı kopyalamak uygun olmayabilir çünkü o bağlantı aşırı derecede uzun olabildiği gibi, sizin seçmiş olduğunuzdan farklı görünüm kullanan kullanıcılar için de doğru çalışmayabilir.\n  Kalıcı bağlantılar <tt>https://<i>site.adresi</i>/post/<i>gönderi@kimliği</i></tt> biçimindedir ve <tt>/thread/</tt> veya URL parçası (<tt>#</tt> veya onu izleyen herhangi bir metin) içermez.\n</p>\n\n<p>\n  Bir konunun kalıcı bağlantısı için ilk gönderinin bağlantısını kullanmanız yeterlidir.\n  \"Gönderi listesi\" veya \"Yatay bölünmüş\" görünümü kullanıyorsanız adres çubuğundaki bağlantıyı kopyalayabilirsiniz.\n  Her gönderinin başlığı da görünüm seçeneğinden bağımsız olarak o gönderinin kalıcı bağlantısıdır.\n</p>\n\n<h2 id=\"drafts\">Taslaklar</h2>\n\n<p>\n  \"Kaydet ve önizle\"yi tıkladığınızda mesajınızın bir taslağı sunucuya kaydedilir.\n  JavaScript etkinse, bu, siz mesajı yazarken de kendiliğinden gerçekleşecektir.\n</p>\n\n<p>\n  Tarayıcı sekmesini henüz mesajı göndermeden yanlışlıkla kapatırsanız, mesajınızın taslağını yeni bir gönderme formu açarak\n  (\"Yeni konu aç\"ı tıklayarak veya bir gönderiyi yanıtlayarak) geri yükleyebilirsiniz.\n  Gönderilmemiş taslak varsa, gönderme formunun üstünde bir uyarı görünecektir.\n  Bir taslağı silmek için kayıt formunun altındaki \"Taslağı sil\" düğmesini tıklayın.\n</p>\n\n<h2 id=\"about\">About</h2>\n\n<p>\n  This website is powered by DFeed, an NNTP / mailing list web frontend / forum software, news aggregator and IRC bot.\n  DFeed was written mostly by <a href=\"https://thecybershadow.net/\">Vladimir Panteleev</a>.\n  The source code is available under the <a href=\"http://www.gnu.org/licenses/agpl-3.0.html\">GNU Affero General Public License</a>\n    on GitHub: <a href=\"https://github.com/CyberShadow/DFeed\">https://github.com/CyberShadow/DFeed</a>\n</p>\n\n<?about?>\n\n<h2 id=\"contributing\">Sizin katkılarınız</h2>\n\n<p>\n  D programlama dilinde yazılmış olan bu forum açık kaynaklıdır.\n  Katkılara açığız. Bu yazılımın gelişmesine yardımcı olmak için hatalarını bildirebilir, öneri getirebilir, ve \"pull request\" açabilirsiniz.\n  Düzeltme, iyileştirme, belgeleme, birim testleri, kodun yeniden düzenlenmesi vb. her türlü yardımı kabul etmekteyiz.\n</p>\n<p>\n  DFeed üzerinde çalışmaya başlamak için <a href=\"https://github.com/CyberShadow/DFeed\">GitHub projesini</a> klonlayın ve programı <a href=\"https://github.com/CyberShadow/DFeed/blob/master/README.md\">README.md</a> dosyasında açıklandığı gibi kendi ortamınızda başlatın.\n</p>\n"
  },
  {
    "path": "site-defaults/web/highlight-js/LICENSE",
    "content": "BSD 3-Clause License\n\nCopyright (c) 2006, Ivan Sagalaev.\nAll rights reserved.\n\nRedistribution and use in source and binary forms, with or without\nmodification, are permitted provided that the following conditions are met:\n\n* Redistributions of source code must retain the above copyright notice, this\n  list of conditions and the following disclaimer.\n\n* Redistributions in binary form must reproduce the above copyright notice,\n  this list of conditions and the following disclaimer in the documentation\n  and/or other materials provided with the distribution.\n\n* Neither the name of the copyright holder nor the names of its\n  contributors may be used to endorse or promote products derived from\n  this software without specific prior written permission.\n\nTHIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS \"AS IS\"\nAND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE\nIMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE\nDISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE\nFOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL\nDAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR\nSERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER\nCAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,\nOR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE\nOF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.\n"
  },
  {
    "path": "site-defaults/web/highlight-js/highlight.pack.js",
    "content": "/*\n  Highlight.js 10.7.1 (421b23b0)\n  License: BSD-3-Clause\n  Copyright (c) 2006-2021, Ivan Sagalaev\n*/\nvar hljs=function(){\"use strict\";function e(t){\nreturn t instanceof Map?t.clear=t.delete=t.set=()=>{\nthrow Error(\"map is read-only\")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{\nthrow Error(\"set is read-only\")\n}),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{var i=t[n]\n;\"object\"!=typeof i||Object.isFrozen(i)||e(i)})),t}var t=e,n=e;t.default=n\n;class i{constructor(e){\nvoid 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1}\nignoreMatch(){this.isMatchIgnored=!0}}function s(e){\nreturn e.replace(/&/g,\"&amp;\").replace(/</g,\"&lt;\").replace(/>/g,\"&gt;\").replace(/\"/g,\"&quot;\").replace(/'/g,\"&#x27;\")\n}function a(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t]\n;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const r=e=>!!e.kind\n;class l{constructor(e,t){\nthis.buffer=\"\",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){\nthis.buffer+=s(e)}openNode(e){if(!r(e))return;let t=e.kind\n;e.sublanguage||(t=`${this.classPrefix}${t}`),this.span(t)}closeNode(e){\nr(e)&&(this.buffer+=\"</span>\")}value(){return this.buffer}span(e){\nthis.buffer+=`<span class=\"${e}\">`}}class o{constructor(){this.rootNode={\nchildren:[]},this.stack=[this.rootNode]}get top(){\nreturn this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){\nthis.top.children.push(e)}openNode(e){const t={kind:e,children:[]}\n;this.add(t),this.stack.push(t)}closeNode(){\nif(this.stack.length>1)return this.stack.pop()}closeAllNodes(){\nfor(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)}\nwalk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){\nreturn\"string\"==typeof t?e.addText(t):t.children&&(e.openNode(t),\nt.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){\n\"string\"!=typeof e&&e.children&&(e.children.every((e=>\"string\"==typeof e))?e.children=[e.children.join(\"\")]:e.children.forEach((e=>{\no._collapse(e)})))}}class c extends o{constructor(e){super(),this.options=e}\naddKeyword(e,t){\"\"!==e&&(this.openNode(t),this.addText(e),this.closeNode())}\naddText(e){\"\"!==e&&this.add(e)}addSublanguage(e,t){const n=e.root\n;n.kind=t,n.sublanguage=!0,this.add(n)}toHTML(){\nreturn new l(this,this.options).value()}finalize(){return!0}}function g(e){\nreturn e?\"string\"==typeof e?e:e.source:null}\nconst u=/\\[(?:[^\\\\\\]]|\\\\.)*\\]|\\(\\??|\\\\([1-9][0-9]*)|\\\\./,h=\"[a-zA-Z]\\\\w*\",d=\"[a-zA-Z_]\\\\w*\",f=\"\\\\b\\\\d+(\\\\.\\\\d+)?\",p=\"(-?)(\\\\b0[xX][a-fA-F0-9]+|(\\\\b\\\\d+(\\\\.\\\\d*)?|\\\\.\\\\d+)([eE][-+]?\\\\d+)?)\",m=\"\\\\b(0b[01]+)\",b={\nbegin:\"\\\\\\\\[\\\\s\\\\S]\",relevance:0},E={className:\"string\",begin:\"'\",end:\"'\",\nillegal:\"\\\\n\",contains:[b]},x={className:\"string\",begin:'\"',end:'\"',\nillegal:\"\\\\n\",contains:[b]},v={\nbegin:/\\b(a|an|the|are|I'm|isn't|don't|doesn't|won't|but|just|should|pretty|simply|enough|gonna|going|wtf|so|such|will|you|your|they|like|more)\\b/\n},w=(e,t,n={})=>{const i=a({className:\"comment\",begin:e,end:t,contains:[]},n)\n;return i.contains.push(v),i.contains.push({className:\"doctag\",\nbegin:\"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):\",relevance:0}),i\n},y=w(\"//\",\"$\"),N=w(\"/\\\\*\",\"\\\\*/\"),R=w(\"#\",\"$\");var _=Object.freeze({\n__proto__:null,MATCH_NOTHING_RE:/\\b\\B/,IDENT_RE:h,UNDERSCORE_IDENT_RE:d,\nNUMBER_RE:f,C_NUMBER_RE:p,BINARY_NUMBER_RE:m,\nRE_STARTERS_RE:\"!|!=|!==|%|%=|&|&&|&=|\\\\*|\\\\*=|\\\\+|\\\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\\\?|\\\\[|\\\\{|\\\\(|\\\\^|\\\\^=|\\\\||\\\\|=|\\\\|\\\\||~\",\nSHEBANG:(e={})=>{const t=/^#![ ]*\\//\n;return e.binary&&(e.begin=((...e)=>e.map((e=>g(e))).join(\"\"))(t,/.*\\b/,e.binary,/\\b.*/)),\na({className:\"meta\",begin:t,end:/$/,relevance:0,\"on:begin\":(e,t)=>{\n0!==e.index&&t.ignoreMatch()}},e)},BACKSLASH_ESCAPE:b,APOS_STRING_MODE:E,\nQUOTE_STRING_MODE:x,PHRASAL_WORDS_MODE:v,COMMENT:w,C_LINE_COMMENT_MODE:y,\nC_BLOCK_COMMENT_MODE:N,HASH_COMMENT_MODE:R,NUMBER_MODE:{className:\"number\",\nbegin:f,relevance:0},C_NUMBER_MODE:{className:\"number\",begin:p,relevance:0},\nBINARY_NUMBER_MODE:{className:\"number\",begin:m,relevance:0},CSS_NUMBER_MODE:{\nclassName:\"number\",\nbegin:f+\"(%|em|ex|ch|rem|vw|vh|vmin|vmax|cm|mm|in|pt|pc|px|deg|grad|rad|turn|s|ms|Hz|kHz|dpi|dpcm|dppx)?\",\nrelevance:0},REGEXP_MODE:{begin:/(?=\\/[^/\\n]*\\/)/,contains:[{className:\"regexp\",\nbegin:/\\//,end:/\\/[gimuy]*/,illegal:/\\n/,contains:[b,{begin:/\\[/,end:/\\]/,\nrelevance:0,contains:[b]}]}]},TITLE_MODE:{className:\"title\",begin:h,relevance:0\n},UNDERSCORE_TITLE_MODE:{className:\"title\",begin:d,relevance:0},METHOD_GUARD:{\nbegin:\"\\\\.\\\\s*[a-zA-Z_]\\\\w*\",relevance:0},END_SAME_AS_BEGIN:e=>Object.assign(e,{\n\"on:begin\":(e,t)=>{t.data._beginMatch=e[1]},\"on:end\":(e,t)=>{\nt.data._beginMatch!==e[1]&&t.ignoreMatch()}})});function k(e,t){\n\".\"===e.input[e.index-1]&&t.ignoreMatch()}function M(e,t){\nt&&e.beginKeywords&&(e.begin=\"\\\\b(\"+e.beginKeywords.split(\" \").join(\"|\")+\")(?!\\\\.)(?=\\\\b|\\\\s)\",\ne.__beforeBegin=k,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords,\nvoid 0===e.relevance&&(e.relevance=0))}function O(e,t){\nArray.isArray(e.illegal)&&(e.illegal=((...e)=>\"(\"+e.map((e=>g(e))).join(\"|\")+\")\")(...e.illegal))\n}function A(e,t){if(e.match){\nif(e.begin||e.end)throw Error(\"begin & end are not supported with match\")\n;e.begin=e.match,delete e.match}}function L(e,t){\nvoid 0===e.relevance&&(e.relevance=1)}\nconst I=[\"of\",\"and\",\"for\",\"in\",\"not\",\"or\",\"if\",\"then\",\"parent\",\"list\",\"value\"]\n;function j(e,t,n=\"keyword\"){const i={}\n;return\"string\"==typeof e?s(n,e.split(\" \")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{\nObject.assign(i,j(e[n],t,n))})),i;function s(e,n){\nt&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split(\"|\")\n;i[n[0]]=[e,B(n[0],n[1])]}))}}function B(e,t){\nreturn t?Number(t):(e=>I.includes(e.toLowerCase()))(e)?0:1}\nfunction T(e,{plugins:t}){function n(t,n){\nreturn RegExp(g(t),\"m\"+(e.case_insensitive?\"i\":\"\")+(n?\"g\":\"\"))}class i{\nconstructor(){\nthis.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0}\naddRule(e,t){\nt.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]),\nthis.matchAt+=(e=>RegExp(e.toString()+\"|\").exec(\"\").length-1)(e)+1}compile(){\n0===this.regexes.length&&(this.exec=()=>null)\n;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(((e,t=\"|\")=>{let n=0\n;return e.map((e=>{n+=1;const t=n;let i=g(e),s=\"\";for(;i.length>0;){\nconst e=u.exec(i);if(!e){s+=i;break}\ns+=i.substring(0,e.index),i=i.substring(e.index+e[0].length),\n\"\\\\\"===e[0][0]&&e[1]?s+=\"\\\\\"+(Number(e[1])+t):(s+=e[0],\"(\"===e[0]&&n++)}return s\n})).map((e=>`(${e})`)).join(t)})(e),!0),this.lastIndex=0}exec(e){\nthis.matcherRe.lastIndex=this.lastIndex;const t=this.matcherRe.exec(e)\n;if(!t)return null\n;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n]\n;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){\nthis.rules=[],this.multiRegexes=[],\nthis.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){\nif(this.multiRegexes[e])return this.multiRegexes[e];const t=new i\n;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))),\nt.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){\nreturn 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){\nthis.rules.push([e,t]),\"begin\"===t.type&&this.count++}exec(e){\nconst t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex\n;let n=t.exec(e)\n;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{\nconst t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)}\nreturn n&&(this.regexIndex+=n.position+1,\nthis.regexIndex===this.count&&this.considerAll()),n}}\nif(e.compilerExtensions||(e.compilerExtensions=[]),\ne.contains&&e.contains.includes(\"self\"))throw Error(\"ERR: contains `self` is not supported at the top-level of a language.  See documentation.\")\n;return e.classNameAliases=a(e.classNameAliases||{}),function t(i,r){const l=i\n;if(i.isCompiled)return l\n;[A].forEach((e=>e(i,r))),e.compilerExtensions.forEach((e=>e(i,r))),\ni.__beforeBegin=null,[M,O,L].forEach((e=>e(i,r))),i.isCompiled=!0;let o=null\n;if(\"object\"==typeof i.keywords&&(o=i.keywords.$pattern,\ndelete i.keywords.$pattern),\ni.keywords&&(i.keywords=j(i.keywords,e.case_insensitive)),\ni.lexemes&&o)throw Error(\"ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) \")\n;return o=o||i.lexemes||/\\w+/,\nl.keywordPatternRe=n(o,!0),r&&(i.begin||(i.begin=/\\B|\\b/),\nl.beginRe=n(i.begin),i.endSameAsBegin&&(i.end=i.begin),\ni.end||i.endsWithParent||(i.end=/\\B|\\b/),\ni.end&&(l.endRe=n(i.end)),l.terminatorEnd=g(i.end)||\"\",\ni.endsWithParent&&r.terminatorEnd&&(l.terminatorEnd+=(i.end?\"|\":\"\")+r.terminatorEnd)),\ni.illegal&&(l.illegalRe=n(i.illegal)),\ni.contains||(i.contains=[]),i.contains=[].concat(...i.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>a(e,{\nvariants:null},t)))),e.cachedVariants?e.cachedVariants:S(e)?a(e,{\nstarts:e.starts?a(e.starts):null\n}):Object.isFrozen(e)?a(e):e))(\"self\"===e?i:e)))),i.contains.forEach((e=>{t(e,l)\n})),i.starts&&t(i.starts,r),l.matcher=(e=>{const t=new s\n;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:\"begin\"\n}))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:\"end\"\n}),e.illegal&&t.addRule(e.illegal,{type:\"illegal\"}),t})(l),l}(e)}function S(e){\nreturn!!e&&(e.endsWithParent||S(e.starts))}function P(e){const t={\nprops:[\"language\",\"code\",\"autodetect\"],data:()=>({detectedLanguage:\"\",\nunknownLanguage:!1}),computed:{className(){\nreturn this.unknownLanguage?\"\":\"hljs \"+this.detectedLanguage},highlighted(){\nif(!this.autoDetect&&!e.getLanguage(this.language))return console.warn(`The language \"${this.language}\" you specified could not be found.`),\nthis.unknownLanguage=!0,s(this.code);let t={}\n;return this.autoDetect?(t=e.highlightAuto(this.code),\nthis.detectedLanguage=t.language):(t=e.highlight(this.language,this.code,this.ignoreIllegals),\nthis.detectedLanguage=this.language),t.value},autoDetect(){\nreturn!(this.language&&(e=this.autodetect,!e&&\"\"!==e));var e},\nignoreIllegals:()=>!0},render(e){return e(\"pre\",{},[e(\"code\",{\nclass:this.className,domProps:{innerHTML:this.highlighted}})])}};return{\nComponent:t,VuePlugin:{install(e){e.component(\"highlightjs\",t)}}}}const D={\n\"after:highlightElement\":({el:e,result:t,text:n})=>{const i=H(e)\n;if(!i.length)return;const a=document.createElement(\"div\")\n;a.innerHTML=t.value,t.value=((e,t,n)=>{let i=0,a=\"\";const r=[];function l(){\nreturn e.length&&t.length?e[0].offset!==t[0].offset?e[0].offset<t[0].offset?e:t:\"start\"===t[0].event?e:t:e.length?e:t\n}function o(e){a+=\"<\"+C(e)+[].map.call(e.attributes,(function(e){\nreturn\" \"+e.nodeName+'=\"'+s(e.value)+'\"'})).join(\"\")+\">\"}function c(e){\na+=\"</\"+C(e)+\">\"}function g(e){(\"start\"===e.event?o:c)(e.node)}\nfor(;e.length||t.length;){let t=l()\n;if(a+=s(n.substring(i,t[0].offset)),i=t[0].offset,t===e){r.reverse().forEach(c)\n;do{g(t.splice(0,1)[0]),t=l()}while(t===e&&t.length&&t[0].offset===i)\n;r.reverse().forEach(o)\n}else\"start\"===t[0].event?r.push(t[0].node):r.pop(),g(t.splice(0,1)[0])}\nreturn a+s(n.substr(i))})(i,H(a),n)}};function C(e){\nreturn e.nodeName.toLowerCase()}function H(e){const t=[];return function e(n,i){\nfor(let s=n.firstChild;s;s=s.nextSibling)3===s.nodeType?i+=s.nodeValue.length:1===s.nodeType&&(t.push({\nevent:\"start\",offset:i,node:s}),i=e(s,i),C(s).match(/br|hr|img|input/)||t.push({\nevent:\"stop\",offset:i,node:s}));return i}(e,0),t}const $=e=>{console.error(e)\n},U=(e,...t)=>{console.log(\"WARN: \"+e,...t)},z=(e,t)=>{\nconsole.log(`Deprecated as of ${e}. ${t}`)},K=s,G=a,V=Symbol(\"nomatch\")\n;return(e=>{const n=Object.create(null),s=Object.create(null),a=[];let r=!0\n;const l=/(^(<[^>]+>|\\t|)+|\\n)/gm,o=\"Could not find the language '{}', did you forget to load/include a language module?\",g={\ndisableAutodetect:!0,name:\"Plain text\",contains:[]};let u={\nnoHighlightRe:/^(no-?highlight)$/i,\nlanguageDetectRe:/\\blang(?:uage)?-([\\w-]+)\\b/i,classPrefix:\"hljs-\",\ntabReplace:null,useBR:!1,languages:null,__emitter:c};function h(e){\nreturn u.noHighlightRe.test(e)}function d(e,t,n,i){let s=\"\",a=\"\"\n;\"object\"==typeof t?(s=e,\nn=t.ignoreIllegals,a=t.language,i=void 0):(z(\"10.7.0\",\"highlight(lang, code, ...args) has been deprecated.\"),\nz(\"10.7.0\",\"Please use highlight(code, options) instead.\\nhttps://github.com/highlightjs/highlight.js/issues/2277\"),\na=e,s=t);const r={code:s,language:a};M(\"before:highlight\",r)\n;const l=r.result?r.result:f(r.language,r.code,n,i)\n;return l.code=r.code,M(\"after:highlight\",l),l}function f(e,t,s,l){\nfunction c(e,t){const n=v.case_insensitive?t[0].toLowerCase():t[0]\n;return Object.prototype.hasOwnProperty.call(e.keywords,n)&&e.keywords[n]}\nfunction g(){null!=R.subLanguage?(()=>{if(\"\"===M)return;let e=null\n;if(\"string\"==typeof R.subLanguage){\nif(!n[R.subLanguage])return void k.addText(M)\n;e=f(R.subLanguage,M,!0,_[R.subLanguage]),_[R.subLanguage]=e.top\n}else e=p(M,R.subLanguage.length?R.subLanguage:null)\n;R.relevance>0&&(O+=e.relevance),k.addSublanguage(e.emitter,e.language)\n})():(()=>{if(!R.keywords)return void k.addText(M);let e=0\n;R.keywordPatternRe.lastIndex=0;let t=R.keywordPatternRe.exec(M),n=\"\";for(;t;){\nn+=M.substring(e,t.index);const i=c(R,t);if(i){const[e,s]=i\n;if(k.addText(n),n=\"\",O+=s,e.startsWith(\"_\"))n+=t[0];else{\nconst n=v.classNameAliases[e]||e;k.addKeyword(t[0],n)}}else n+=t[0]\n;e=R.keywordPatternRe.lastIndex,t=R.keywordPatternRe.exec(M)}\nn+=M.substr(e),k.addText(n)})(),M=\"\"}function h(e){\nreturn e.className&&k.openNode(v.classNameAliases[e.className]||e.className),\nR=Object.create(e,{parent:{value:R}}),R}function d(e,t,n){let s=((e,t)=>{\nconst n=e&&e.exec(t);return n&&0===n.index})(e.endRe,n);if(s){if(e[\"on:end\"]){\nconst n=new i(e);e[\"on:end\"](t,n),n.isMatchIgnored&&(s=!1)}if(s){\nfor(;e.endsParent&&e.parent;)e=e.parent;return e}}\nif(e.endsWithParent)return d(e.parent,t,n)}function m(e){\nreturn 0===R.matcher.regexIndex?(M+=e[0],1):(I=!0,0)}function b(e){\nconst n=e[0],i=t.substr(e.index),s=d(R,e,i);if(!s)return V;const a=R\n;a.skip?M+=n:(a.returnEnd||a.excludeEnd||(M+=n),g(),a.excludeEnd&&(M=n));do{\nR.className&&k.closeNode(),R.skip||R.subLanguage||(O+=R.relevance),R=R.parent\n}while(R!==s.parent)\n;return s.starts&&(s.endSameAsBegin&&(s.starts.endRe=s.endRe),\nh(s.starts)),a.returnEnd?0:n.length}let E={};function x(n,a){const l=a&&a[0]\n;if(M+=n,null==l)return g(),0\n;if(\"begin\"===E.type&&\"end\"===a.type&&E.index===a.index&&\"\"===l){\nif(M+=t.slice(a.index,a.index+1),!r){const t=Error(\"0 width match regex\")\n;throw t.languageName=e,t.badRule=E.rule,t}return 1}\nif(E=a,\"begin\"===a.type)return function(e){\nconst t=e[0],n=e.rule,s=new i(n),a=[n.__beforeBegin,n[\"on:begin\"]]\n;for(const n of a)if(n&&(n(e,s),s.isMatchIgnored))return m(t)\n;return n&&n.endSameAsBegin&&(n.endRe=RegExp(t.replace(/[-/\\\\^$*+?.()|[\\]{}]/g,\"\\\\$&\"),\"m\")),\nn.skip?M+=t:(n.excludeBegin&&(M+=t),\ng(),n.returnBegin||n.excludeBegin||(M=t)),h(n),n.returnBegin?0:t.length}(a)\n;if(\"illegal\"===a.type&&!s){\nconst e=Error('Illegal lexeme \"'+l+'\" for mode \"'+(R.className||\"<unnamed>\")+'\"')\n;throw e.mode=R,e}if(\"end\"===a.type){const e=b(a);if(e!==V)return e}\nif(\"illegal\"===a.type&&\"\"===l)return 1\n;if(L>1e5&&L>3*a.index)throw Error(\"potential infinite loop, way more iterations than matches\")\n;return M+=l,l.length}const v=N(e)\n;if(!v)throw $(o.replace(\"{}\",e)),Error('Unknown language: \"'+e+'\"')\n;const w=T(v,{plugins:a});let y=\"\",R=l||w;const _={},k=new u.__emitter(u);(()=>{\nconst e=[];for(let t=R;t!==v;t=t.parent)t.className&&e.unshift(t.className)\n;e.forEach((e=>k.openNode(e)))})();let M=\"\",O=0,A=0,L=0,I=!1;try{\nfor(R.matcher.considerAll();;){\nL++,I?I=!1:R.matcher.considerAll(),R.matcher.lastIndex=A\n;const e=R.matcher.exec(t);if(!e)break;const n=x(t.substring(A,e.index),e)\n;A=e.index+n}return x(t.substr(A)),k.closeAllNodes(),k.finalize(),y=k.toHTML(),{\nrelevance:Math.floor(O),value:y,language:e,illegal:!1,emitter:k,top:R}}catch(n){\nif(n.message&&n.message.includes(\"Illegal\"))return{illegal:!0,illegalBy:{\nmsg:n.message,context:t.slice(A-100,A+100),mode:n.mode},sofar:y,relevance:0,\nvalue:K(t),emitter:k};if(r)return{illegal:!1,relevance:0,value:K(t),emitter:k,\nlanguage:e,top:R,errorRaised:n};throw n}}function p(e,t){\nt=t||u.languages||Object.keys(n);const i=(e=>{const t={relevance:0,\nemitter:new u.__emitter(u),value:K(e),illegal:!1,top:g}\n;return t.emitter.addText(e),t})(e),s=t.filter(N).filter(k).map((t=>f(t,e,!1)))\n;s.unshift(i);const a=s.sort(((e,t)=>{\nif(e.relevance!==t.relevance)return t.relevance-e.relevance\n;if(e.language&&t.language){if(N(e.language).supersetOf===t.language)return 1\n;if(N(t.language).supersetOf===e.language)return-1}return 0})),[r,l]=a,o=r\n;return o.second_best=l,o}const m={\"before:highlightElement\":({el:e})=>{\nu.useBR&&(e.innerHTML=e.innerHTML.replace(/\\n/g,\"\").replace(/<br[ /]*>/g,\"\\n\"))\n},\"after:highlightElement\":({result:e})=>{\nu.useBR&&(e.value=e.value.replace(/\\n/g,\"<br>\"))}},b=/^(<[^>]+>|\\t)+/gm,E={\n\"after:highlightElement\":({result:e})=>{\nu.tabReplace&&(e.value=e.value.replace(b,(e=>e.replace(/\\t/g,u.tabReplace))))}}\n;function x(e){let t=null;const n=(e=>{let t=e.className+\" \"\n;t+=e.parentNode?e.parentNode.className:\"\";const n=u.languageDetectRe.exec(t)\n;if(n){const t=N(n[1])\n;return t||(U(o.replace(\"{}\",n[1])),U(\"Falling back to no-highlight mode for this block.\",e)),\nt?n[1]:\"no-highlight\"}return t.split(/\\s+/).find((e=>h(e)||N(e)))})(e)\n;if(h(n))return;M(\"before:highlightElement\",{el:e,language:n}),t=e\n;const i=t.textContent,a=n?d(i,{language:n,ignoreIllegals:!0}):p(i)\n;M(\"after:highlightElement\",{el:e,result:a,text:i\n}),e.innerHTML=a.value,((e,t,n)=>{const i=t?s[t]:n\n;e.classList.add(\"hljs\"),i&&e.classList.add(i)})(e,n,a.language),e.result={\nlanguage:a.language,re:a.relevance,relavance:a.relevance\n},a.second_best&&(e.second_best={language:a.second_best.language,\nre:a.second_best.relevance,relavance:a.second_best.relevance})}const v=()=>{\nv.called||(v.called=!0,\nz(\"10.6.0\",\"initHighlighting() is deprecated.  Use highlightAll() instead.\"),\ndocument.querySelectorAll(\"pre code\").forEach(x))};let w=!1;function y(){\n\"loading\"!==document.readyState?document.querySelectorAll(\"pre code\").forEach(x):w=!0\n}function N(e){return e=(e||\"\").toLowerCase(),n[e]||n[s[e]]}\nfunction R(e,{languageName:t}){\"string\"==typeof e&&(e=[e]),e.forEach((e=>{\ns[e.toLowerCase()]=t}))}function k(e){const t=N(e)\n;return t&&!t.disableAutodetect}function M(e,t){const n=e;a.forEach((e=>{\ne[n]&&e[n](t)}))}\n\"undefined\"!=typeof window&&window.addEventListener&&window.addEventListener(\"DOMContentLoaded\",(()=>{\nw&&y()}),!1),Object.assign(e,{highlight:d,highlightAuto:p,highlightAll:y,\nfixMarkup:e=>{\nreturn z(\"10.2.0\",\"fixMarkup will be removed entirely in v11.0\"),z(\"10.2.0\",\"Please see https://github.com/highlightjs/highlight.js/issues/2534\"),\nt=e,\nu.tabReplace||u.useBR?t.replace(l,(e=>\"\\n\"===e?u.useBR?\"<br>\":e:u.tabReplace?e.replace(/\\t/g,u.tabReplace):e)):t\n;var t},highlightElement:x,\nhighlightBlock:e=>(z(\"10.7.0\",\"highlightBlock will be removed entirely in v12.0\"),\nz(\"10.7.0\",\"Please use highlightElement now.\"),x(e)),configure:e=>{\ne.useBR&&(z(\"10.3.0\",\"'useBR' will be removed entirely in v11.0\"),\nz(\"10.3.0\",\"Please see https://github.com/highlightjs/highlight.js/issues/2559\")),\nu=G(u,e)},initHighlighting:v,initHighlightingOnLoad:()=>{\nz(\"10.6.0\",\"initHighlightingOnLoad() is deprecated.  Use highlightAll() instead.\"),\nw=!0},registerLanguage:(t,i)=>{let s=null;try{s=i(e)}catch(e){\nif($(\"Language definition for '{}' could not be registered.\".replace(\"{}\",t)),\n!r)throw e;$(e),s=g}\ns.name||(s.name=t),n[t]=s,s.rawDefinition=i.bind(null,e),s.aliases&&R(s.aliases,{\nlanguageName:t})},unregisterLanguage:e=>{delete n[e]\n;for(const t of Object.keys(s))s[t]===e&&delete s[t]},\nlistLanguages:()=>Object.keys(n),getLanguage:N,registerAliases:R,\nrequireLanguage:e=>{\nz(\"10.4.0\",\"requireLanguage will be removed entirely in v11.\"),\nz(\"10.4.0\",\"Please see https://github.com/highlightjs/highlight.js/pull/2844\")\n;const t=N(e);if(t)return t\n;throw Error(\"The '{}' language is required, but not loaded.\".replace(\"{}\",e))},\nautoDetection:k,inherit:G,addPlugin:e=>{(e=>{\ne[\"before:highlightBlock\"]&&!e[\"before:highlightElement\"]&&(e[\"before:highlightElement\"]=t=>{\ne[\"before:highlightBlock\"](Object.assign({block:t.el},t))\n}),e[\"after:highlightBlock\"]&&!e[\"after:highlightElement\"]&&(e[\"after:highlightElement\"]=t=>{\ne[\"after:highlightBlock\"](Object.assign({block:t.el},t))})})(e),a.push(e)},\nvuePlugin:P(e).VuePlugin}),e.debugMode=()=>{r=!1},e.safeMode=()=>{r=!0\n},e.versionString=\"10.7.1\";for(const e in _)\"object\"==typeof _[e]&&t(_[e])\n;return Object.assign(e,_),e.addPlugin(m),e.addPlugin(D),e.addPlugin(E),e})({})\n}();\"object\"==typeof exports&&\"undefined\"!=typeof module&&(module.exports=hljs);hljs.registerLanguage(\"java\",(()=>{\"use strict\"\n;var e=\"\\\\.([0-9](_*[0-9])*)\",n=\"[0-9a-fA-F](_*[0-9a-fA-F])*\",a={\nclassName:\"number\",variants:[{\nbegin:`(\\\\b([0-9](_*[0-9])*)((${e})|\\\\.)?|(${e}))[eE][+-]?([0-9](_*[0-9])*)[fFdD]?\\\\b`\n},{begin:`\\\\b([0-9](_*[0-9])*)((${e})[fFdD]?\\\\b|\\\\.([fFdD]\\\\b)?)`},{\nbegin:`(${e})[fFdD]?\\\\b`},{begin:\"\\\\b([0-9](_*[0-9])*)[fFdD]\\\\b\"},{\nbegin:`\\\\b0[xX]((${n})\\\\.?|(${n})?\\\\.(${n}))[pP][+-]?([0-9](_*[0-9])*)[fFdD]?\\\\b`\n},{begin:\"\\\\b(0|[1-9](_*[0-9])*)[lL]?\\\\b\"},{begin:`\\\\b0[xX](${n})[lL]?\\\\b`},{\nbegin:\"\\\\b0(_*[0-7])*[lL]?\\\\b\"},{begin:\"\\\\b0[bB][01](_*[01])*[lL]?\\\\b\"}],\nrelevance:0};return e=>{\nvar n=\"false synchronized int abstract float private char boolean var static null if const for true while long strictfp finally protected import native final void enum else break transient catch instanceof byte super volatile case assert short package default double public try this switch continue throws protected public private module requires exports do\",s={\nclassName:\"meta\",begin:\"@[\\xc0-\\u02b8a-zA-Z_$][\\xc0-\\u02b8a-zA-Z_$0-9]*\",\ncontains:[{begin:/\\(/,end:/\\)/,contains:[\"self\"]}]};const r=a;return{\nname:\"Java\",aliases:[\"jsp\"],keywords:n,illegal:/<\\/|#/,\ncontains:[e.COMMENT(\"/\\\\*\\\\*\",\"\\\\*/\",{relevance:0,contains:[{begin:/\\w+@/,\nrelevance:0},{className:\"doctag\",begin:\"@[A-Za-z]+\"}]}),{\nbegin:/import java\\.[a-z]+\\./,keywords:\"import\",relevance:2\n},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{\nclassName:\"class\",beginKeywords:\"class interface enum\",end:/[{;=]/,\nexcludeEnd:!0,relevance:1,keywords:\"class interface enum\",illegal:/[:\"\\[\\]]/,\ncontains:[{beginKeywords:\"extends implements\"},e.UNDERSCORE_TITLE_MODE]},{\nbeginKeywords:\"new throw return else\",relevance:0},{className:\"class\",\nbegin:\"record\\\\s+\"+e.UNDERSCORE_IDENT_RE+\"\\\\s*\\\\(\",returnBegin:!0,excludeEnd:!0,\nend:/[{;=]/,keywords:n,contains:[{beginKeywords:\"record\"},{\nbegin:e.UNDERSCORE_IDENT_RE+\"\\\\s*\\\\(\",returnBegin:!0,relevance:0,\ncontains:[e.UNDERSCORE_TITLE_MODE]},{className:\"params\",begin:/\\(/,end:/\\)/,\nkeywords:n,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE]\n},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:\"function\",\nbegin:\"([\\xc0-\\u02b8a-zA-Z_$][\\xc0-\\u02b8a-zA-Z_$0-9]*(<[\\xc0-\\u02b8a-zA-Z_$][\\xc0-\\u02b8a-zA-Z_$0-9]*(\\\\s*,\\\\s*[\\xc0-\\u02b8a-zA-Z_$][\\xc0-\\u02b8a-zA-Z_$0-9]*)*>)?\\\\s+)+\"+e.UNDERSCORE_IDENT_RE+\"\\\\s*\\\\(\",\nreturnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:n,contains:[{\nbegin:e.UNDERSCORE_IDENT_RE+\"\\\\s*\\\\(\",returnBegin:!0,relevance:0,\ncontains:[e.UNDERSCORE_TITLE_MODE]},{className:\"params\",begin:/\\(/,end:/\\)/,\nkeywords:n,relevance:0,\ncontains:[s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,e.C_BLOCK_COMMENT_MODE]\n},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},r,s]}}})());hljs.registerLanguage(\"plaintext\",(()=>{\"use strict\";return t=>({\nname:\"Plain text\",aliases:[\"text\",\"txt\"],disableAutodetect:!0})})());hljs.registerLanguage(\"bash\",(()=>{\"use strict\";function e(...e){\nreturn e.map((e=>{return(s=e)?\"string\"==typeof s?s:s.source:null;var s\n})).join(\"\")}return s=>{const n={},t={begin:/\\$\\{/,end:/\\}/,contains:[\"self\",{\nbegin:/:-/,contains:[n]}]};Object.assign(n,{className:\"variable\",variants:[{\nbegin:e(/\\$[\\w\\d#@][\\w\\d_]*/,\"(?![\\\\w\\\\d])(?![$])\")},t]});const a={\nclassName:\"subst\",begin:/\\$\\(/,end:/\\)/,contains:[s.BACKSLASH_ESCAPE]},i={\nbegin:/<<-?\\s*(?=\\w+)/,starts:{contains:[s.END_SAME_AS_BEGIN({begin:/(\\w+)/,\nend:/(\\w+)/,className:\"string\"})]}},c={className:\"string\",begin:/\"/,end:/\"/,\ncontains:[s.BACKSLASH_ESCAPE,n,a]};a.contains.push(c);const o={begin:/\\$\\(\\(/,\nend:/\\)\\)/,contains:[{begin:/\\d+#[0-9a-f]+/,className:\"number\"},s.NUMBER_MODE,n]\n},r=s.SHEBANG({binary:\"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)\",relevance:10\n}),l={className:\"function\",begin:/\\w[\\w\\d_]*\\s*\\(\\s*\\)\\s*\\{/,returnBegin:!0,\ncontains:[s.inherit(s.TITLE_MODE,{begin:/\\w[\\w\\d_]*/})],relevance:0};return{\nname:\"Bash\",aliases:[\"sh\",\"zsh\"],keywords:{$pattern:/\\b[a-z._-]+\\b/,\nkeyword:\"if then else elif fi for while in do done case esac function\",\nliteral:\"true false\",\nbuilt_in:\"break cd continue eval exec exit export getopts hash pwd readonly return shift test times trap umask unset alias bind builtin caller command declare echo enable help let local logout mapfile printf read readarray source type typeset ulimit unalias set shopt autoload bg bindkey bye cap chdir clone comparguments compcall compctl compdescribe compfiles compgroups compquote comptags comptry compvalues dirs disable disown echotc echoti emulate fc fg float functions getcap getln history integer jobs kill limit log noglob popd print pushd pushln rehash sched setcap setopt stat suspend ttyctl unfunction unhash unlimit unsetopt vared wait whence where which zcompile zformat zftp zle zmodload zparseopts zprof zpty zregexparse zsocket zstyle ztcp\"\n},contains:[r,s.SHEBANG(),l,o,s.HASH_COMMENT_MODE,i,c,{className:\"\",begin:/\\\\\"/\n},{className:\"string\",begin:/'/,end:/'/},n]}}})());hljs.registerLanguage(\"shell\",(()=>{\"use strict\";return s=>({\nname:\"Shell Session\",aliases:[\"console\"],contains:[{className:\"meta\",\nbegin:/^\\s{0,3}[/~\\w\\d[\\]()@-]*[>%$#]/,starts:{end:/[^\\\\](?=\\s*$)/,\nsubLanguage:\"bash\"}}]})})());hljs.registerLanguage(\"properties\",(()=>{\"use strict\";return e=>{\nvar n=\"[ \\\\t\\\\f]*\",a=n+\"[:=]\"+n,t=\"(\"+a+\"|[ \\\\t\\\\f]+)\",r=\"([^\\\\\\\\\\\\W:= \\\\t\\\\f\\\\n]|\\\\\\\\.)+\",s=\"([^\\\\\\\\:= \\\\t\\\\f\\\\n]|\\\\\\\\.)+\",i={\nend:t,relevance:0,starts:{className:\"string\",end:/$/,relevance:0,contains:[{\nbegin:\"\\\\\\\\\\\\\\\\\"},{begin:\"\\\\\\\\\\\\n\"}]}};return{name:\".properties\",\ncase_insensitive:!0,illegal:/\\S/,contains:[e.COMMENT(\"^\\\\s*[!#]\",\"$\"),{\nreturnBegin:!0,variants:[{begin:r+a,relevance:1},{begin:r+\"[ \\\\t\\\\f]+\",\nrelevance:0}],contains:[{className:\"attr\",begin:r,endsParent:!0,relevance:0}],\nstarts:i},{begin:s+t,returnBegin:!0,relevance:0,contains:[{className:\"meta\",\nbegin:s,endsParent:!0,relevance:0}],starts:i},{className:\"attr\",relevance:0,\nbegin:s+n+\"$\"}]}}})());hljs.registerLanguage(\"sql\",(()=>{\"use strict\";function e(e){\nreturn e?\"string\"==typeof e?e:e.source:null}function r(...r){\nreturn r.map((r=>e(r))).join(\"\")}function t(...r){\nreturn\"(\"+r.map((r=>e(r))).join(\"|\")+\")\"}return e=>{\nconst n=e.COMMENT(\"--\",\"$\"),a=[\"true\",\"false\",\"unknown\"],i=[\"bigint\",\"binary\",\"blob\",\"boolean\",\"char\",\"character\",\"clob\",\"date\",\"dec\",\"decfloat\",\"decimal\",\"float\",\"int\",\"integer\",\"interval\",\"nchar\",\"nclob\",\"national\",\"numeric\",\"real\",\"row\",\"smallint\",\"time\",\"timestamp\",\"varchar\",\"varying\",\"varbinary\"],s=[\"abs\",\"acos\",\"array_agg\",\"asin\",\"atan\",\"avg\",\"cast\",\"ceil\",\"ceiling\",\"coalesce\",\"corr\",\"cos\",\"cosh\",\"count\",\"covar_pop\",\"covar_samp\",\"cume_dist\",\"dense_rank\",\"deref\",\"element\",\"exp\",\"extract\",\"first_value\",\"floor\",\"json_array\",\"json_arrayagg\",\"json_exists\",\"json_object\",\"json_objectagg\",\"json_query\",\"json_table\",\"json_table_primitive\",\"json_value\",\"lag\",\"last_value\",\"lead\",\"listagg\",\"ln\",\"log\",\"log10\",\"lower\",\"max\",\"min\",\"mod\",\"nth_value\",\"ntile\",\"nullif\",\"percent_rank\",\"percentile_cont\",\"percentile_disc\",\"position\",\"position_regex\",\"power\",\"rank\",\"regr_avgx\",\"regr_avgy\",\"regr_count\",\"regr_intercept\",\"regr_r2\",\"regr_slope\",\"regr_sxx\",\"regr_sxy\",\"regr_syy\",\"row_number\",\"sin\",\"sinh\",\"sqrt\",\"stddev_pop\",\"stddev_samp\",\"substring\",\"substring_regex\",\"sum\",\"tan\",\"tanh\",\"translate\",\"translate_regex\",\"treat\",\"trim\",\"trim_array\",\"unnest\",\"upper\",\"value_of\",\"var_pop\",\"var_samp\",\"width_bucket\"],o=[\"create table\",\"insert into\",\"primary key\",\"foreign key\",\"not null\",\"alter table\",\"add constraint\",\"grouping sets\",\"on overflow\",\"character set\",\"respect nulls\",\"ignore nulls\",\"nulls first\",\"nulls last\",\"depth first\",\"breadth first\"],c=s,l=[\"abs\",\"acos\",\"all\",\"allocate\",\"alter\",\"and\",\"any\",\"are\",\"array\",\"array_agg\",\"array_max_cardinality\",\"as\",\"asensitive\",\"asin\",\"asymmetric\",\"at\",\"atan\",\"atomic\",\"authorization\",\"avg\",\"begin\",\"begin_frame\",\"begin_partition\",\"between\",\"bigint\",\"binary\",\"blob\",\"boolean\",\"both\",\"by\",\"call\",\"called\",\"cardinality\",\"cascaded\",\"case\",\"cast\",\"ceil\",\"ceiling\",\"char\",\"char_length\",\"character\",\"character_length\",\"check\",\"classifier\",\"clob\",\"close\",\"coalesce\",\"collate\",\"collect\",\"column\",\"commit\",\"condition\",\"connect\",\"constraint\",\"contains\",\"convert\",\"copy\",\"corr\",\"corresponding\",\"cos\",\"cosh\",\"count\",\"covar_pop\",\"covar_samp\",\"create\",\"cross\",\"cube\",\"cume_dist\",\"current\",\"current_catalog\",\"current_date\",\"current_default_transform_group\",\"current_path\",\"current_role\",\"current_row\",\"current_schema\",\"current_time\",\"current_timestamp\",\"current_path\",\"current_role\",\"current_transform_group_for_type\",\"current_user\",\"cursor\",\"cycle\",\"date\",\"day\",\"deallocate\",\"dec\",\"decimal\",\"decfloat\",\"declare\",\"default\",\"define\",\"delete\",\"dense_rank\",\"deref\",\"describe\",\"deterministic\",\"disconnect\",\"distinct\",\"double\",\"drop\",\"dynamic\",\"each\",\"element\",\"else\",\"empty\",\"end\",\"end_frame\",\"end_partition\",\"end-exec\",\"equals\",\"escape\",\"every\",\"except\",\"exec\",\"execute\",\"exists\",\"exp\",\"external\",\"extract\",\"false\",\"fetch\",\"filter\",\"first_value\",\"float\",\"floor\",\"for\",\"foreign\",\"frame_row\",\"free\",\"from\",\"full\",\"function\",\"fusion\",\"get\",\"global\",\"grant\",\"group\",\"grouping\",\"groups\",\"having\",\"hold\",\"hour\",\"identity\",\"in\",\"indicator\",\"initial\",\"inner\",\"inout\",\"insensitive\",\"insert\",\"int\",\"integer\",\"intersect\",\"intersection\",\"interval\",\"into\",\"is\",\"join\",\"json_array\",\"json_arrayagg\",\"json_exists\",\"json_object\",\"json_objectagg\",\"json_query\",\"json_table\",\"json_table_primitive\",\"json_value\",\"lag\",\"language\",\"large\",\"last_value\",\"lateral\",\"lead\",\"leading\",\"left\",\"like\",\"like_regex\",\"listagg\",\"ln\",\"local\",\"localtime\",\"localtimestamp\",\"log\",\"log10\",\"lower\",\"match\",\"match_number\",\"match_recognize\",\"matches\",\"max\",\"member\",\"merge\",\"method\",\"min\",\"minute\",\"mod\",\"modifies\",\"module\",\"month\",\"multiset\",\"national\",\"natural\",\"nchar\",\"nclob\",\"new\",\"no\",\"none\",\"normalize\",\"not\",\"nth_value\",\"ntile\",\"null\",\"nullif\",\"numeric\",\"octet_length\",\"occurrences_regex\",\"of\",\"offset\",\"old\",\"omit\",\"on\",\"one\",\"only\",\"open\",\"or\",\"order\",\"out\",\"outer\",\"over\",\"overlaps\",\"overlay\",\"parameter\",\"partition\",\"pattern\",\"per\",\"percent\",\"percent_rank\",\"percentile_cont\",\"percentile_disc\",\"period\",\"portion\",\"position\",\"position_regex\",\"power\",\"precedes\",\"precision\",\"prepare\",\"primary\",\"procedure\",\"ptf\",\"range\",\"rank\",\"reads\",\"real\",\"recursive\",\"ref\",\"references\",\"referencing\",\"regr_avgx\",\"regr_avgy\",\"regr_count\",\"regr_intercept\",\"regr_r2\",\"regr_slope\",\"regr_sxx\",\"regr_sxy\",\"regr_syy\",\"release\",\"result\",\"return\",\"returns\",\"revoke\",\"right\",\"rollback\",\"rollup\",\"row\",\"row_number\",\"rows\",\"running\",\"savepoint\",\"scope\",\"scroll\",\"search\",\"second\",\"seek\",\"select\",\"sensitive\",\"session_user\",\"set\",\"show\",\"similar\",\"sin\",\"sinh\",\"skip\",\"smallint\",\"some\",\"specific\",\"specifictype\",\"sql\",\"sqlexception\",\"sqlstate\",\"sqlwarning\",\"sqrt\",\"start\",\"static\",\"stddev_pop\",\"stddev_samp\",\"submultiset\",\"subset\",\"substring\",\"substring_regex\",\"succeeds\",\"sum\",\"symmetric\",\"system\",\"system_time\",\"system_user\",\"table\",\"tablesample\",\"tan\",\"tanh\",\"then\",\"time\",\"timestamp\",\"timezone_hour\",\"timezone_minute\",\"to\",\"trailing\",\"translate\",\"translate_regex\",\"translation\",\"treat\",\"trigger\",\"trim\",\"trim_array\",\"true\",\"truncate\",\"uescape\",\"union\",\"unique\",\"unknown\",\"unnest\",\"update   \",\"upper\",\"user\",\"using\",\"value\",\"values\",\"value_of\",\"var_pop\",\"var_samp\",\"varbinary\",\"varchar\",\"varying\",\"versioning\",\"when\",\"whenever\",\"where\",\"width_bucket\",\"window\",\"with\",\"within\",\"without\",\"year\",\"add\",\"asc\",\"collation\",\"desc\",\"final\",\"first\",\"last\",\"view\"].filter((e=>!s.includes(e))),u={\nbegin:r(/\\b/,t(...c),/\\s*\\(/),keywords:{built_in:c}};return{name:\"SQL\",\ncase_insensitive:!0,illegal:/[{}]|<\\//,keywords:{$pattern:/\\b[\\w\\.]+/,\nkeyword:((e,{exceptions:r,when:t}={})=>{const n=t\n;return r=r||[],e.map((e=>e.match(/\\|\\d+$/)||r.includes(e)?e:n(e)?e+\"|0\":e))\n})(l,{when:e=>e.length<3}),literal:a,type:i,\nbuilt_in:[\"current_catalog\",\"current_date\",\"current_default_transform_group\",\"current_path\",\"current_role\",\"current_schema\",\"current_transform_group_for_type\",\"current_user\",\"session_user\",\"system_time\",\"system_user\",\"current_time\",\"localtime\",\"current_timestamp\",\"localtimestamp\"]\n},contains:[{begin:t(...o),keywords:{$pattern:/[\\w\\.]+/,keyword:l.concat(o),\nliteral:a,type:i}},{className:\"type\",\nbegin:t(\"double precision\",\"large object\",\"with timezone\",\"without timezone\")\n},u,{className:\"variable\",begin:/@[a-z0-9]+/},{className:\"string\",variants:[{\nbegin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/\"/,end:/\"/,contains:[{\nbegin:/\"\"/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,n,{className:\"operator\",\nbegin:/[-+*/=%^~]|&&?|\\|\\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}})());hljs.registerLanguage(\"perl\",(()=>{\"use strict\";function e(e){\nreturn e?\"string\"==typeof e?e:e.source:null}function n(...n){\nreturn n.map((n=>e(n))).join(\"\")}function t(...n){\nreturn\"(\"+n.map((n=>e(n))).join(\"|\")+\")\"}return e=>{\nconst r=/[dualxmsipngr]{0,12}/,s={$pattern:/[\\w.]+/,\nkeyword:\"abs accept alarm and atan2 bind binmode bless break caller chdir chmod chomp chop chown chr chroot close closedir connect continue cos crypt dbmclose dbmopen defined delete die do dump each else elsif endgrent endhostent endnetent endprotoent endpwent endservent eof eval exec exists exit exp fcntl fileno flock for foreach fork format formline getc getgrent getgrgid getgrnam gethostbyaddr gethostbyname gethostent getlogin getnetbyaddr getnetbyname getnetent getpeername getpgrp getpriority getprotobyname getprotobynumber getprotoent getpwent getpwnam getpwuid getservbyname getservbyport getservent getsockname getsockopt given glob gmtime goto grep gt hex if index int ioctl join keys kill last lc lcfirst length link listen local localtime log lstat lt ma map mkdir msgctl msgget msgrcv msgsnd my ne next no not oct open opendir or ord our pack package pipe pop pos print printf prototype push q|0 qq quotemeta qw qx rand read readdir readline readlink readpipe recv redo ref rename require reset return reverse rewinddir rindex rmdir say scalar seek seekdir select semctl semget semop send setgrent sethostent setnetent setpgrp setpriority setprotoent setpwent setservent setsockopt shift shmctl shmget shmread shmwrite shutdown sin sleep socket socketpair sort splice split sprintf sqrt srand stat state study sub substr symlink syscall sysopen sysread sysseek system syswrite tell telldir tie tied time times tr truncate uc ucfirst umask undef unless unlink unpack unshift untie until use utime values vec wait waitpid wantarray warn when while write x|0 xor y|0\"\n},i={className:\"subst\",begin:\"[$@]\\\\{\",end:\"\\\\}\",keywords:s},a={begin:/->\\{/,\nend:/\\}/},o={variants:[{begin:/\\$\\d/},{\nbegin:n(/[$%@](\\^\\w\\b|#\\w+(::\\w+)*|\\{\\w+\\}|\\w+(::\\w*)*)/,\"(?![A-Za-z])(?![@$%])\")\n},{begin:/[$%@][^\\s\\w{]/,relevance:0}]\n},c=[e.BACKSLASH_ESCAPE,i,o],g=[/!/,/\\//,/\\|/,/\\?/,/'/,/\"/,/#/],l=(e,t,s=\"\\\\1\")=>{\nconst i=\"\\\\1\"===s?s:n(s,t)\n;return n(n(\"(?:\",e,\")\"),t,/(?:\\\\.|[^\\\\\\/])*?/,i,/(?:\\\\.|[^\\\\\\/])*?/,s,r)\n},d=(e,t,s)=>n(n(\"(?:\",e,\")\"),t,/(?:\\\\.|[^\\\\\\/])*?/,s,r),p=[o,e.HASH_COMMENT_MODE,e.COMMENT(/^=\\w/,/=cut/,{\nendsWithParent:!0}),a,{className:\"string\",contains:c,variants:[{\nbegin:\"q[qwxr]?\\\\s*\\\\(\",end:\"\\\\)\",relevance:5},{begin:\"q[qwxr]?\\\\s*\\\\[\",\nend:\"\\\\]\",relevance:5},{begin:\"q[qwxr]?\\\\s*\\\\{\",end:\"\\\\}\",relevance:5},{\nbegin:\"q[qwxr]?\\\\s*\\\\|\",end:\"\\\\|\",relevance:5},{begin:\"q[qwxr]?\\\\s*<\",end:\">\",\nrelevance:5},{begin:\"qw\\\\s+q\",end:\"q\",relevance:5},{begin:\"'\",end:\"'\",\ncontains:[e.BACKSLASH_ESCAPE]},{begin:'\"',end:'\"'},{begin:\"`\",end:\"`\",\ncontains:[e.BACKSLASH_ESCAPE]},{begin:/\\{\\w+\\}/,relevance:0},{\nbegin:\"-?\\\\w+\\\\s*=>\",relevance:0}]},{className:\"number\",\nbegin:\"(\\\\b0[0-7_]+)|(\\\\b0x[0-9a-fA-F_]+)|(\\\\b[1-9][0-9_]*(\\\\.[0-9_]+)?)|[0_]\\\\b\",\nrelevance:0},{\nbegin:\"(\\\\/\\\\/|\"+e.RE_STARTERS_RE+\"|\\\\b(split|return|print|reverse|grep)\\\\b)\\\\s*\",\nkeywords:\"split return print reverse grep\",relevance:0,\ncontains:[e.HASH_COMMENT_MODE,{className:\"regexp\",variants:[{\nbegin:l(\"s|tr|y\",t(...g))},{begin:l(\"s|tr|y\",\"\\\\(\",\"\\\\)\")},{\nbegin:l(\"s|tr|y\",\"\\\\[\",\"\\\\]\")},{begin:l(\"s|tr|y\",\"\\\\{\",\"\\\\}\")}],relevance:2},{\nclassName:\"regexp\",variants:[{begin:/(m|qr)\\/\\//,relevance:0},{\nbegin:d(\"(?:m|qr)?\",/\\//,/\\//)},{begin:d(\"m|qr\",t(...g),/\\1/)},{\nbegin:d(\"m|qr\",/\\(/,/\\)/)},{begin:d(\"m|qr\",/\\[/,/\\]/)},{\nbegin:d(\"m|qr\",/\\{/,/\\}/)}]}]},{className:\"function\",beginKeywords:\"sub\",\nend:\"(\\\\s*\\\\(.*?\\\\))?[;{]\",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{\nbegin:\"-\\\\w\\\\b\",relevance:0},{begin:\"^__DATA__$\",end:\"^__END__$\",\nsubLanguage:\"mojolicious\",contains:[{begin:\"^@@.*\",end:\"$\",className:\"comment\"}]\n}];return i.contains=p,a.contains=p,{name:\"Perl\",aliases:[\"pl\",\"pm\"],keywords:s,\ncontains:p}}})());hljs.registerLanguage(\"csharp\",(()=>{\"use strict\";return e=>{const n={\nkeyword:[\"abstract\",\"as\",\"base\",\"break\",\"case\",\"class\",\"const\",\"continue\",\"do\",\"else\",\"event\",\"explicit\",\"extern\",\"finally\",\"fixed\",\"for\",\"foreach\",\"goto\",\"if\",\"implicit\",\"in\",\"interface\",\"internal\",\"is\",\"lock\",\"namespace\",\"new\",\"operator\",\"out\",\"override\",\"params\",\"private\",\"protected\",\"public\",\"readonly\",\"record\",\"ref\",\"return\",\"sealed\",\"sizeof\",\"stackalloc\",\"static\",\"struct\",\"switch\",\"this\",\"throw\",\"try\",\"typeof\",\"unchecked\",\"unsafe\",\"using\",\"virtual\",\"void\",\"volatile\",\"while\"].concat([\"add\",\"alias\",\"and\",\"ascending\",\"async\",\"await\",\"by\",\"descending\",\"equals\",\"from\",\"get\",\"global\",\"group\",\"init\",\"into\",\"join\",\"let\",\"nameof\",\"not\",\"notnull\",\"on\",\"or\",\"orderby\",\"partial\",\"remove\",\"select\",\"set\",\"unmanaged\",\"value|0\",\"var\",\"when\",\"where\",\"with\",\"yield\"]),\nbuilt_in:[\"bool\",\"byte\",\"char\",\"decimal\",\"delegate\",\"double\",\"dynamic\",\"enum\",\"float\",\"int\",\"long\",\"nint\",\"nuint\",\"object\",\"sbyte\",\"short\",\"string\",\"ulong\",\"uint\",\"ushort\"],\nliteral:[\"default\",\"false\",\"null\",\"true\"]},a=e.inherit(e.TITLE_MODE,{\nbegin:\"[a-zA-Z](\\\\.?\\\\w)*\"}),i={className:\"number\",variants:[{\nbegin:\"\\\\b(0b[01']+)\"},{\nbegin:\"(-?)\\\\b([\\\\d']+(\\\\.[\\\\d']*)?|\\\\.[\\\\d']+)(u|U|l|L|ul|UL|f|F|b|B)\"},{\nbegin:\"(-?)(\\\\b0[xX][a-fA-F0-9']+|(\\\\b[\\\\d']+(\\\\.[\\\\d']*)?|\\\\.[\\\\d']+)([eE][-+]?[\\\\d']+)?)\"\n}],relevance:0},s={className:\"string\",begin:'@\"',end:'\"',contains:[{begin:'\"\"'}]\n},t=e.inherit(s,{illegal:/\\n/}),r={className:\"subst\",begin:/\\{/,end:/\\}/,\nkeywords:n},l=e.inherit(r,{illegal:/\\n/}),c={className:\"string\",begin:/\\$\"/,\nend:'\"',illegal:/\\n/,contains:[{begin:/\\{\\{/},{begin:/\\}\\}/\n},e.BACKSLASH_ESCAPE,l]},o={className:\"string\",begin:/\\$@\"/,end:'\"',contains:[{\nbegin:/\\{\\{/},{begin:/\\}\\}/},{begin:'\"\"'},r]},d=e.inherit(o,{illegal:/\\n/,\ncontains:[{begin:/\\{\\{/},{begin:/\\}\\}/},{begin:'\"\"'},l]})\n;r.contains=[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.C_BLOCK_COMMENT_MODE],\nl.contains=[d,c,t,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.inherit(e.C_BLOCK_COMMENT_MODE,{\nillegal:/\\n/})];const g={variants:[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]\n},E={begin:\"<\",end:\">\",contains:[{beginKeywords:\"in out\"},a]\n},_=e.IDENT_RE+\"(<\"+e.IDENT_RE+\"(\\\\s*,\\\\s*\"+e.IDENT_RE+\")*>)?(\\\\[\\\\])?\",b={\nbegin:\"@\"+e.IDENT_RE,relevance:0};return{name:\"C#\",aliases:[\"cs\",\"c#\"],\nkeywords:n,illegal:/::/,contains:[e.COMMENT(\"///\",\"$\",{returnBegin:!0,\ncontains:[{className:\"doctag\",variants:[{begin:\"///\",relevance:0},{\nbegin:\"\\x3c!--|--\\x3e\"},{begin:\"</?\",end:\">\"}]}]\n}),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:\"meta\",begin:\"#\",\nend:\"$\",keywords:{\n\"meta-keyword\":\"if else elif endif define undef warning error line region endregion pragma checksum\"\n}},g,i,{beginKeywords:\"class interface\",relevance:0,end:/[{;=]/,\nillegal:/[^\\s:,]/,contains:[{beginKeywords:\"where class\"\n},a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:\"namespace\",\nrelevance:0,end:/[{;=]/,illegal:/[^\\s:]/,\ncontains:[a,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{\nbeginKeywords:\"record\",relevance:0,end:/[{;=]/,illegal:/[^\\s:]/,\ncontains:[a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:\"meta\",\nbegin:\"^\\\\s*\\\\[\",excludeBegin:!0,end:\"\\\\]\",excludeEnd:!0,contains:[{\nclassName:\"meta-string\",begin:/\"/,end:/\"/}]},{\nbeginKeywords:\"new return throw await else\",relevance:0},{className:\"function\",\nbegin:\"(\"+_+\"\\\\s+)+\"+e.IDENT_RE+\"\\\\s*(<.+>\\\\s*)?\\\\(\",returnBegin:!0,\nend:/\\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{\nbeginKeywords:\"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial\",\nrelevance:0},{begin:e.IDENT_RE+\"\\\\s*(<.+>\\\\s*)?\\\\(\",returnBegin:!0,\ncontains:[e.TITLE_MODE,E],relevance:0},{className:\"params\",begin:/\\(/,end:/\\)/,\nexcludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0,\ncontains:[g,i,e.C_BLOCK_COMMENT_MODE]\n},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},b]}}})());hljs.registerLanguage(\"nginx\",(()=>{\"use strict\";return e=>{const n={\nclassName:\"variable\",variants:[{begin:/\\$\\d+/},{begin:/\\$\\{/,end:/\\}/},{\nbegin:/[$@]/+e.UNDERSCORE_IDENT_RE}]},a={endsWithParent:!0,keywords:{\n$pattern:\"[a-z/_]+\",\nliteral:\"on off yes no true false none blocked debug info notice warn error crit select break last permanent redirect kqueue rtsig epoll poll /dev/poll\"\n},relevance:0,illegal:\"=>\",contains:[e.HASH_COMMENT_MODE,{className:\"string\",\ncontains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/\"/,end:/\"/},{begin:/'/,end:/'/\n}]},{begin:\"([a-z]+):/\",end:\"\\\\s\",endsWithParent:!0,excludeEnd:!0,contains:[n]\n},{className:\"regexp\",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:\"\\\\s\\\\^\",\nend:\"\\\\s|\\\\{|;\",returnEnd:!0},{begin:\"~\\\\*?\\\\s+\",end:\"\\\\s|\\\\{|;\",returnEnd:!0},{\nbegin:\"\\\\*(\\\\.[a-z\\\\-]+)+\"},{begin:\"([a-z\\\\-]+\\\\.)+\\\\*\"}]},{className:\"number\",\nbegin:\"\\\\b\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}\\\\.\\\\d{1,3}(:\\\\d{1,5})?\\\\b\"},{\nclassName:\"number\",begin:\"\\\\b\\\\d+[kKmMgGdshdwy]*\\\\b\",relevance:0},n]};return{\nname:\"Nginx config\",aliases:[\"nginxconf\"],contains:[e.HASH_COMMENT_MODE,{\nbegin:e.UNDERSCORE_IDENT_RE+\"\\\\s+\\\\{\",returnBegin:!0,end:/\\{/,contains:[{\nclassName:\"section\",begin:e.UNDERSCORE_IDENT_RE}],relevance:0},{\nbegin:e.UNDERSCORE_IDENT_RE+\"\\\\s\",end:\";|\\\\{\",returnBegin:!0,contains:[{\nclassName:\"attribute\",begin:e.UNDERSCORE_IDENT_RE,starts:a}],relevance:0}],\nillegal:\"[^\\\\s\\\\}]\"}}})());hljs.registerLanguage(\"ruby\",(()=>{\"use strict\";function e(...e){\nreturn e.map((e=>{return(n=e)?\"string\"==typeof n?n:n.source:null;var n\n})).join(\"\")}return n=>{\nconst a=\"([a-zA-Z_]\\\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\\\*\\\\*|[-/+%^&*~`|]|\\\\[\\\\]=?)\",i={\nkeyword:\"and then defined module in return redo if BEGIN retry end for self when next until do begin unless END rescue else break undef not super class case require yield alias while ensure elsif or include attr_reader attr_writer attr_accessor __FILE__\",\nbuilt_in:\"proc lambda\",literal:\"true false nil\"},s={className:\"doctag\",\nbegin:\"@[A-Za-z]+\"},r={begin:\"#<\",end:\">\"},b=[n.COMMENT(\"#\",\"$\",{contains:[s]\n}),n.COMMENT(\"^=begin\",\"^=end\",{contains:[s],relevance:10\n}),n.COMMENT(\"^__END__\",\"\\\\n$\")],c={className:\"subst\",begin:/#\\{/,end:/\\}/,\nkeywords:i},t={className:\"string\",contains:[n.BACKSLASH_ESCAPE,c],variants:[{\nbegin:/'/,end:/'/},{begin:/\"/,end:/\"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\\(/,\nend:/\\)/},{begin:/%[qQwWx]?\\[/,end:/\\]/},{begin:/%[qQwWx]?\\{/,end:/\\}/},{\nbegin:/%[qQwWx]?</,end:/>/},{begin:/%[qQwWx]?\\//,end:/\\//},{begin:/%[qQwWx]?%/,\nend:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\\|/,end:/\\|/},{\nbegin:/\\B\\?(\\\\\\d{1,3})/},{begin:/\\B\\?(\\\\x[A-Fa-f0-9]{1,2})/},{\nbegin:/\\B\\?(\\\\u\\{?[A-Fa-f0-9]{1,6}\\}?)/},{\nbegin:/\\B\\?(\\\\M-\\\\C-|\\\\M-\\\\c|\\\\c\\\\M-|\\\\M-|\\\\C-\\\\M-)[\\x20-\\x7e]/},{\nbegin:/\\B\\?\\\\(c|C-)[\\x20-\\x7e]/},{begin:/\\B\\?\\\\?\\S/},{\nbegin:/<<[-~]?'?(\\w+)\\n(?:[^\\n]*\\n)*?\\s*\\1\\b/,returnBegin:!0,contains:[{\nbegin:/<<[-~]?'?/},n.END_SAME_AS_BEGIN({begin:/(\\w+)/,end:/(\\w+)/,\ncontains:[n.BACKSLASH_ESCAPE,c]})]}]},g=\"[0-9](_?[0-9])*\",d={className:\"number\",\nrelevance:0,variants:[{\nbegin:`\\\\b([1-9](_?[0-9])*|0)(\\\\.(${g}))?([eE][+-]?(${g})|r)?i?\\\\b`},{\nbegin:\"\\\\b0[dD][0-9](_?[0-9])*r?i?\\\\b\"},{begin:\"\\\\b0[bB][0-1](_?[0-1])*r?i?\\\\b\"\n},{begin:\"\\\\b0[oO][0-7](_?[0-7])*r?i?\\\\b\"},{\nbegin:\"\\\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\\\b\"},{\nbegin:\"\\\\b0(_?[0-7])+r?i?\\\\b\"}]},l={className:\"params\",begin:\"\\\\(\",end:\"\\\\)\",\nendsParent:!0,keywords:i},o=[t,{className:\"class\",beginKeywords:\"class module\",\nend:\"$|;\",illegal:/=/,contains:[n.inherit(n.TITLE_MODE,{\nbegin:\"[A-Za-z_]\\\\w*(::\\\\w+)*(\\\\?|!)?\"}),{begin:\"<\\\\s*\",contains:[{\nbegin:\"(\"+n.IDENT_RE+\"::)?\"+n.IDENT_RE,relevance:0}]}].concat(b)},{\nclassName:\"function\",begin:e(/def\\s+/,(_=a+\"\\\\s*(\\\\(|;|$)\",e(\"(?=\",_,\")\"))),\nrelevance:0,keywords:\"def\",end:\"$|;\",contains:[n.inherit(n.TITLE_MODE,{begin:a\n}),l].concat(b)},{begin:n.IDENT_RE+\"::\"},{className:\"symbol\",\nbegin:n.UNDERSCORE_IDENT_RE+\"(!|\\\\?)?:\",relevance:0},{className:\"symbol\",\nbegin:\":(?!\\\\s)\",contains:[t,{begin:a}],relevance:0},d,{className:\"variable\",\nbegin:\"(\\\\$\\\\W)|((\\\\$|@@?)(\\\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])\"},{\nclassName:\"params\",begin:/\\|/,end:/\\|/,relevance:0,keywords:i},{\nbegin:\"(\"+n.RE_STARTERS_RE+\"|unless)\\\\s*\",keywords:\"unless\",contains:[{\nclassName:\"regexp\",contains:[n.BACKSLASH_ESCAPE,c],illegal:/\\n/,variants:[{\nbegin:\"/\",end:\"/[a-z]*\"},{begin:/%r\\{/,end:/\\}[a-z]*/},{begin:\"%r\\\\(\",\nend:\"\\\\)[a-z]*\"},{begin:\"%r!\",end:\"![a-z]*\"},{begin:\"%r\\\\[\",end:\"\\\\][a-z]*\"}]\n}].concat(r,b),relevance:0}].concat(r,b);var _;c.contains=o,l.contains=o\n;const E=[{begin:/^\\s*=>/,starts:{end:\"$\",contains:o}},{className:\"meta\",\nbegin:\"^([>?]>|[\\\\w#]+\\\\(\\\\w+\\\\):\\\\d+:\\\\d+>|(\\\\w+-)?\\\\d+\\\\.\\\\d+\\\\.\\\\d+(p\\\\d+)?[^\\\\d][^>]+>)(?=[ ])\",\nstarts:{end:\"$\",contains:o}}];return b.unshift(r),{name:\"Ruby\",\naliases:[\"rb\",\"gemspec\",\"podspec\",\"thor\",\"irb\"],keywords:i,illegal:/\\/\\*/,\ncontains:[n.SHEBANG({binary:\"ruby\"})].concat(E).concat(b).concat(o)}}})());hljs.registerLanguage(\"swift\",(()=>{\"use strict\";function e(e){\nreturn e?\"string\"==typeof e?e:e.source:null}function n(e){return a(\"(?=\",e,\")\")}\nfunction a(...n){return n.map((n=>e(n))).join(\"\")}function t(...n){\nreturn\"(\"+n.map((n=>e(n))).join(\"|\")+\")\"}\nconst i=e=>a(/\\b/,e,/\\w$/.test(e)?/\\b/:/\\B/),s=[\"Protocol\",\"Type\"].map(i),u=[\"init\",\"self\"].map(i),c=[\"Any\",\"Self\"],r=[\"associatedtype\",\"async\",\"await\",/as\\?/,/as!/,\"as\",\"break\",\"case\",\"catch\",\"class\",\"continue\",\"convenience\",\"default\",\"defer\",\"deinit\",\"didSet\",\"do\",\"dynamic\",\"else\",\"enum\",\"extension\",\"fallthrough\",/fileprivate\\(set\\)/,\"fileprivate\",\"final\",\"for\",\"func\",\"get\",\"guard\",\"if\",\"import\",\"indirect\",\"infix\",/init\\?/,/init!/,\"inout\",/internal\\(set\\)/,\"internal\",\"in\",\"is\",\"lazy\",\"let\",\"mutating\",\"nonmutating\",/open\\(set\\)/,\"open\",\"operator\",\"optional\",\"override\",\"postfix\",\"precedencegroup\",\"prefix\",/private\\(set\\)/,\"private\",\"protocol\",/public\\(set\\)/,\"public\",\"repeat\",\"required\",\"rethrows\",\"return\",\"set\",\"some\",\"static\",\"struct\",\"subscript\",\"super\",\"switch\",\"throws\",\"throw\",/try\\?/,/try!/,\"try\",\"typealias\",/unowned\\(safe\\)/,/unowned\\(unsafe\\)/,\"unowned\",\"var\",\"weak\",\"where\",\"while\",\"willSet\"],o=[\"false\",\"nil\",\"true\"],l=[\"assignment\",\"associativity\",\"higherThan\",\"left\",\"lowerThan\",\"none\",\"right\"],m=[\"#colorLiteral\",\"#column\",\"#dsohandle\",\"#else\",\"#elseif\",\"#endif\",\"#error\",\"#file\",\"#fileID\",\"#fileLiteral\",\"#filePath\",\"#function\",\"#if\",\"#imageLiteral\",\"#keyPath\",\"#line\",\"#selector\",\"#sourceLocation\",\"#warn_unqualified_access\",\"#warning\"],d=[\"abs\",\"all\",\"any\",\"assert\",\"assertionFailure\",\"debugPrint\",\"dump\",\"fatalError\",\"getVaList\",\"isKnownUniquelyReferenced\",\"max\",\"min\",\"numericCast\",\"pointwiseMax\",\"pointwiseMin\",\"precondition\",\"preconditionFailure\",\"print\",\"readLine\",\"repeatElement\",\"sequence\",\"stride\",\"swap\",\"swift_unboxFromSwiftValueWithType\",\"transcode\",\"type\",\"unsafeBitCast\",\"unsafeDowncast\",\"withExtendedLifetime\",\"withUnsafeMutablePointer\",\"withUnsafePointer\",\"withVaList\",\"withoutActuallyEscaping\",\"zip\"],p=t(/[/=\\-+!*%<>&|^~?]/,/[\\u00A1-\\u00A7]/,/[\\u00A9\\u00AB]/,/[\\u00AC\\u00AE]/,/[\\u00B0\\u00B1]/,/[\\u00B6\\u00BB\\u00BF\\u00D7\\u00F7]/,/[\\u2016-\\u2017]/,/[\\u2020-\\u2027]/,/[\\u2030-\\u203E]/,/[\\u2041-\\u2053]/,/[\\u2055-\\u205E]/,/[\\u2190-\\u23FF]/,/[\\u2500-\\u2775]/,/[\\u2794-\\u2BFF]/,/[\\u2E00-\\u2E7F]/,/[\\u3001-\\u3003]/,/[\\u3008-\\u3020]/,/[\\u3030]/),F=t(p,/[\\u0300-\\u036F]/,/[\\u1DC0-\\u1DFF]/,/[\\u20D0-\\u20FF]/,/[\\uFE00-\\uFE0F]/,/[\\uFE20-\\uFE2F]/),b=a(p,F,\"*\"),h=t(/[a-zA-Z_]/,/[\\u00A8\\u00AA\\u00AD\\u00AF\\u00B2-\\u00B5\\u00B7-\\u00BA]/,/[\\u00BC-\\u00BE\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u00FF]/,/[\\u0100-\\u02FF\\u0370-\\u167F\\u1681-\\u180D\\u180F-\\u1DBF]/,/[\\u1E00-\\u1FFF]/,/[\\u200B-\\u200D\\u202A-\\u202E\\u203F-\\u2040\\u2054\\u2060-\\u206F]/,/[\\u2070-\\u20CF\\u2100-\\u218F\\u2460-\\u24FF\\u2776-\\u2793]/,/[\\u2C00-\\u2DFF\\u2E80-\\u2FFF]/,/[\\u3004-\\u3007\\u3021-\\u302F\\u3031-\\u303F\\u3040-\\uD7FF]/,/[\\uF900-\\uFD3D\\uFD40-\\uFDCF\\uFDF0-\\uFE1F\\uFE30-\\uFE44]/,/[\\uFE47-\\uFEFE\\uFF00-\\uFFFD]/),f=t(h,/\\d/,/[\\u0300-\\u036F\\u1DC0-\\u1DFF\\u20D0-\\u20FF\\uFE20-\\uFE2F]/),w=a(h,f,\"*\"),y=a(/[A-Z]/,f,\"*\"),g=[\"autoclosure\",a(/convention\\(/,t(\"swift\",\"block\",\"c\"),/\\)/),\"discardableResult\",\"dynamicCallable\",\"dynamicMemberLookup\",\"escaping\",\"frozen\",\"GKInspectable\",\"IBAction\",\"IBDesignable\",\"IBInspectable\",\"IBOutlet\",\"IBSegueAction\",\"inlinable\",\"main\",\"nonobjc\",\"NSApplicationMain\",\"NSCopying\",\"NSManaged\",a(/objc\\(/,w,/\\)/),\"objc\",\"objcMembers\",\"propertyWrapper\",\"requires_stored_property_inits\",\"testable\",\"UIApplicationMain\",\"unknown\",\"usableFromInline\"],E=[\"iOS\",\"iOSApplicationExtension\",\"macOS\",\"macOSApplicationExtension\",\"macCatalyst\",\"macCatalystApplicationExtension\",\"watchOS\",\"watchOSApplicationExtension\",\"tvOS\",\"tvOSApplicationExtension\",\"swift\"]\n;return e=>{const p={match:/\\s+/,relevance:0},h=e.COMMENT(\"/\\\\*\",\"\\\\*/\",{\ncontains:[\"self\"]}),v=[e.C_LINE_COMMENT_MODE,h],N={className:\"keyword\",\nbegin:a(/\\./,n(t(...s,...u))),end:t(...s,...u),excludeBegin:!0},A={\nmatch:a(/\\./,t(...r)),relevance:0\n},C=r.filter((e=>\"string\"==typeof e)).concat([\"_|0\"]),_={variants:[{\nclassName:\"keyword\",\nmatch:t(...r.filter((e=>\"string\"!=typeof e)).concat(c).map(i),...u)}]},D={\n$pattern:t(/\\b\\w+/,/#\\w+/),keyword:C.concat(m),literal:o},B=[N,A,_],k=[{\nmatch:a(/\\./,t(...d)),relevance:0},{className:\"built_in\",\nmatch:a(/\\b/,t(...d),/(?=\\()/)}],M={match:/->/,relevance:0},S=[M,{\nclassName:\"operator\",relevance:0,variants:[{match:b},{match:`\\\\.(\\\\.|${F})+`}]\n}],x=\"([0-9a-fA-F]_*)+\",I={className:\"number\",relevance:0,variants:[{\nmatch:\"\\\\b(([0-9]_*)+)(\\\\.(([0-9]_*)+))?([eE][+-]?(([0-9]_*)+))?\\\\b\"},{\nmatch:`\\\\b0x(${x})(\\\\.(${x}))?([pP][+-]?(([0-9]_*)+))?\\\\b`},{\nmatch:/\\b0o([0-7]_*)+\\b/},{match:/\\b0b([01]_*)+\\b/}]},O=(e=\"\")=>({\nclassName:\"subst\",variants:[{match:a(/\\\\/,e,/[0\\\\tnr\"']/)},{\nmatch:a(/\\\\/,e,/u\\{[0-9a-fA-F]{1,8}\\}/)}]}),T=(e=\"\")=>({className:\"subst\",\nmatch:a(/\\\\/,e,/[\\t ]*(?:[\\r\\n]|\\r\\n)/)}),L=(e=\"\")=>({className:\"subst\",\nlabel:\"interpol\",begin:a(/\\\\/,e,/\\(/),end:/\\)/}),P=(e=\"\")=>({begin:a(e,/\"\"\"/),\nend:a(/\"\"\"/,e),contains:[O(e),T(e),L(e)]}),$=(e=\"\")=>({begin:a(e,/\"/),\nend:a(/\"/,e),contains:[O(e),L(e)]}),K={className:\"string\",\nvariants:[P(),P(\"#\"),P(\"##\"),P(\"###\"),$(),$(\"#\"),$(\"##\"),$(\"###\")]},j={\nmatch:a(/`/,w,/`/)},z=[j,{className:\"variable\",match:/\\$\\d+/},{\nclassName:\"variable\",match:`\\\\$${f}+`}],q=[{match:/(@|#)available/,\nclassName:\"keyword\",starts:{contains:[{begin:/\\(/,end:/\\)/,keywords:E,\ncontains:[...S,I,K]}]}},{className:\"keyword\",match:a(/@/,t(...g))},{\nclassName:\"meta\",match:a(/@/,w)}],U={match:n(/\\b[A-Z]/),relevance:0,contains:[{\nclassName:\"type\",\nmatch:a(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,f,\"+\")\n},{className:\"type\",match:y,relevance:0},{match:/[?!]+/,relevance:0},{\nmatch:/\\.\\.\\./,relevance:0},{match:a(/\\s+&\\s+/,n(y)),relevance:0}]},Z={\nbegin:/</,end:/>/,keywords:D,contains:[...v,...B,...q,M,U]};U.contains.push(Z)\n;const G={begin:/\\(/,end:/\\)/,relevance:0,keywords:D,contains:[\"self\",{\nmatch:a(w,/\\s*:/),keywords:\"_|0\",relevance:0\n},...v,...B,...k,...S,I,K,...z,...q,U]},H={beginKeywords:\"func\",contains:[{\nclassName:\"title\",match:t(j.match,w,b),endsParent:!0,relevance:0},p]},R={\nbegin:/</,end:/>/,contains:[...v,U]},V={begin:/\\(/,end:/\\)/,keywords:D,\ncontains:[{begin:t(n(a(w,/\\s*:/)),n(a(w,/\\s+/,w,/\\s*:/))),end:/:/,relevance:0,\ncontains:[{className:\"keyword\",match:/\\b_\\b/},{className:\"params\",match:w}]\n},...v,...B,...S,I,K,...q,U,G],endsParent:!0,illegal:/[\"']/},W={\nclassName:\"function\",match:n(/\\bfunc\\b/),contains:[H,R,V,p],illegal:[/\\[/,/%/]\n},X={className:\"function\",match:/\\b(subscript|init[?!]?)\\s*(?=[<(])/,keywords:{\nkeyword:\"subscript init init? init!\",$pattern:/\\w+[?!]?/},contains:[R,V,p],\nillegal:/\\[|%/},J={beginKeywords:\"operator\",end:e.MATCH_NOTHING_RE,contains:[{\nclassName:\"title\",match:b,endsParent:!0,relevance:0}]},Q={\nbeginKeywords:\"precedencegroup\",end:e.MATCH_NOTHING_RE,contains:[{\nclassName:\"title\",match:y,relevance:0},{begin:/{/,end:/}/,relevance:0,\nendsParent:!0,keywords:[...l,...o],contains:[U]}]};for(const e of K.variants){\nconst n=e.contains.find((e=>\"interpol\"===e.label));n.keywords=D\n;const a=[...B,...k,...S,I,K,...z];n.contains=[...a,{begin:/\\(/,end:/\\)/,\ncontains:[\"self\",...a]}]}return{name:\"Swift\",keywords:D,contains:[...v,W,X,{\nclassName:\"class\",beginKeywords:\"struct protocol class extension enum\",\nend:\"\\\\{\",excludeEnd:!0,keywords:D,contains:[e.inherit(e.TITLE_MODE,{\nbegin:/[A-Za-z$_][\\u00C0-\\u02B80-9A-Za-z$_]*/}),...B]},J,Q,{\nbeginKeywords:\"import\",end:/$/,contains:[...v],relevance:0\n},...B,...k,...S,I,K,...z,...q,U,G]}}})());hljs.registerLanguage(\"ini\",(()=>{\"use strict\";function e(e){\nreturn e?\"string\"==typeof e?e:e.source:null}function n(...n){\nreturn n.map((n=>e(n))).join(\"\")}return s=>{const a={className:\"number\",\nrelevance:0,variants:[{begin:/([+-]+)?[\\d]+_[\\d_]+/},{begin:s.NUMBER_RE}]\n},i=s.COMMENT();i.variants=[{begin:/;/,end:/$/},{begin:/#/,end:/$/}];const t={\nclassName:\"variable\",variants:[{begin:/\\$[\\w\\d\"][\\w\\d_]*/},{begin:/\\$\\{(.*?)\\}/\n}]},r={className:\"literal\",begin:/\\bon|off|true|false|yes|no\\b/},l={\nclassName:\"string\",contains:[s.BACKSLASH_ESCAPE],variants:[{begin:\"'''\",\nend:\"'''\",relevance:10},{begin:'\"\"\"',end:'\"\"\"',relevance:10},{begin:'\"',end:'\"'\n},{begin:\"'\",end:\"'\"}]},c={begin:/\\[/,end:/\\]/,contains:[i,r,t,l,a,\"self\"],\nrelevance:0\n},g=\"(\"+[/[A-Za-z0-9_-]+/,/\"(\\\\\"|[^\"])*\"/,/'[^']*'/].map((n=>e(n))).join(\"|\")+\")\"\n;return{name:\"TOML, also INI\",aliases:[\"toml\"],case_insensitive:!0,illegal:/\\S/,\ncontains:[i,{className:\"section\",begin:/\\[+/,end:/\\]+/},{\nbegin:n(g,\"(\\\\s*\\\\.\\\\s*\",g,\")*\",n(\"(?=\",/\\s*=\\s*[^#\\s]/,\")\")),className:\"attr\",\nstarts:{end:/$/,contains:[i,c,r,t,l,a]}}]}}})());hljs.registerLanguage(\"coffeescript\",(()=>{\"use strict\"\n;const e=[\"as\",\"in\",\"of\",\"if\",\"for\",\"while\",\"finally\",\"var\",\"new\",\"function\",\"do\",\"return\",\"void\",\"else\",\"break\",\"catch\",\"instanceof\",\"with\",\"throw\",\"case\",\"default\",\"try\",\"switch\",\"continue\",\"typeof\",\"delete\",\"let\",\"yield\",\"const\",\"class\",\"debugger\",\"async\",\"await\",\"static\",\"import\",\"from\",\"export\",\"extends\"],n=[\"true\",\"false\",\"null\",\"undefined\",\"NaN\",\"Infinity\"],a=[].concat([\"setInterval\",\"setTimeout\",\"clearInterval\",\"clearTimeout\",\"require\",\"exports\",\"eval\",\"isFinite\",\"isNaN\",\"parseFloat\",\"parseInt\",\"decodeURI\",\"decodeURIComponent\",\"encodeURI\",\"encodeURIComponent\",\"escape\",\"unescape\"],[\"arguments\",\"this\",\"super\",\"console\",\"window\",\"document\",\"localStorage\",\"module\",\"global\"],[\"Intl\",\"DataView\",\"Number\",\"Math\",\"Date\",\"String\",\"RegExp\",\"Object\",\"Function\",\"Boolean\",\"Error\",\"Symbol\",\"Set\",\"Map\",\"WeakSet\",\"WeakMap\",\"Proxy\",\"Reflect\",\"JSON\",\"Promise\",\"Float64Array\",\"Int16Array\",\"Int32Array\",\"Int8Array\",\"Uint16Array\",\"Uint32Array\",\"Float32Array\",\"Array\",\"Uint8Array\",\"Uint8ClampedArray\",\"ArrayBuffer\",\"BigInt64Array\",\"BigUint64Array\",\"BigInt\"],[\"EvalError\",\"InternalError\",\"RangeError\",\"ReferenceError\",\"SyntaxError\",\"TypeError\",\"URIError\"])\n;return r=>{const t={\nkeyword:e.concat([\"then\",\"unless\",\"until\",\"loop\",\"by\",\"when\",\"and\",\"or\",\"is\",\"isnt\",\"not\"]).filter((i=[\"var\",\"const\",\"let\",\"function\",\"static\"],\ne=>!i.includes(e))),literal:n.concat([\"yes\",\"no\",\"on\",\"off\"]),\nbuilt_in:a.concat([\"npm\",\"print\"])};var i;const s=\"[A-Za-z$_][0-9A-Za-z$_]*\",o={\nclassName:\"subst\",begin:/#\\{/,end:/\\}/,keywords:t\n},c=[r.BINARY_NUMBER_MODE,r.inherit(r.C_NUMBER_MODE,{starts:{end:\"(\\\\s*/)?\",\nrelevance:0}}),{className:\"string\",variants:[{begin:/'''/,end:/'''/,\ncontains:[r.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[r.BACKSLASH_ESCAPE]\n},{begin:/\"\"\"/,end:/\"\"\"/,contains:[r.BACKSLASH_ESCAPE,o]},{begin:/\"/,end:/\"/,\ncontains:[r.BACKSLASH_ESCAPE,o]}]},{className:\"regexp\",variants:[{begin:\"///\",\nend:\"///\",contains:[o,r.HASH_COMMENT_MODE]},{begin:\"//[gim]{0,3}(?=\\\\W)\",\nrelevance:0},{begin:/\\/(?![ *]).*?(?![\\\\]).\\/[gim]{0,3}(?=\\W)/}]},{begin:\"@\"+s\n},{subLanguage:\"javascript\",excludeBegin:!0,excludeEnd:!0,variants:[{\nbegin:\"```\",end:\"```\"},{begin:\"`\",end:\"`\"}]}];o.contains=c\n;const l=r.inherit(r.TITLE_MODE,{begin:s}),d=\"(\\\\(.*\\\\)\\\\s*)?\\\\B[-=]>\",g={\nclassName:\"params\",begin:\"\\\\([^\\\\(]\",returnBegin:!0,contains:[{begin:/\\(/,\nend:/\\)/,keywords:t,contains:[\"self\"].concat(c)}]};return{name:\"CoffeeScript\",\naliases:[\"coffee\",\"cson\",\"iced\"],keywords:t,illegal:/\\/\\*/,\ncontains:c.concat([r.COMMENT(\"###\",\"###\"),r.HASH_COMMENT_MODE,{\nclassName:\"function\",begin:\"^\\\\s*\"+s+\"\\\\s*=\\\\s*\"+d,end:\"[-=]>\",returnBegin:!0,\ncontains:[l,g]},{begin:/[:\\(,=]\\s*/,relevance:0,contains:[{className:\"function\",\nbegin:d,end:\"[-=]>\",returnBegin:!0,contains:[g]}]},{className:\"class\",\nbeginKeywords:\"class\",end:\"$\",illegal:/[:=\"\\[\\]]/,contains:[{\nbeginKeywords:\"extends\",endsWithParent:!0,illegal:/[:=\"\\[\\]]/,contains:[l]},l]\n},{begin:s+\":\",end:\":\",returnBegin:!0,returnEnd:!0,relevance:0}])}}})());hljs.registerLanguage(\"xml\",(()=>{\"use strict\";function e(e){\nreturn e?\"string\"==typeof e?e:e.source:null}function n(e){return a(\"(?=\",e,\")\")}\nfunction a(...n){return n.map((n=>e(n))).join(\"\")}function s(...n){\nreturn\"(\"+n.map((n=>e(n))).join(\"|\")+\")\"}return e=>{\nconst t=a(/[A-Z_]/,a(\"(\",/[A-Z0-9_.-]*:/,\")?\"),/[A-Z0-9_.-]*/),i={\nclassName:\"symbol\",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},r={begin:/\\s/,\ncontains:[{className:\"meta-keyword\",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\\n/}]\n},c=e.inherit(r,{begin:/\\(/,end:/\\)/}),l=e.inherit(e.APOS_STRING_MODE,{\nclassName:\"meta-string\"}),g=e.inherit(e.QUOTE_STRING_MODE,{\nclassName:\"meta-string\"}),m={endsWithParent:!0,illegal:/</,relevance:0,\ncontains:[{className:\"attr\",begin:/[A-Za-z0-9._:-]+/,relevance:0},{begin:/=\\s*/,\nrelevance:0,contains:[{className:\"string\",endsParent:!0,variants:[{begin:/\"/,\nend:/\"/,contains:[i]},{begin:/'/,end:/'/,contains:[i]},{begin:/[^\\s\"'=<>`]+/}]}]\n}]};return{name:\"HTML, XML\",\naliases:[\"html\",\"xhtml\",\"rss\",\"atom\",\"xjb\",\"xsd\",\"xsl\",\"plist\",\"wsf\",\"svg\"],\ncase_insensitive:!0,contains:[{className:\"meta\",begin:/<![a-z]/,end:/>/,\nrelevance:10,contains:[r,g,l,c,{begin:/\\[/,end:/\\]/,contains:[{className:\"meta\",\nbegin:/<![a-z]/,end:/>/,contains:[r,c,g,l]}]}]},e.COMMENT(/<!--/,/-->/,{\nrelevance:10}),{begin:/<!\\[CDATA\\[/,end:/\\]\\]>/,relevance:10},i,{\nclassName:\"meta\",begin:/<\\?xml/,end:/\\?>/,relevance:10},{className:\"tag\",\nbegin:/<style(?=\\s|>)/,end:/>/,keywords:{name:\"style\"},contains:[m],starts:{\nend:/<\\/style>/,returnEnd:!0,subLanguage:[\"css\",\"xml\"]}},{className:\"tag\",\nbegin:/<script(?=\\s|>)/,end:/>/,keywords:{name:\"script\"},contains:[m],starts:{\nend:/<\\/script>/,returnEnd:!0,subLanguage:[\"javascript\",\"handlebars\",\"xml\"]}},{\nclassName:\"tag\",begin:/<>|<\\/>/},{className:\"tag\",\nbegin:a(/</,n(a(t,s(/\\/>/,/>/,/\\s/)))),end:/\\/?>/,contains:[{className:\"name\",\nbegin:t,relevance:0,starts:m}]},{className:\"tag\",begin:a(/<\\//,n(a(t,/>/))),\ncontains:[{className:\"name\",begin:t,relevance:0},{begin:/>/,relevance:0,\nendsParent:!0}]}]}}})());hljs.registerLanguage(\"scss\",(()=>{\"use strict\"\n;const e=[\"a\",\"abbr\",\"address\",\"article\",\"aside\",\"audio\",\"b\",\"blockquote\",\"body\",\"button\",\"canvas\",\"caption\",\"cite\",\"code\",\"dd\",\"del\",\"details\",\"dfn\",\"div\",\"dl\",\"dt\",\"em\",\"fieldset\",\"figcaption\",\"figure\",\"footer\",\"form\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"header\",\"hgroup\",\"html\",\"i\",\"iframe\",\"img\",\"input\",\"ins\",\"kbd\",\"label\",\"legend\",\"li\",\"main\",\"mark\",\"menu\",\"nav\",\"object\",\"ol\",\"p\",\"q\",\"quote\",\"samp\",\"section\",\"span\",\"strong\",\"summary\",\"sup\",\"table\",\"tbody\",\"td\",\"textarea\",\"tfoot\",\"th\",\"thead\",\"time\",\"tr\",\"ul\",\"var\",\"video\"],t=[\"any-hover\",\"any-pointer\",\"aspect-ratio\",\"color\",\"color-gamut\",\"color-index\",\"device-aspect-ratio\",\"device-height\",\"device-width\",\"display-mode\",\"forced-colors\",\"grid\",\"height\",\"hover\",\"inverted-colors\",\"monochrome\",\"orientation\",\"overflow-block\",\"overflow-inline\",\"pointer\",\"prefers-color-scheme\",\"prefers-contrast\",\"prefers-reduced-motion\",\"prefers-reduced-transparency\",\"resolution\",\"scan\",\"scripting\",\"update\",\"width\",\"min-width\",\"max-width\",\"min-height\",\"max-height\"],i=[\"active\",\"any-link\",\"blank\",\"checked\",\"current\",\"default\",\"defined\",\"dir\",\"disabled\",\"drop\",\"empty\",\"enabled\",\"first\",\"first-child\",\"first-of-type\",\"fullscreen\",\"future\",\"focus\",\"focus-visible\",\"focus-within\",\"has\",\"host\",\"host-context\",\"hover\",\"indeterminate\",\"in-range\",\"invalid\",\"is\",\"lang\",\"last-child\",\"last-of-type\",\"left\",\"link\",\"local-link\",\"not\",\"nth-child\",\"nth-col\",\"nth-last-child\",\"nth-last-col\",\"nth-last-of-type\",\"nth-of-type\",\"only-child\",\"only-of-type\",\"optional\",\"out-of-range\",\"past\",\"placeholder-shown\",\"read-only\",\"read-write\",\"required\",\"right\",\"root\",\"scope\",\"target\",\"target-within\",\"user-invalid\",\"valid\",\"visited\",\"where\"],o=[\"after\",\"backdrop\",\"before\",\"cue\",\"cue-region\",\"first-letter\",\"first-line\",\"grammar-error\",\"marker\",\"part\",\"placeholder\",\"selection\",\"slotted\",\"spelling-error\"],r=[\"align-content\",\"align-items\",\"align-self\",\"animation\",\"animation-delay\",\"animation-direction\",\"animation-duration\",\"animation-fill-mode\",\"animation-iteration-count\",\"animation-name\",\"animation-play-state\",\"animation-timing-function\",\"auto\",\"backface-visibility\",\"background\",\"background-attachment\",\"background-clip\",\"background-color\",\"background-image\",\"background-origin\",\"background-position\",\"background-repeat\",\"background-size\",\"border\",\"border-bottom\",\"border-bottom-color\",\"border-bottom-left-radius\",\"border-bottom-right-radius\",\"border-bottom-style\",\"border-bottom-width\",\"border-collapse\",\"border-color\",\"border-image\",\"border-image-outset\",\"border-image-repeat\",\"border-image-slice\",\"border-image-source\",\"border-image-width\",\"border-left\",\"border-left-color\",\"border-left-style\",\"border-left-width\",\"border-radius\",\"border-right\",\"border-right-color\",\"border-right-style\",\"border-right-width\",\"border-spacing\",\"border-style\",\"border-top\",\"border-top-color\",\"border-top-left-radius\",\"border-top-right-radius\",\"border-top-style\",\"border-top-width\",\"border-width\",\"bottom\",\"box-decoration-break\",\"box-shadow\",\"box-sizing\",\"break-after\",\"break-before\",\"break-inside\",\"caption-side\",\"clear\",\"clip\",\"clip-path\",\"color\",\"column-count\",\"column-fill\",\"column-gap\",\"column-rule\",\"column-rule-color\",\"column-rule-style\",\"column-rule-width\",\"column-span\",\"column-width\",\"columns\",\"content\",\"counter-increment\",\"counter-reset\",\"cursor\",\"direction\",\"display\",\"empty-cells\",\"filter\",\"flex\",\"flex-basis\",\"flex-direction\",\"flex-flow\",\"flex-grow\",\"flex-shrink\",\"flex-wrap\",\"float\",\"font\",\"font-display\",\"font-family\",\"font-feature-settings\",\"font-kerning\",\"font-language-override\",\"font-size\",\"font-size-adjust\",\"font-smoothing\",\"font-stretch\",\"font-style\",\"font-variant\",\"font-variant-ligatures\",\"font-variation-settings\",\"font-weight\",\"height\",\"hyphens\",\"icon\",\"image-orientation\",\"image-rendering\",\"image-resolution\",\"ime-mode\",\"inherit\",\"initial\",\"justify-content\",\"left\",\"letter-spacing\",\"line-height\",\"list-style\",\"list-style-image\",\"list-style-position\",\"list-style-type\",\"margin\",\"margin-bottom\",\"margin-left\",\"margin-right\",\"margin-top\",\"marks\",\"mask\",\"max-height\",\"max-width\",\"min-height\",\"min-width\",\"nav-down\",\"nav-index\",\"nav-left\",\"nav-right\",\"nav-up\",\"none\",\"normal\",\"object-fit\",\"object-position\",\"opacity\",\"order\",\"orphans\",\"outline\",\"outline-color\",\"outline-offset\",\"outline-style\",\"outline-width\",\"overflow\",\"overflow-wrap\",\"overflow-x\",\"overflow-y\",\"padding\",\"padding-bottom\",\"padding-left\",\"padding-right\",\"padding-top\",\"page-break-after\",\"page-break-before\",\"page-break-inside\",\"perspective\",\"perspective-origin\",\"pointer-events\",\"position\",\"quotes\",\"resize\",\"right\",\"src\",\"tab-size\",\"table-layout\",\"text-align\",\"text-align-last\",\"text-decoration\",\"text-decoration-color\",\"text-decoration-line\",\"text-decoration-style\",\"text-indent\",\"text-overflow\",\"text-rendering\",\"text-shadow\",\"text-transform\",\"text-underline-position\",\"top\",\"transform\",\"transform-origin\",\"transform-style\",\"transition\",\"transition-delay\",\"transition-duration\",\"transition-property\",\"transition-timing-function\",\"unicode-bidi\",\"vertical-align\",\"visibility\",\"white-space\",\"widows\",\"width\",\"word-break\",\"word-spacing\",\"word-wrap\",\"z-index\"].reverse()\n;return a=>{const n=(e=>({IMPORTANT:{className:\"meta\",begin:\"!important\"},\nHEXCOLOR:{className:\"number\",begin:\"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\"},\nATTRIBUTE_SELECTOR_MODE:{className:\"selector-attr\",begin:/\\[/,end:/\\]/,\nillegal:\"$\",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}\n}))(a),l=o,s=i,d=\"@[a-z-]+\",c={className:\"variable\",\nbegin:\"(\\\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\\\b\"};return{name:\"SCSS\",case_insensitive:!0,\nillegal:\"[=/|']\",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{\nclassName:\"selector-id\",begin:\"#[A-Za-z0-9_-]+\",relevance:0},{\nclassName:\"selector-class\",begin:\"\\\\.[A-Za-z0-9_-]+\",relevance:0\n},n.ATTRIBUTE_SELECTOR_MODE,{className:\"selector-tag\",\nbegin:\"\\\\b(\"+e.join(\"|\")+\")\\\\b\",relevance:0},{className:\"selector-pseudo\",\nbegin:\":(\"+s.join(\"|\")+\")\"},{className:\"selector-pseudo\",\nbegin:\"::(\"+l.join(\"|\")+\")\"},c,{begin:/\\(/,end:/\\)/,contains:[a.CSS_NUMBER_MODE]\n},{className:\"attribute\",begin:\"\\\\b(\"+r.join(\"|\")+\")\\\\b\"},{\nbegin:\"\\\\b(whitespace|wait|w-resize|visible|vertical-text|vertical-ideographic|uppercase|upper-roman|upper-alpha|underline|transparent|top|thin|thick|text|text-top|text-bottom|tb-rl|table-header-group|table-footer-group|sw-resize|super|strict|static|square|solid|small-caps|separate|se-resize|scroll|s-resize|rtl|row-resize|ridge|right|repeat|repeat-y|repeat-x|relative|progress|pointer|overline|outside|outset|oblique|nowrap|not-allowed|normal|none|nw-resize|no-repeat|no-drop|newspaper|ne-resize|n-resize|move|middle|medium|ltr|lr-tb|lowercase|lower-roman|lower-alpha|loose|list-item|line|line-through|line-edge|lighter|left|keep-all|justify|italic|inter-word|inter-ideograph|inside|inset|inline|inline-block|inherit|inactive|ideograph-space|ideograph-parenthesis|ideograph-numeric|ideograph-alpha|horizontal|hidden|help|hand|groove|fixed|ellipsis|e-resize|double|dotted|distribute|distribute-space|distribute-letter|distribute-all-lines|disc|disabled|default|decimal|dashed|crosshair|collapse|col-resize|circle|char|center|capitalize|break-word|break-all|bottom|both|bolder|bold|block|bidi-override|below|baseline|auto|always|all-scroll|absolute|table|table-cell)\\\\b\"\n},{begin:\":\",end:\";\",\ncontains:[c,n.HEXCOLOR,a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,n.IMPORTANT]\n},{begin:\"@(page|font-face)\",lexemes:d,keywords:\"@page @font-face\"},{begin:\"@\",\nend:\"[{;]\",returnBegin:!0,keywords:{$pattern:/[a-z-]+/,\nkeyword:\"and or not only\",attribute:t.join(\" \")},contains:[{begin:d,\nclassName:\"keyword\"},{begin:/[a-z-]+(?=:)/,className:\"attribute\"\n},c,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,n.HEXCOLOR,a.CSS_NUMBER_MODE]}]}}\n})());hljs.registerLanguage(\"kotlin\",(()=>{\"use strict\"\n;var e=\"\\\\.([0-9](_*[0-9])*)\",n=\"[0-9a-fA-F](_*[0-9a-fA-F])*\",a={\nclassName:\"number\",variants:[{\nbegin:`(\\\\b([0-9](_*[0-9])*)((${e})|\\\\.)?|(${e}))[eE][+-]?([0-9](_*[0-9])*)[fFdD]?\\\\b`\n},{begin:`\\\\b([0-9](_*[0-9])*)((${e})[fFdD]?\\\\b|\\\\.([fFdD]\\\\b)?)`},{\nbegin:`(${e})[fFdD]?\\\\b`},{begin:\"\\\\b([0-9](_*[0-9])*)[fFdD]\\\\b\"},{\nbegin:`\\\\b0[xX]((${n})\\\\.?|(${n})?\\\\.(${n}))[pP][+-]?([0-9](_*[0-9])*)[fFdD]?\\\\b`\n},{begin:\"\\\\b(0|[1-9](_*[0-9])*)[lL]?\\\\b\"},{begin:`\\\\b0[xX](${n})[lL]?\\\\b`},{\nbegin:\"\\\\b0(_*[0-7])*[lL]?\\\\b\"},{begin:\"\\\\b0[bB][01](_*[01])*[lL]?\\\\b\"}],\nrelevance:0};return e=>{const n={\nkeyword:\"abstract as val var vararg get set class object open private protected public noinline crossinline dynamic final enum if else do while for when throw try catch finally import package is in fun override companion reified inline lateinit init interface annotation data sealed internal infix operator out by constructor super tailrec where const inner suspend typealias external expect actual\",\nbuilt_in:\"Byte Short Char Int Long Boolean Float Double Void Unit Nothing\",\nliteral:\"true false null\"},i={className:\"symbol\",begin:e.UNDERSCORE_IDENT_RE+\"@\"\n},s={className:\"subst\",begin:/\\$\\{/,end:/\\}/,contains:[e.C_NUMBER_MODE]},t={\nclassName:\"variable\",begin:\"\\\\$\"+e.UNDERSCORE_IDENT_RE},r={className:\"string\",\nvariants:[{begin:'\"\"\"',end:'\"\"\"(?=[^\"])',contains:[t,s]},{begin:\"'\",end:\"'\",\nillegal:/\\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'\"',end:'\"',illegal:/\\n/,\ncontains:[e.BACKSLASH_ESCAPE,t,s]}]};s.contains.push(r);const l={\nclassName:\"meta\",\nbegin:\"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\\\s*:(?:\\\\s*\"+e.UNDERSCORE_IDENT_RE+\")?\"\n},c={className:\"meta\",begin:\"@\"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\\(/,\nend:/\\)/,contains:[e.inherit(r,{className:\"meta-string\"})]}]\n},o=a,b=e.COMMENT(\"/\\\\*\",\"\\\\*/\",{contains:[e.C_BLOCK_COMMENT_MODE]}),E={\nvariants:[{className:\"type\",begin:e.UNDERSCORE_IDENT_RE},{begin:/\\(/,end:/\\)/,\ncontains:[]}]},d=E;return d.variants[1].contains=[E],E.variants[1].contains=[d],\n{name:\"Kotlin\",aliases:[\"kt\",\"kts\"],keywords:n,\ncontains:[e.COMMENT(\"/\\\\*\\\\*\",\"\\\\*/\",{relevance:0,contains:[{className:\"doctag\",\nbegin:\"@[A-Za-z]+\"}]}),e.C_LINE_COMMENT_MODE,b,{className:\"keyword\",\nbegin:/\\b(break|continue|return|this)\\b/,starts:{contains:[{className:\"symbol\",\nbegin:/@\\w+/}]}},i,l,c,{className:\"function\",beginKeywords:\"fun\",end:\"[(]|$\",\nreturnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{\nbegin:e.UNDERSCORE_IDENT_RE+\"\\\\s*\\\\(\",returnBegin:!0,relevance:0,\ncontains:[e.UNDERSCORE_TITLE_MODE]},{className:\"type\",begin:/</,end:/>/,\nkeywords:\"reified\",relevance:0},{className:\"params\",begin:/\\(/,end:/\\)/,\nendsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\\/]/,\nendsWithParent:!0,contains:[E,e.C_LINE_COMMENT_MODE,b],relevance:0\n},e.C_LINE_COMMENT_MODE,b,l,c,r,e.C_NUMBER_MODE]},b]},{className:\"class\",\nbeginKeywords:\"class interface trait\",end:/[:\\{(]|$/,excludeEnd:!0,\nillegal:\"extends implements\",contains:[{\nbeginKeywords:\"public protected internal private constructor\"\n},e.UNDERSCORE_TITLE_MODE,{className:\"type\",begin:/</,end:/>/,excludeBegin:!0,\nexcludeEnd:!0,relevance:0},{className:\"type\",begin:/[,:]\\s*/,end:/[<\\(,]|$/,\nexcludeBegin:!0,returnEnd:!0},l,c]},r,{className:\"meta\",begin:\"^#!/usr/bin/env\",\nend:\"$\",illegal:\"\\n\"},o]}}})());hljs.registerLanguage(\"makefile\",(()=>{\"use strict\";return e=>{const i={\nclassName:\"variable\",variants:[{begin:\"\\\\$\\\\(\"+e.UNDERSCORE_IDENT_RE+\"\\\\)\",\ncontains:[e.BACKSLASH_ESCAPE]},{begin:/\\$[@%<?\\^\\+\\*]/}]},a={className:\"string\",\nbegin:/\"/,end:/\"/,contains:[e.BACKSLASH_ESCAPE,i]},n={className:\"variable\",\nbegin:/\\$\\([\\w-]+\\s/,end:/\\)/,keywords:{\nbuilt_in:\"subst patsubst strip findstring filter filter-out sort word wordlist firstword lastword dir notdir suffix basename addsuffix addprefix join wildcard realpath abspath error warning shell origin flavor foreach if or and call eval file value\"\n},contains:[i]},s={begin:\"^\"+e.UNDERSCORE_IDENT_RE+\"\\\\s*(?=[:+?]?=)\"},r={\nclassName:\"section\",begin:/^[^\\s]+:/,end:/$/,contains:[i]};return{\nname:\"Makefile\",aliases:[\"mk\",\"mak\",\"make\"],keywords:{$pattern:/[\\w-]+/,\nkeyword:\"define endef undefine ifdef ifndef ifeq ifneq else endif include -include sinclude override export unexport private vpath\"\n},contains:[e.HASH_COMMENT_MODE,i,a,n,s,{className:\"meta\",begin:/^\\.PHONY:/,\nend:/$/,keywords:{$pattern:/[\\.\\w]+/,\"meta-keyword\":\".PHONY\"}},r]}}})());hljs.registerLanguage(\"c\",(()=>{\"use strict\";function e(e){\nreturn((...e)=>e.map((e=>(e=>e?\"string\"==typeof e?e:e.source:null)(e))).join(\"\"))(\"(\",e,\")?\")\n}return t=>{const n=t.COMMENT(\"//\",\"$\",{contains:[{begin:/\\\\\\n/}]\n}),r=\"[a-zA-Z_]\\\\w*::\",a=\"(decltype\\\\(auto\\\\)|\"+e(r)+\"[a-zA-Z_]\\\\w*\"+e(\"<[^<>]+>\")+\")\",i={\nclassName:\"keyword\",begin:\"\\\\b[a-z\\\\d_]*_t\\\\b\"},s={className:\"string\",\nvariants:[{begin:'(u8?|U|L)?\"',end:'\"',illegal:\"\\\\n\",\ncontains:[t.BACKSLASH_ESCAPE]},{\nbegin:\"(u8?|U|L)?'(\\\\\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\\\S)|.)\",\nend:\"'\",illegal:\".\"},t.END_SAME_AS_BEGIN({\nbegin:/(?:u8?|U|L)?R\"([^()\\\\ ]{0,16})\\(/,end:/\\)([^()\\\\ ]{0,16})\"/})]},o={\nclassName:\"number\",variants:[{begin:\"\\\\b(0b[01']+)\"},{\nbegin:\"(-?)\\\\b([\\\\d']+(\\\\.[\\\\d']*)?|\\\\.[\\\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)\"\n},{\nbegin:\"(-?)(\\\\b0[xX][a-fA-F0-9']+|(\\\\b[\\\\d']+(\\\\.[\\\\d']*)?|\\\\.[\\\\d']+)([eE][-+]?[\\\\d']+)?)\"\n}],relevance:0},c={className:\"meta\",begin:/#\\s*[a-z]+\\b/,end:/$/,keywords:{\n\"meta-keyword\":\"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include\"\n},contains:[{begin:/\\\\\\n/,relevance:0},t.inherit(s,{className:\"meta-string\"}),{\nclassName:\"meta-string\",begin:/<.*?>/},n,t.C_BLOCK_COMMENT_MODE]},l={\nclassName:\"title\",begin:e(r)+t.IDENT_RE,relevance:0\n},d=e(r)+t.IDENT_RE+\"\\\\s*\\\\(\",u={\nkeyword:\"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq\",\nbuilt_in:\"std string wstring cin cout cerr clog stdin stdout stderr stringstream istringstream ostringstream auto_ptr deque list queue stack vector map set pair bitset multiset multimap unordered_set unordered_map unordered_multiset unordered_multimap priority_queue make_pair array shared_ptr abort terminate abs acos asin atan2 atan calloc ceil cosh cos exit exp fabs floor fmod fprintf fputs free frexp fscanf future isalnum isalpha iscntrl isdigit isgraph islower isprint ispunct isspace isupper isxdigit tolower toupper labs ldexp log10 log malloc realloc memchr memcmp memcpy memset modf pow printf putchar puts scanf sinh sin snprintf sprintf sqrt sscanf strcat strchr strcmp strcpy strcspn strlen strncat strncmp strncpy strpbrk strrchr strspn strstr tanh tan vfprintf vprintf vsprintf endl initializer_list unique_ptr _Bool complex _Complex imaginary _Imaginary\",\nliteral:\"true false nullptr NULL\"},m=[c,i,n,t.C_BLOCK_COMMENT_MODE,o,s],p={\nvariants:[{begin:/=/,end:/;/},{begin:/\\(/,end:/\\)/},{\nbeginKeywords:\"new throw return else\",end:/;/}],keywords:u,contains:m.concat([{\nbegin:/\\(/,end:/\\)/,keywords:u,contains:m.concat([\"self\"]),relevance:0}]),\nrelevance:0},_={className:\"function\",begin:\"(\"+a+\"[\\\\*&\\\\s]+)+\"+d,\nreturnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:u,illegal:/[^\\w\\s\\*&:<>.]/,\ncontains:[{begin:\"decltype\\\\(auto\\\\)\",keywords:u,relevance:0},{begin:d,\nreturnBegin:!0,contains:[l],relevance:0},{className:\"params\",begin:/\\(/,\nend:/\\)/,keywords:u,relevance:0,contains:[n,t.C_BLOCK_COMMENT_MODE,s,o,i,{\nbegin:/\\(/,end:/\\)/,keywords:u,relevance:0,\ncontains:[\"self\",n,t.C_BLOCK_COMMENT_MODE,s,o,i]}]\n},i,n,t.C_BLOCK_COMMENT_MODE,c]};return{name:\"C\",aliases:[\"h\"],keywords:u,\ndisableAutodetect:!0,illegal:\"</\",contains:[].concat(p,_,m,[c,{\nbegin:\"\\\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\\\s*<\",\nend:\">\",keywords:u,contains:[\"self\",i]},{begin:t.IDENT_RE+\"::\",keywords:u},{\nclassName:\"class\",beginKeywords:\"enum class struct union\",end:/[{;:<>=]/,\ncontains:[{beginKeywords:\"final class struct\"},t.TITLE_MODE]}]),exports:{\npreprocessor:c,strings:s,keywords:u}}}})());hljs.registerLanguage(\"http\",(()=>{\"use strict\";function e(...e){\nreturn e.map((e=>{return(n=e)?\"string\"==typeof n?n:n.source:null;var n\n})).join(\"\")}return n=>{const a=\"HTTP/(2|1\\\\.[01])\",s={className:\"attribute\",\nbegin:e(\"^\",/[A-Za-z][A-Za-z0-9-]*/,\"(?=\\\\:\\\\s)\"),starts:{contains:[{\nclassName:\"punctuation\",begin:/: /,relevance:0,starts:{end:\"$\",relevance:0}}]}\n},t=[s,{begin:\"\\\\n\\\\n\",starts:{subLanguage:[],endsWithParent:!0}}];return{\nname:\"HTTP\",aliases:[\"https\"],illegal:/\\S/,contains:[{begin:\"^(?=\"+a+\" \\\\d{3})\",\nend:/$/,contains:[{className:\"meta\",begin:a},{className:\"number\",\nbegin:\"\\\\b\\\\d{3}\\\\b\"}],starts:{end:/\\b\\B/,illegal:/\\S/,contains:t}},{\nbegin:\"(?=^[A-Z]+ (.*?) \"+a+\"$)\",end:/$/,contains:[{className:\"string\",\nbegin:\" \",end:\" \",excludeBegin:!0,excludeEnd:!0},{className:\"meta\",begin:a},{\nclassName:\"keyword\",begin:\"[A-Z]+\"}],starts:{end:/\\b\\B/,illegal:/\\S/,contains:t}\n},n.inherit(s,{relevance:0})]}}})());hljs.registerLanguage(\"markdown\",(()=>{\"use strict\";function n(...n){\nreturn n.map((n=>{return(e=n)?\"string\"==typeof e?e:e.source:null;var e\n})).join(\"\")}return e=>{const a={begin:/<\\/?[A-Za-z_]/,end:\">\",\nsubLanguage:\"xml\",relevance:0},i={variants:[{begin:/\\[.+?\\]\\[.*?\\]/,relevance:0\n},{begin:/\\[.+?\\]\\(((data|javascript|mailto):|(?:http|ftp)s?:\\/\\/).*?\\)/,\nrelevance:2},{begin:n(/\\[.+?\\]\\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\\/\\/.*?\\)/),\nrelevance:2},{begin:/\\[.+?\\]\\([./?&#].*?\\)/,relevance:1},{\nbegin:/\\[.+?\\]\\(.*?\\)/,relevance:0}],returnBegin:!0,contains:[{\nclassName:\"string\",relevance:0,begin:\"\\\\[\",end:\"\\\\]\",excludeBegin:!0,\nreturnEnd:!0},{className:\"link\",relevance:0,begin:\"\\\\]\\\\(\",end:\"\\\\)\",\nexcludeBegin:!0,excludeEnd:!0},{className:\"symbol\",relevance:0,begin:\"\\\\]\\\\[\",\nend:\"\\\\]\",excludeBegin:!0,excludeEnd:!0}]},s={className:\"strong\",contains:[],\nvariants:[{begin:/_{2}/,end:/_{2}/},{begin:/\\*{2}/,end:/\\*{2}/}]},c={\nclassName:\"emphasis\",contains:[],variants:[{begin:/\\*(?!\\*)/,end:/\\*/},{\nbegin:/_(?!_)/,end:/_/,relevance:0}]};s.contains.push(c),c.contains.push(s)\n;let t=[a,i]\n;return s.contains=s.contains.concat(t),c.contains=c.contains.concat(t),\nt=t.concat(s,c),{name:\"Markdown\",aliases:[\"md\",\"mkdown\",\"mkd\"],contains:[{\nclassName:\"section\",variants:[{begin:\"^#{1,6}\",end:\"$\",contains:t},{\nbegin:\"(?=^.+?\\\\n[=-]{2,}$)\",contains:[{begin:\"^[=-]*$\"},{begin:\"^\",end:\"\\\\n\",\ncontains:t}]}]},a,{className:\"bullet\",begin:\"^[ \\t]*([*+-]|(\\\\d+\\\\.))(?=\\\\s+)\",\nend:\"\\\\s+\",excludeEnd:!0},s,c,{className:\"quote\",begin:\"^>\\\\s+\",contains:t,\nend:\"$\"},{className:\"code\",variants:[{begin:\"(`{3,})[^`](.|\\\\n)*?\\\\1`*[ ]*\"},{\nbegin:\"(~{3,})[^~](.|\\\\n)*?\\\\1~*[ ]*\"},{begin:\"```\",end:\"```+[ ]*$\"},{\nbegin:\"~~~\",end:\"~~~+[ ]*$\"},{begin:\"`.+?`\"},{begin:\"(?=^( {4}|\\\\t))\",\ncontains:[{begin:\"^( {4}|\\\\t)\",end:\"(\\\\n)$\"}],relevance:0}]},{\nbegin:\"^[-\\\\*]{3,}\",end:\"$\"},i,{begin:/^\\[[^\\n]+\\]:/,returnBegin:!0,contains:[{\nclassName:\"symbol\",begin:/\\[/,end:/\\]/,excludeBegin:!0,excludeEnd:!0},{\nclassName:\"link\",begin:/:\\s*/,end:/$/,excludeBegin:!0}]}]}}})());hljs.registerLanguage(\"apache\",(()=>{\"use strict\";return e=>{const n={\nclassName:\"number\",begin:/\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?/}\n;return{name:\"Apache config\",aliases:[\"apacheconf\"],case_insensitive:!0,\ncontains:[e.HASH_COMMENT_MODE,{className:\"section\",begin:/<\\/?/,end:/>/,\ncontains:[n,{className:\"number\",begin:/:\\d{1,5}/\n},e.inherit(e.QUOTE_STRING_MODE,{relevance:0})]},{className:\"attribute\",\nbegin:/\\w+/,relevance:0,keywords:{\nnomarkup:\"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername\"\n},starts:{end:/$/,relevance:0,keywords:{literal:\"on off all deny allow\"},\ncontains:[{className:\"meta\",begin:/\\s\\[/,end:/\\]$/},{className:\"variable\",\nbegin:/[\\$%]\\{/,end:/\\}/,contains:[\"self\",{className:\"number\",begin:/[$%]\\d+/}]\n},n,{className:\"number\",begin:/\\d+/},e.QUOTE_STRING_MODE]}}],illegal:/\\S/}}\n})());hljs.registerLanguage(\"rust\",(()=>{\"use strict\";return e=>{\nconst n=\"([ui](8|16|32|64|128|size)|f(32|64))?\",t=\"drop i8 i16 i32 i64 i128 isize u8 u16 u32 u64 u128 usize f32 f64 str char bool Box Option Result String Vec Copy Send Sized Sync Drop Fn FnMut FnOnce ToOwned Clone Debug PartialEq PartialOrd Eq Ord AsRef AsMut Into From Default Iterator Extend IntoIterator DoubleEndedIterator ExactSizeIterator SliceConcatExt ToString assert! assert_eq! bitflags! bytes! cfg! col! concat! concat_idents! debug_assert! debug_assert_eq! env! panic! file! format! format_args! include_bin! include_str! line! local_data_key! module_path! option_env! print! println! select! stringify! try! unimplemented! unreachable! vec! write! writeln! macro_rules! assert_ne! debug_assert_ne!\"\n;return{name:\"Rust\",aliases:[\"rs\"],keywords:{$pattern:e.IDENT_RE+\"!?\",\nkeyword:\"abstract as async await become box break const continue crate do dyn else enum extern false final fn for if impl in let loop macro match mod move mut override priv pub ref return self Self static struct super trait true try type typeof unsafe unsized use virtual where while yield\",\nliteral:\"true false Some None Ok Err\",built_in:t},illegal:\"</\",\ncontains:[e.C_LINE_COMMENT_MODE,e.COMMENT(\"/\\\\*\",\"\\\\*/\",{contains:[\"self\"]\n}),e.inherit(e.QUOTE_STRING_MODE,{begin:/b?\"/,illegal:null}),{\nclassName:\"string\",variants:[{begin:/r(#*)\"(.|\\n)*?\"\\1(?!#)/},{\nbegin:/b?'\\\\?(x\\w{2}|u\\w{4}|U\\w{8}|.)'/}]},{className:\"symbol\",\nbegin:/'[a-zA-Z_][a-zA-Z0-9_]*/},{className:\"number\",variants:[{\nbegin:\"\\\\b0b([01_]+)\"+n},{begin:\"\\\\b0o([0-7_]+)\"+n},{\nbegin:\"\\\\b0x([A-Fa-f0-9_]+)\"+n},{\nbegin:\"\\\\b(\\\\d[\\\\d_]*(\\\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)\"+n}],relevance:0},{\nclassName:\"function\",beginKeywords:\"fn\",end:\"(\\\\(|<)\",excludeEnd:!0,\ncontains:[e.UNDERSCORE_TITLE_MODE]},{className:\"meta\",begin:\"#!?\\\\[\",end:\"\\\\]\",\ncontains:[{className:\"meta-string\",begin:/\"/,end:/\"/}]},{className:\"class\",\nbeginKeywords:\"type\",end:\";\",contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{\nendsParent:!0})],illegal:\"\\\\S\"},{className:\"class\",\nbeginKeywords:\"trait enum struct union\",end:/\\{/,\ncontains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:\"[\\\\w\\\\d]\"\n},{begin:e.IDENT_RE+\"::\",keywords:{built_in:t}},{begin:\"->\"}]}}})());hljs.registerLanguage(\"php\",(()=>{\"use strict\";return e=>{const r={\nclassName:\"variable\",\nbegin:\"\\\\$+[a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*(?![A-Za-z0-9])(?![$])\"},t={\nclassName:\"meta\",variants:[{begin:/<\\?php/,relevance:10},{begin:/<\\?[=]?/},{\nbegin:/\\?>/}]},a={className:\"subst\",variants:[{begin:/\\$\\w+/},{begin:/\\{\\$/,\nend:/\\}/}]},n=e.inherit(e.APOS_STRING_MODE,{illegal:null\n}),i=e.inherit(e.QUOTE_STRING_MODE,{illegal:null,\ncontains:e.QUOTE_STRING_MODE.contains.concat(a)}),o=e.END_SAME_AS_BEGIN({\nbegin:/<<<[ \\t]*(\\w+)\\n/,end:/[ \\t]*(\\w+)\\b/,\ncontains:e.QUOTE_STRING_MODE.contains.concat(a)}),l={className:\"string\",\ncontains:[e.BACKSLASH_ESCAPE,t],variants:[e.inherit(n,{begin:\"b'\",end:\"'\"\n}),e.inherit(i,{begin:'b\"',end:'\"'}),i,n,o]},s={className:\"number\",variants:[{\nbegin:\"\\\\b0b[01]+(?:_[01]+)*\\\\b\"},{begin:\"\\\\b0o[0-7]+(?:_[0-7]+)*\\\\b\"},{\nbegin:\"\\\\b0x[\\\\da-f]+(?:_[\\\\da-f]+)*\\\\b\"},{\nbegin:\"(?:\\\\b\\\\d+(?:_\\\\d+)*(\\\\.(?:\\\\d+(?:_\\\\d+)*))?|\\\\B\\\\.\\\\d+)(?:e[+-]?\\\\d+)?\"\n}],relevance:0},c={\nkeyword:\"__CLASS__ __DIR__ __FILE__ __FUNCTION__ __LINE__ __METHOD__ __NAMESPACE__ __TRAIT__ die echo exit include include_once print require require_once array abstract and as binary bool boolean break callable case catch class clone const continue declare default do double else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum eval extends final finally float for foreach from global goto if implements instanceof insteadof int integer interface isset iterable list match|0 mixed new object or private protected public real return string switch throw trait try unset use var void while xor yield\",\nliteral:\"false null true\",\nbuilt_in:\"Error|0 AppendIterator ArgumentCountError ArithmeticError ArrayIterator ArrayObject AssertionError BadFunctionCallException BadMethodCallException CachingIterator CallbackFilterIterator CompileError Countable DirectoryIterator DivisionByZeroError DomainException EmptyIterator ErrorException Exception FilesystemIterator FilterIterator GlobIterator InfiniteIterator InvalidArgumentException IteratorIterator LengthException LimitIterator LogicException MultipleIterator NoRewindIterator OutOfBoundsException OutOfRangeException OuterIterator OverflowException ParentIterator ParseError RangeException RecursiveArrayIterator RecursiveCachingIterator RecursiveCallbackFilterIterator RecursiveDirectoryIterator RecursiveFilterIterator RecursiveIterator RecursiveIteratorIterator RecursiveRegexIterator RecursiveTreeIterator RegexIterator RuntimeException SeekableIterator SplDoublyLinkedList SplFileInfo SplFileObject SplFixedArray SplHeap SplMaxHeap SplMinHeap SplObjectStorage SplObserver SplObserver SplPriorityQueue SplQueue SplStack SplSubject SplSubject SplTempFileObject TypeError UnderflowException UnexpectedValueException UnhandledMatchError ArrayAccess Closure Generator Iterator IteratorAggregate Serializable Stringable Throwable Traversable WeakReference WeakMap Directory __PHP_Incomplete_Class parent php_user_filter self static stdClass\"\n};return{aliases:[\"php3\",\"php4\",\"php5\",\"php6\",\"php7\",\"php8\"],\ncase_insensitive:!0,keywords:c,\ncontains:[e.HASH_COMMENT_MODE,e.COMMENT(\"//\",\"$\",{contains:[t]\n}),e.COMMENT(\"/\\\\*\",\"\\\\*/\",{contains:[{className:\"doctag\",begin:\"@[A-Za-z]+\"}]\n}),e.COMMENT(\"__halt_compiler.+?;\",!1,{endsWithParent:!0,\nkeywords:\"__halt_compiler\"}),t,{className:\"keyword\",begin:/\\$this\\b/},r,{\nbegin:/(::|->)+[a-zA-Z_\\x7f-\\xff][a-zA-Z0-9_\\x7f-\\xff]*/},{className:\"function\",\nrelevance:0,beginKeywords:\"fn function\",end:/[;{]/,excludeEnd:!0,\nillegal:\"[$%\\\\[]\",contains:[{beginKeywords:\"use\"},e.UNDERSCORE_TITLE_MODE,{\nbegin:\"=>\",endsParent:!0},{className:\"params\",begin:\"\\\\(\",end:\"\\\\)\",\nexcludeBegin:!0,excludeEnd:!0,keywords:c,\ncontains:[\"self\",r,e.C_BLOCK_COMMENT_MODE,l,s]}]},{className:\"class\",variants:[{\nbeginKeywords:\"enum\",illegal:/[($\"]/},{beginKeywords:\"class interface trait\",\nillegal:/[:($\"]/}],relevance:0,end:/\\{/,excludeEnd:!0,contains:[{\nbeginKeywords:\"extends implements\"},e.UNDERSCORE_TITLE_MODE]},{\nbeginKeywords:\"namespace\",relevance:0,end:\";\",illegal:/[.']/,\ncontains:[e.UNDERSCORE_TITLE_MODE]},{beginKeywords:\"use\",relevance:0,end:\";\",\ncontains:[e.UNDERSCORE_TITLE_MODE]},l,s]}}})());hljs.registerLanguage(\"php-template\",(()=>{\"use strict\";return n=>({\nname:\"PHP template\",subLanguage:\"xml\",contains:[{begin:/<\\?(php|=)?/,end:/\\?>/,\nsubLanguage:\"php\",contains:[{begin:\"/\\\\*\",end:\"\\\\*/\",skip:!0},{begin:'b\"',\nend:'\"',skip:!0},{begin:\"b'\",end:\"'\",skip:!0},n.inherit(n.APOS_STRING_MODE,{\nillegal:null,className:null,contains:null,skip:!0\n}),n.inherit(n.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null,\nskip:!0})]}]})})());hljs.registerLanguage(\"cpp\",(()=>{\"use strict\";function e(e){\nreturn t(\"(\",e,\")?\")}function t(...e){return e.map((e=>{\nreturn(t=e)?\"string\"==typeof t?t:t.source:null;var t})).join(\"\")}return n=>{\nconst r=n.COMMENT(\"//\",\"$\",{contains:[{begin:/\\\\\\n/}]\n}),a=\"[a-zA-Z_]\\\\w*::\",i=\"(decltype\\\\(auto\\\\)|\"+e(a)+\"[a-zA-Z_]\\\\w*\"+e(\"<[^<>]+>\")+\")\",s={\nclassName:\"keyword\",begin:\"\\\\b[a-z\\\\d_]*_t\\\\b\"},c={className:\"string\",\nvariants:[{begin:'(u8?|U|L)?\"',end:'\"',illegal:\"\\\\n\",\ncontains:[n.BACKSLASH_ESCAPE]},{\nbegin:\"(u8?|U|L)?'(\\\\\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\\\S)|.)\",\nend:\"'\",illegal:\".\"},n.END_SAME_AS_BEGIN({\nbegin:/(?:u8?|U|L)?R\"([^()\\\\ ]{0,16})\\(/,end:/\\)([^()\\\\ ]{0,16})\"/})]},o={\nclassName:\"number\",variants:[{begin:\"\\\\b(0b[01']+)\"},{\nbegin:\"(-?)\\\\b([\\\\d']+(\\\\.[\\\\d']*)?|\\\\.[\\\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)\"\n},{\nbegin:\"(-?)(\\\\b0[xX][a-fA-F0-9']+|(\\\\b[\\\\d']+(\\\\.[\\\\d']*)?|\\\\.[\\\\d']+)([eE][-+]?[\\\\d']+)?)\"\n}],relevance:0},l={className:\"meta\",begin:/#\\s*[a-z]+\\b/,end:/$/,keywords:{\n\"meta-keyword\":\"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include\"\n},contains:[{begin:/\\\\\\n/,relevance:0},n.inherit(c,{className:\"meta-string\"}),{\nclassName:\"meta-string\",begin:/<.*?>/},r,n.C_BLOCK_COMMENT_MODE]},d={\nclassName:\"title\",begin:e(a)+n.IDENT_RE,relevance:0\n},u=e(a)+n.IDENT_RE+\"\\\\s*\\\\(\",m={\nkeyword:\"int float while private char char8_t char16_t char32_t catch import module export virtual operator sizeof dynamic_cast|10 typedef const_cast|10 const for static_cast|10 union namespace unsigned long volatile static protected bool template mutable if public friend do goto auto void enum else break extern using asm case typeid wchar_t short reinterpret_cast|10 default double register explicit signed typename try this switch continue inline delete alignas alignof constexpr consteval constinit decltype concept co_await co_return co_yield requires noexcept static_assert thread_local restrict final override atomic_bool atomic_char atomic_schar atomic_uchar atomic_short atomic_ushort atomic_int atomic_uint atomic_long atomic_ulong atomic_llong atomic_ullong new throw return and and_eq bitand bitor compl not not_eq or or_eq xor xor_eq\",\nbuilt_in:\"_Bool _Complex _Imaginary\",\n_relevance_hints:[\"asin\",\"atan2\",\"atan\",\"calloc\",\"ceil\",\"cosh\",\"cos\",\"exit\",\"exp\",\"fabs\",\"floor\",\"fmod\",\"fprintf\",\"fputs\",\"free\",\"frexp\",\"auto_ptr\",\"deque\",\"list\",\"queue\",\"stack\",\"vector\",\"map\",\"set\",\"pair\",\"bitset\",\"multiset\",\"multimap\",\"unordered_set\",\"fscanf\",\"future\",\"isalnum\",\"isalpha\",\"iscntrl\",\"isdigit\",\"isgraph\",\"islower\",\"isprint\",\"ispunct\",\"isspace\",\"isupper\",\"isxdigit\",\"tolower\",\"toupper\",\"labs\",\"ldexp\",\"log10\",\"log\",\"malloc\",\"realloc\",\"memchr\",\"memcmp\",\"memcpy\",\"memset\",\"modf\",\"pow\",\"printf\",\"putchar\",\"puts\",\"scanf\",\"sinh\",\"sin\",\"snprintf\",\"sprintf\",\"sqrt\",\"sscanf\",\"strcat\",\"strchr\",\"strcmp\",\"strcpy\",\"strcspn\",\"strlen\",\"strncat\",\"strncmp\",\"strncpy\",\"strpbrk\",\"strrchr\",\"strspn\",\"strstr\",\"tanh\",\"tan\",\"unordered_map\",\"unordered_multiset\",\"unordered_multimap\",\"priority_queue\",\"make_pair\",\"array\",\"shared_ptr\",\"abort\",\"terminate\",\"abs\",\"acos\",\"vfprintf\",\"vprintf\",\"vsprintf\",\"endl\",\"initializer_list\",\"unique_ptr\",\"complex\",\"imaginary\",\"std\",\"string\",\"wstring\",\"cin\",\"cout\",\"cerr\",\"clog\",\"stdin\",\"stdout\",\"stderr\",\"stringstream\",\"istringstream\",\"ostringstream\"],\nliteral:\"true false nullptr NULL\"},p={className:\"function.dispatch\",relevance:0,\nkeywords:m,\nbegin:t(/\\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,n.IDENT_RE,(_=/\\s*\\(/,\nt(\"(?=\",_,\")\")))};var _;const g=[p,l,s,r,n.C_BLOCK_COMMENT_MODE,o,c],b={\nvariants:[{begin:/=/,end:/;/},{begin:/\\(/,end:/\\)/},{\nbeginKeywords:\"new throw return else\",end:/;/}],keywords:m,contains:g.concat([{\nbegin:/\\(/,end:/\\)/,keywords:m,contains:g.concat([\"self\"]),relevance:0}]),\nrelevance:0},f={className:\"function\",begin:\"(\"+i+\"[\\\\*&\\\\s]+)+\"+u,\nreturnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:m,illegal:/[^\\w\\s\\*&:<>.]/,\ncontains:[{begin:\"decltype\\\\(auto\\\\)\",keywords:m,relevance:0},{begin:u,\nreturnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/,\nendsWithParent:!0,contains:[c,o]},{className:\"params\",begin:/\\(/,end:/\\)/,\nkeywords:m,relevance:0,contains:[r,n.C_BLOCK_COMMENT_MODE,c,o,s,{begin:/\\(/,\nend:/\\)/,keywords:m,relevance:0,contains:[\"self\",r,n.C_BLOCK_COMMENT_MODE,c,o,s]\n}]},s,r,n.C_BLOCK_COMMENT_MODE,l]};return{name:\"C++\",\naliases:[\"cc\",\"c++\",\"h++\",\"hpp\",\"hh\",\"hxx\",\"cxx\"],keywords:m,illegal:\"</\",\nclassNameAliases:{\"function.dispatch\":\"built_in\"},\ncontains:[].concat(b,f,p,g,[l,{\nbegin:\"\\\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\\\s*<\",\nend:\">\",keywords:m,contains:[\"self\",s]},{begin:n.IDENT_RE+\"::\",keywords:m},{\nclassName:\"class\",beginKeywords:\"enum class struct union\",end:/[{;:<>=]/,\ncontains:[{beginKeywords:\"final class struct\"},n.TITLE_MODE]}]),exports:{\npreprocessor:l,strings:c,keywords:m}}}})());hljs.registerLanguage(\"json\",(()=>{\"use strict\";return n=>{const e={\nliteral:\"true false null\"\n},i=[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE],a=[n.QUOTE_STRING_MODE,n.C_NUMBER_MODE],l={\nend:\",\",endsWithParent:!0,excludeEnd:!0,contains:a,keywords:e},t={begin:/\\{/,\nend:/\\}/,contains:[{className:\"attr\",begin:/\"/,end:/\"/,\ncontains:[n.BACKSLASH_ESCAPE],illegal:\"\\\\n\"},n.inherit(l,{begin:/:/\n})].concat(i),illegal:\"\\\\S\"},s={begin:\"\\\\[\",end:\"\\\\]\",contains:[n.inherit(l)],\nillegal:\"\\\\S\"};return a.push(t,s),i.forEach((n=>{a.push(n)})),{name:\"JSON\",\ncontains:a,keywords:e,illegal:\"\\\\S\"}}})());hljs.registerLanguage(\"objectivec\",(()=>{\"use strict\";return e=>{\nconst n=/[a-zA-Z@][a-zA-Z0-9_]*/,_={$pattern:n,\nkeyword:\"@interface @class @protocol @implementation\"};return{\nname:\"Objective-C\",aliases:[\"mm\",\"objc\",\"obj-c\",\"obj-c++\",\"objective-c++\"],\nkeywords:{$pattern:n,\nkeyword:\"int float while char export sizeof typedef const struct for union unsigned long volatile static bool mutable if do return goto void enum else break extern asm case short default double register explicit signed typename this switch continue wchar_t inline readonly assign readwrite self @synchronized id typeof nonatomic super unichar IBOutlet IBAction strong weak copy in out inout bycopy byref oneway __strong __weak __block __autoreleasing @private @protected @public @try @property @end @throw @catch @finally @autoreleasepool @synthesize @dynamic @selector @optional @required @encode @package @import @defs @compatibility_alias __bridge __bridge_transfer __bridge_retained __bridge_retain __covariant __contravariant __kindof _Nonnull _Nullable _Null_unspecified __FUNCTION__ __PRETTY_FUNCTION__ __attribute__ getter setter retain unsafe_unretained nonnull nullable null_unspecified null_resettable class instancetype NS_DESIGNATED_INITIALIZER NS_UNAVAILABLE NS_REQUIRES_SUPER NS_RETURNS_INNER_POINTER NS_INLINE NS_AVAILABLE NS_DEPRECATED NS_ENUM NS_OPTIONS NS_SWIFT_UNAVAILABLE NS_ASSUME_NONNULL_BEGIN NS_ASSUME_NONNULL_END NS_REFINED_FOR_SWIFT NS_SWIFT_NAME NS_SWIFT_NOTHROW NS_DURING NS_HANDLER NS_ENDHANDLER NS_VALUERETURN NS_VOIDRETURN\",\nliteral:\"false true FALSE TRUE nil YES NO NULL\",\nbuilt_in:\"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once\"\n},illegal:\"</\",contains:[{className:\"built_in\",\nbegin:\"\\\\b(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)\\\\w+\"\n},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{\nclassName:\"string\",variants:[{begin:'@\"',end:'\"',illegal:\"\\\\n\",\ncontains:[e.BACKSLASH_ESCAPE]}]},{className:\"meta\",begin:/#\\s*[a-z]+\\b/,end:/$/,\nkeywords:{\n\"meta-keyword\":\"if else elif endif define undef warning error line pragma ifdef ifndef include\"\n},contains:[{begin:/\\\\\\n/,relevance:0},e.inherit(e.QUOTE_STRING_MODE,{\nclassName:\"meta-string\"}),{className:\"meta-string\",begin:/<.*?>/,end:/$/,\nillegal:\"\\\\n\"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{\nclassName:\"class\",begin:\"(\"+_.keyword.split(\" \").join(\"|\")+\")\\\\b\",end:/(\\{|$)/,\nexcludeEnd:!0,keywords:_,contains:[e.UNDERSCORE_TITLE_MODE]},{\nbegin:\"\\\\.\"+e.UNDERSCORE_IDENT_RE,relevance:0}]}}})());hljs.registerLanguage(\"less\",(()=>{\"use strict\"\n;const e=[\"a\",\"abbr\",\"address\",\"article\",\"aside\",\"audio\",\"b\",\"blockquote\",\"body\",\"button\",\"canvas\",\"caption\",\"cite\",\"code\",\"dd\",\"del\",\"details\",\"dfn\",\"div\",\"dl\",\"dt\",\"em\",\"fieldset\",\"figcaption\",\"figure\",\"footer\",\"form\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"header\",\"hgroup\",\"html\",\"i\",\"iframe\",\"img\",\"input\",\"ins\",\"kbd\",\"label\",\"legend\",\"li\",\"main\",\"mark\",\"menu\",\"nav\",\"object\",\"ol\",\"p\",\"q\",\"quote\",\"samp\",\"section\",\"span\",\"strong\",\"summary\",\"sup\",\"table\",\"tbody\",\"td\",\"textarea\",\"tfoot\",\"th\",\"thead\",\"time\",\"tr\",\"ul\",\"var\",\"video\"],t=[\"any-hover\",\"any-pointer\",\"aspect-ratio\",\"color\",\"color-gamut\",\"color-index\",\"device-aspect-ratio\",\"device-height\",\"device-width\",\"display-mode\",\"forced-colors\",\"grid\",\"height\",\"hover\",\"inverted-colors\",\"monochrome\",\"orientation\",\"overflow-block\",\"overflow-inline\",\"pointer\",\"prefers-color-scheme\",\"prefers-contrast\",\"prefers-reduced-motion\",\"prefers-reduced-transparency\",\"resolution\",\"scan\",\"scripting\",\"update\",\"width\",\"min-width\",\"max-width\",\"min-height\",\"max-height\"],i=[\"active\",\"any-link\",\"blank\",\"checked\",\"current\",\"default\",\"defined\",\"dir\",\"disabled\",\"drop\",\"empty\",\"enabled\",\"first\",\"first-child\",\"first-of-type\",\"fullscreen\",\"future\",\"focus\",\"focus-visible\",\"focus-within\",\"has\",\"host\",\"host-context\",\"hover\",\"indeterminate\",\"in-range\",\"invalid\",\"is\",\"lang\",\"last-child\",\"last-of-type\",\"left\",\"link\",\"local-link\",\"not\",\"nth-child\",\"nth-col\",\"nth-last-child\",\"nth-last-col\",\"nth-last-of-type\",\"nth-of-type\",\"only-child\",\"only-of-type\",\"optional\",\"out-of-range\",\"past\",\"placeholder-shown\",\"read-only\",\"read-write\",\"required\",\"right\",\"root\",\"scope\",\"target\",\"target-within\",\"user-invalid\",\"valid\",\"visited\",\"where\"],o=[\"after\",\"backdrop\",\"before\",\"cue\",\"cue-region\",\"first-letter\",\"first-line\",\"grammar-error\",\"marker\",\"part\",\"placeholder\",\"selection\",\"slotted\",\"spelling-error\"],n=[\"align-content\",\"align-items\",\"align-self\",\"animation\",\"animation-delay\",\"animation-direction\",\"animation-duration\",\"animation-fill-mode\",\"animation-iteration-count\",\"animation-name\",\"animation-play-state\",\"animation-timing-function\",\"auto\",\"backface-visibility\",\"background\",\"background-attachment\",\"background-clip\",\"background-color\",\"background-image\",\"background-origin\",\"background-position\",\"background-repeat\",\"background-size\",\"border\",\"border-bottom\",\"border-bottom-color\",\"border-bottom-left-radius\",\"border-bottom-right-radius\",\"border-bottom-style\",\"border-bottom-width\",\"border-collapse\",\"border-color\",\"border-image\",\"border-image-outset\",\"border-image-repeat\",\"border-image-slice\",\"border-image-source\",\"border-image-width\",\"border-left\",\"border-left-color\",\"border-left-style\",\"border-left-width\",\"border-radius\",\"border-right\",\"border-right-color\",\"border-right-style\",\"border-right-width\",\"border-spacing\",\"border-style\",\"border-top\",\"border-top-color\",\"border-top-left-radius\",\"border-top-right-radius\",\"border-top-style\",\"border-top-width\",\"border-width\",\"bottom\",\"box-decoration-break\",\"box-shadow\",\"box-sizing\",\"break-after\",\"break-before\",\"break-inside\",\"caption-side\",\"clear\",\"clip\",\"clip-path\",\"color\",\"column-count\",\"column-fill\",\"column-gap\",\"column-rule\",\"column-rule-color\",\"column-rule-style\",\"column-rule-width\",\"column-span\",\"column-width\",\"columns\",\"content\",\"counter-increment\",\"counter-reset\",\"cursor\",\"direction\",\"display\",\"empty-cells\",\"filter\",\"flex\",\"flex-basis\",\"flex-direction\",\"flex-flow\",\"flex-grow\",\"flex-shrink\",\"flex-wrap\",\"float\",\"font\",\"font-display\",\"font-family\",\"font-feature-settings\",\"font-kerning\",\"font-language-override\",\"font-size\",\"font-size-adjust\",\"font-smoothing\",\"font-stretch\",\"font-style\",\"font-variant\",\"font-variant-ligatures\",\"font-variation-settings\",\"font-weight\",\"height\",\"hyphens\",\"icon\",\"image-orientation\",\"image-rendering\",\"image-resolution\",\"ime-mode\",\"inherit\",\"initial\",\"justify-content\",\"left\",\"letter-spacing\",\"line-height\",\"list-style\",\"list-style-image\",\"list-style-position\",\"list-style-type\",\"margin\",\"margin-bottom\",\"margin-left\",\"margin-right\",\"margin-top\",\"marks\",\"mask\",\"max-height\",\"max-width\",\"min-height\",\"min-width\",\"nav-down\",\"nav-index\",\"nav-left\",\"nav-right\",\"nav-up\",\"none\",\"normal\",\"object-fit\",\"object-position\",\"opacity\",\"order\",\"orphans\",\"outline\",\"outline-color\",\"outline-offset\",\"outline-style\",\"outline-width\",\"overflow\",\"overflow-wrap\",\"overflow-x\",\"overflow-y\",\"padding\",\"padding-bottom\",\"padding-left\",\"padding-right\",\"padding-top\",\"page-break-after\",\"page-break-before\",\"page-break-inside\",\"perspective\",\"perspective-origin\",\"pointer-events\",\"position\",\"quotes\",\"resize\",\"right\",\"src\",\"tab-size\",\"table-layout\",\"text-align\",\"text-align-last\",\"text-decoration\",\"text-decoration-color\",\"text-decoration-line\",\"text-decoration-style\",\"text-indent\",\"text-overflow\",\"text-rendering\",\"text-shadow\",\"text-transform\",\"text-underline-position\",\"top\",\"transform\",\"transform-origin\",\"transform-style\",\"transition\",\"transition-delay\",\"transition-duration\",\"transition-property\",\"transition-timing-function\",\"unicode-bidi\",\"vertical-align\",\"visibility\",\"white-space\",\"widows\",\"width\",\"word-break\",\"word-spacing\",\"word-wrap\",\"z-index\"].reverse(),r=i.concat(o)\n;return a=>{const s=(e=>({IMPORTANT:{className:\"meta\",begin:\"!important\"},\nHEXCOLOR:{className:\"number\",begin:\"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\"},\nATTRIBUTE_SELECTOR_MODE:{className:\"selector-attr\",begin:/\\[/,end:/\\]/,\nillegal:\"$\",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}\n}))(a),l=r,d=\"([\\\\w-]+|@\\\\{[\\\\w-]+\\\\})\",c=[],g=[],b=e=>({className:\"string\",\nbegin:\"~?\"+e+\".*?\"+e}),m=(e,t,i)=>({className:e,begin:t,relevance:i}),u={\n$pattern:/[a-z-]+/,keyword:\"and or not only\",attribute:t.join(\" \")},p={\nbegin:\"\\\\(\",end:\"\\\\)\",contains:g,keywords:u,relevance:0}\n;g.push(a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b(\"'\"),b('\"'),a.CSS_NUMBER_MODE,{\nbegin:\"(url|data-uri)\\\\(\",starts:{className:\"string\",end:\"[\\\\)\\\\n]\",\nexcludeEnd:!0}\n},s.HEXCOLOR,p,m(\"variable\",\"@@?[\\\\w-]+\",10),m(\"variable\",\"@\\\\{[\\\\w-]+\\\\}\"),m(\"built_in\",\"~?`[^`]*?`\"),{\nclassName:\"attribute\",begin:\"[\\\\w-]+\\\\s*:\",end:\":\",returnBegin:!0,excludeEnd:!0\n},s.IMPORTANT);const f=g.concat({begin:/\\{/,end:/\\}/,contains:c}),h={\nbeginKeywords:\"when\",endsWithParent:!0,contains:[{beginKeywords:\"and not\"\n}].concat(g)},w={begin:d+\"\\\\s*:\",returnBegin:!0,end:/[;}]/,relevance:0,\ncontains:[{begin:/-(webkit|moz|ms|o)-/},{className:\"attribute\",\nbegin:\"\\\\b(\"+n.join(\"|\")+\")\\\\b\",end:/(?=:)/,starts:{endsWithParent:!0,\nillegal:\"[<=$]\",relevance:0,contains:g}}]},v={className:\"keyword\",\nbegin:\"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\\\b\",\nstarts:{end:\"[;{}]\",keywords:u,returnEnd:!0,contains:g,relevance:0}},y={\nclassName:\"variable\",variants:[{begin:\"@[\\\\w-]+\\\\s*:\",relevance:15},{\nbegin:\"@[\\\\w-]+\"}],starts:{end:\"[;}]\",returnEnd:!0,contains:f}},k={variants:[{\nbegin:\"[\\\\.#:&\\\\[>]\",end:\"[;{}]\"},{begin:d,end:/\\{/}],returnBegin:!0,\nreturnEnd:!0,illegal:\"[<='$\\\"]\",relevance:0,\ncontains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,h,m(\"keyword\",\"all\\\\b\"),m(\"variable\",\"@\\\\{[\\\\w-]+\\\\}\"),{\nbegin:\"\\\\b(\"+e.join(\"|\")+\")\\\\b\",className:\"selector-tag\"\n},m(\"selector-tag\",d+\"%?\",0),m(\"selector-id\",\"#\"+d),m(\"selector-class\",\"\\\\.\"+d,0),m(\"selector-tag\",\"&\",0),s.ATTRIBUTE_SELECTOR_MODE,{\nclassName:\"selector-pseudo\",begin:\":(\"+i.join(\"|\")+\")\"},{\nclassName:\"selector-pseudo\",begin:\"::(\"+o.join(\"|\")+\")\"},{begin:\"\\\\(\",end:\"\\\\)\",\ncontains:f},{begin:\"!important\"}]},E={begin:`[\\\\w-]+:(:)?(${l.join(\"|\")})`,\nreturnBegin:!0,contains:[k]}\n;return c.push(a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,v,y,E,w,k),{\nname:\"Less\",case_insensitive:!0,illegal:\"[=>'/<($\\\"]\",contains:c}}})());hljs.registerLanguage(\"go\",(()=>{\"use strict\";return e=>{const n={\nkeyword:\"break default func interface select case map struct chan else goto package switch const fallthrough if range type continue for import return var go defer bool byte complex64 complex128 float32 float64 int8 int16 int32 int64 string uint8 uint16 uint32 uint64 int uint uintptr rune\",\nliteral:\"true false iota nil\",\nbuilt_in:\"append cap close complex copy imag len make new panic print println real recover delete\"\n};return{name:\"Go\",aliases:[\"golang\"],keywords:n,illegal:\"</\",\ncontains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:\"string\",\nvariants:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{begin:\"`\",end:\"`\"}]},{\nclassName:\"number\",variants:[{begin:e.C_NUMBER_RE+\"[i]\",relevance:1\n},e.C_NUMBER_MODE]},{begin:/:=/},{className:\"function\",beginKeywords:\"func\",\nend:\"\\\\s*(\\\\{|$)\",excludeEnd:!0,contains:[e.TITLE_MODE,{className:\"params\",\nbegin:/\\(/,end:/\\)/,keywords:n,illegal:/[\"']/}]}]}}})());hljs.registerLanguage(\"diff\",(()=>{\"use strict\";return e=>({name:\"Diff\",\naliases:[\"patch\"],contains:[{className:\"meta\",relevance:10,variants:[{\nbegin:/^@@ +-\\d+,\\d+ +\\+\\d+,\\d+ +@@/},{begin:/^\\*\\*\\* +\\d+,\\d+ +\\*\\*\\*\\*$/},{\nbegin:/^--- +\\d+,\\d+ +----$/}]},{className:\"comment\",variants:[{begin:/Index: /,\nend:/$/},{begin:/^index/,end:/$/},{begin:/={3,}/,end:/$/},{begin:/^-{3}/,end:/$/\n},{begin:/^\\*{3} /,end:/$/},{begin:/^\\+{3}/,end:/$/},{begin:/^\\*{15}$/},{\nbegin:/^diff --git/,end:/$/}]},{className:\"addition\",begin:/^\\+/,end:/$/},{\nclassName:\"deletion\",begin:/^-/,end:/$/},{className:\"addition\",begin:/^!/,\nend:/$/}]})})());hljs.registerLanguage(\"python\",(()=>{\"use strict\";return e=>{const n={\n$pattern:/[A-Za-z]\\w+|__\\w+__/,\nkeyword:[\"and\",\"as\",\"assert\",\"async\",\"await\",\"break\",\"class\",\"continue\",\"def\",\"del\",\"elif\",\"else\",\"except\",\"finally\",\"for\",\"from\",\"global\",\"if\",\"import\",\"in\",\"is\",\"lambda\",\"nonlocal|10\",\"not\",\"or\",\"pass\",\"raise\",\"return\",\"try\",\"while\",\"with\",\"yield\"],\nbuilt_in:[\"__import__\",\"abs\",\"all\",\"any\",\"ascii\",\"bin\",\"bool\",\"breakpoint\",\"bytearray\",\"bytes\",\"callable\",\"chr\",\"classmethod\",\"compile\",\"complex\",\"delattr\",\"dict\",\"dir\",\"divmod\",\"enumerate\",\"eval\",\"exec\",\"filter\",\"float\",\"format\",\"frozenset\",\"getattr\",\"globals\",\"hasattr\",\"hash\",\"help\",\"hex\",\"id\",\"input\",\"int\",\"isinstance\",\"issubclass\",\"iter\",\"len\",\"list\",\"locals\",\"map\",\"max\",\"memoryview\",\"min\",\"next\",\"object\",\"oct\",\"open\",\"ord\",\"pow\",\"print\",\"property\",\"range\",\"repr\",\"reversed\",\"round\",\"set\",\"setattr\",\"slice\",\"sorted\",\"staticmethod\",\"str\",\"sum\",\"super\",\"tuple\",\"type\",\"vars\",\"zip\"],\nliteral:[\"__debug__\",\"Ellipsis\",\"False\",\"None\",\"NotImplemented\",\"True\"],\ntype:[\"Any\",\"Callable\",\"Coroutine\",\"Dict\",\"List\",\"Literal\",\"Generic\",\"Optional\",\"Sequence\",\"Set\",\"Tuple\",\"Type\",\"Union\"]\n},a={className:\"meta\",begin:/^(>>>|\\.\\.\\.) /},i={className:\"subst\",begin:/\\{/,\nend:/\\}/,keywords:n,illegal:/#/},s={begin:/\\{\\{/,relevance:0},t={\nclassName:\"string\",contains:[e.BACKSLASH_ESCAPE],variants:[{\nbegin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/,\ncontains:[e.BACKSLASH_ESCAPE,a],relevance:10},{\nbegin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?\"\"\"/,end:/\"\"\"/,\ncontains:[e.BACKSLASH_ESCAPE,a],relevance:10},{\nbegin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/,\ncontains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/([fF][rR]|[rR][fF]|[fF])\"\"\"/,\nend:/\"\"\"/,contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/([uU]|[rR])'/,end:/'/,\nrelevance:10},{begin:/([uU]|[rR])\"/,end:/\"/,relevance:10},{\nbegin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])\"/,\nend:/\"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/,\ncontains:[e.BACKSLASH_ESCAPE,s,i]},{begin:/([fF][rR]|[rR][fF]|[fF])\"/,end:/\"/,\ncontains:[e.BACKSLASH_ESCAPE,s,i]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]\n},r=\"[0-9](_?[0-9])*\",l=`(\\\\b(${r}))?\\\\.(${r})|\\\\b(${r})\\\\.`,b={\nclassName:\"number\",relevance:0,variants:[{\nbegin:`(\\\\b(${r})|(${l}))[eE][+-]?(${r})[jJ]?\\\\b`},{begin:`(${l})[jJ]?`},{\nbegin:\"\\\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?\\\\b\"},{\nbegin:\"\\\\b0[bB](_?[01])+[lL]?\\\\b\"},{begin:\"\\\\b0[oO](_?[0-7])+[lL]?\\\\b\"},{\nbegin:\"\\\\b0[xX](_?[0-9a-fA-F])+[lL]?\\\\b\"},{begin:`\\\\b(${r})[jJ]\\\\b`}]},o={\nclassName:\"comment\",\nbegin:(d=/# type:/,((...e)=>e.map((e=>(e=>e?\"string\"==typeof e?e:e.source:null)(e))).join(\"\"))(\"(?=\",d,\")\")),\nend:/$/,keywords:n,contains:[{begin:/# type:/},{begin:/#/,end:/\\b\\B/,\nendsWithParent:!0}]},c={className:\"params\",variants:[{className:\"\",\nbegin:/\\(\\s*\\)/,skip:!0},{begin:/\\(/,end:/\\)/,excludeBegin:!0,excludeEnd:!0,\nkeywords:n,contains:[\"self\",a,b,t,e.HASH_COMMENT_MODE]}]};var d\n;return i.contains=[t,b,a],{name:\"Python\",aliases:[\"py\",\"gyp\",\"ipython\"],\nkeywords:n,illegal:/(<\\/|->|\\?)|=>/,contains:[a,b,{begin:/\\bself\\b/},{\nbeginKeywords:\"if\",relevance:0},t,o,e.HASH_COMMENT_MODE,{variants:[{\nclassName:\"function\",beginKeywords:\"def\"},{className:\"class\",\nbeginKeywords:\"class\"}],end:/:/,illegal:/[${=;\\n,]/,\ncontains:[e.UNDERSCORE_TITLE_MODE,c,{begin:/->/,endsWithParent:!0,keywords:n}]\n},{className:\"meta\",begin:/^[\\t ]*@/,end:/(?=#)|$/,contains:[b,c,t]}]}}})());hljs.registerLanguage(\"python-repl\",(()=>{\"use strict\";return s=>({\naliases:[\"pycon\"],contains:[{className:\"meta\",starts:{end:/ |$/,starts:{end:\"$\",\nsubLanguage:\"python\"}},variants:[{begin:/^>>>(?=[ ]|$)/},{\nbegin:/^\\.\\.\\.(?=[ ]|$)/}]}]})})());hljs.registerLanguage(\"css\",(()=>{\"use strict\"\n;const e=[\"a\",\"abbr\",\"address\",\"article\",\"aside\",\"audio\",\"b\",\"blockquote\",\"body\",\"button\",\"canvas\",\"caption\",\"cite\",\"code\",\"dd\",\"del\",\"details\",\"dfn\",\"div\",\"dl\",\"dt\",\"em\",\"fieldset\",\"figcaption\",\"figure\",\"footer\",\"form\",\"h1\",\"h2\",\"h3\",\"h4\",\"h5\",\"h6\",\"header\",\"hgroup\",\"html\",\"i\",\"iframe\",\"img\",\"input\",\"ins\",\"kbd\",\"label\",\"legend\",\"li\",\"main\",\"mark\",\"menu\",\"nav\",\"object\",\"ol\",\"p\",\"q\",\"quote\",\"samp\",\"section\",\"span\",\"strong\",\"summary\",\"sup\",\"table\",\"tbody\",\"td\",\"textarea\",\"tfoot\",\"th\",\"thead\",\"time\",\"tr\",\"ul\",\"var\",\"video\"],t=[\"any-hover\",\"any-pointer\",\"aspect-ratio\",\"color\",\"color-gamut\",\"color-index\",\"device-aspect-ratio\",\"device-height\",\"device-width\",\"display-mode\",\"forced-colors\",\"grid\",\"height\",\"hover\",\"inverted-colors\",\"monochrome\",\"orientation\",\"overflow-block\",\"overflow-inline\",\"pointer\",\"prefers-color-scheme\",\"prefers-contrast\",\"prefers-reduced-motion\",\"prefers-reduced-transparency\",\"resolution\",\"scan\",\"scripting\",\"update\",\"width\",\"min-width\",\"max-width\",\"min-height\",\"max-height\"],i=[\"active\",\"any-link\",\"blank\",\"checked\",\"current\",\"default\",\"defined\",\"dir\",\"disabled\",\"drop\",\"empty\",\"enabled\",\"first\",\"first-child\",\"first-of-type\",\"fullscreen\",\"future\",\"focus\",\"focus-visible\",\"focus-within\",\"has\",\"host\",\"host-context\",\"hover\",\"indeterminate\",\"in-range\",\"invalid\",\"is\",\"lang\",\"last-child\",\"last-of-type\",\"left\",\"link\",\"local-link\",\"not\",\"nth-child\",\"nth-col\",\"nth-last-child\",\"nth-last-col\",\"nth-last-of-type\",\"nth-of-type\",\"only-child\",\"only-of-type\",\"optional\",\"out-of-range\",\"past\",\"placeholder-shown\",\"read-only\",\"read-write\",\"required\",\"right\",\"root\",\"scope\",\"target\",\"target-within\",\"user-invalid\",\"valid\",\"visited\",\"where\"],o=[\"after\",\"backdrop\",\"before\",\"cue\",\"cue-region\",\"first-letter\",\"first-line\",\"grammar-error\",\"marker\",\"part\",\"placeholder\",\"selection\",\"slotted\",\"spelling-error\"],r=[\"align-content\",\"align-items\",\"align-self\",\"animation\",\"animation-delay\",\"animation-direction\",\"animation-duration\",\"animation-fill-mode\",\"animation-iteration-count\",\"animation-name\",\"animation-play-state\",\"animation-timing-function\",\"auto\",\"backface-visibility\",\"background\",\"background-attachment\",\"background-clip\",\"background-color\",\"background-image\",\"background-origin\",\"background-position\",\"background-repeat\",\"background-size\",\"border\",\"border-bottom\",\"border-bottom-color\",\"border-bottom-left-radius\",\"border-bottom-right-radius\",\"border-bottom-style\",\"border-bottom-width\",\"border-collapse\",\"border-color\",\"border-image\",\"border-image-outset\",\"border-image-repeat\",\"border-image-slice\",\"border-image-source\",\"border-image-width\",\"border-left\",\"border-left-color\",\"border-left-style\",\"border-left-width\",\"border-radius\",\"border-right\",\"border-right-color\",\"border-right-style\",\"border-right-width\",\"border-spacing\",\"border-style\",\"border-top\",\"border-top-color\",\"border-top-left-radius\",\"border-top-right-radius\",\"border-top-style\",\"border-top-width\",\"border-width\",\"bottom\",\"box-decoration-break\",\"box-shadow\",\"box-sizing\",\"break-after\",\"break-before\",\"break-inside\",\"caption-side\",\"clear\",\"clip\",\"clip-path\",\"color\",\"column-count\",\"column-fill\",\"column-gap\",\"column-rule\",\"column-rule-color\",\"column-rule-style\",\"column-rule-width\",\"column-span\",\"column-width\",\"columns\",\"content\",\"counter-increment\",\"counter-reset\",\"cursor\",\"direction\",\"display\",\"empty-cells\",\"filter\",\"flex\",\"flex-basis\",\"flex-direction\",\"flex-flow\",\"flex-grow\",\"flex-shrink\",\"flex-wrap\",\"float\",\"font\",\"font-display\",\"font-family\",\"font-feature-settings\",\"font-kerning\",\"font-language-override\",\"font-size\",\"font-size-adjust\",\"font-smoothing\",\"font-stretch\",\"font-style\",\"font-variant\",\"font-variant-ligatures\",\"font-variation-settings\",\"font-weight\",\"height\",\"hyphens\",\"icon\",\"image-orientation\",\"image-rendering\",\"image-resolution\",\"ime-mode\",\"inherit\",\"initial\",\"justify-content\",\"left\",\"letter-spacing\",\"line-height\",\"list-style\",\"list-style-image\",\"list-style-position\",\"list-style-type\",\"margin\",\"margin-bottom\",\"margin-left\",\"margin-right\",\"margin-top\",\"marks\",\"mask\",\"max-height\",\"max-width\",\"min-height\",\"min-width\",\"nav-down\",\"nav-index\",\"nav-left\",\"nav-right\",\"nav-up\",\"none\",\"normal\",\"object-fit\",\"object-position\",\"opacity\",\"order\",\"orphans\",\"outline\",\"outline-color\",\"outline-offset\",\"outline-style\",\"outline-width\",\"overflow\",\"overflow-wrap\",\"overflow-x\",\"overflow-y\",\"padding\",\"padding-bottom\",\"padding-left\",\"padding-right\",\"padding-top\",\"page-break-after\",\"page-break-before\",\"page-break-inside\",\"perspective\",\"perspective-origin\",\"pointer-events\",\"position\",\"quotes\",\"resize\",\"right\",\"src\",\"tab-size\",\"table-layout\",\"text-align\",\"text-align-last\",\"text-decoration\",\"text-decoration-color\",\"text-decoration-line\",\"text-decoration-style\",\"text-indent\",\"text-overflow\",\"text-rendering\",\"text-shadow\",\"text-transform\",\"text-underline-position\",\"top\",\"transform\",\"transform-origin\",\"transform-style\",\"transition\",\"transition-delay\",\"transition-duration\",\"transition-property\",\"transition-timing-function\",\"unicode-bidi\",\"vertical-align\",\"visibility\",\"white-space\",\"widows\",\"width\",\"word-break\",\"word-spacing\",\"word-wrap\",\"z-index\"].reverse()\n;return n=>{const a=(e=>({IMPORTANT:{className:\"meta\",begin:\"!important\"},\nHEXCOLOR:{className:\"number\",begin:\"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})\"},\nATTRIBUTE_SELECTOR_MODE:{className:\"selector-attr\",begin:/\\[/,end:/\\]/,\nillegal:\"$\",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]}\n}))(n),l=[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE];return{name:\"CSS\",\ncase_insensitive:!0,illegal:/[=|'\\$]/,keywords:{keyframePosition:\"from to\"},\nclassNameAliases:{keyframePosition:\"selector-tag\"},\ncontains:[n.C_BLOCK_COMMENT_MODE,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/\n},n.CSS_NUMBER_MODE,{className:\"selector-id\",begin:/#[A-Za-z0-9_-]+/,relevance:0\n},{className:\"selector-class\",begin:\"\\\\.[a-zA-Z-][a-zA-Z0-9_-]*\",relevance:0\n},a.ATTRIBUTE_SELECTOR_MODE,{className:\"selector-pseudo\",variants:[{\nbegin:\":(\"+i.join(\"|\")+\")\"},{begin:\"::(\"+o.join(\"|\")+\")\"}]},{\nclassName:\"attribute\",begin:\"\\\\b(\"+r.join(\"|\")+\")\\\\b\"},{begin:\":\",end:\"[;}]\",\ncontains:[a.HEXCOLOR,a.IMPORTANT,n.CSS_NUMBER_MODE,...l,{\nbegin:/(url|data-uri)\\(/,end:/\\)/,relevance:0,keywords:{built_in:\"url data-uri\"\n},contains:[{className:\"string\",begin:/[^)]/,endsWithParent:!0,excludeEnd:!0}]\n},{className:\"built_in\",begin:/[\\w-]+(?=\\()/}]},{\nbegin:(s=/@/,((...e)=>e.map((e=>(e=>e?\"string\"==typeof e?e:e.source:null)(e))).join(\"\"))(\"(?=\",s,\")\")),\nend:\"[{;]\",relevance:0,illegal:/:/,contains:[{className:\"keyword\",\nbegin:/@-?\\w[\\w]*(-\\w+)*/},{begin:/\\s/,endsWithParent:!0,excludeEnd:!0,\nrelevance:0,keywords:{$pattern:/[a-z-]+/,keyword:\"and or not only\",\nattribute:t.join(\" \")},contains:[{begin:/[a-z-]+(?=:)/,className:\"attribute\"\n},...l,n.CSS_NUMBER_MODE]}]},{className:\"selector-tag\",\nbegin:\"\\\\b(\"+e.join(\"|\")+\")\\\\b\"}]};var s}})());hljs.registerLanguage(\"lua\",(()=>{\"use strict\";return e=>{\nconst t=\"\\\\[=*\\\\[\",a=\"\\\\]=*\\\\]\",n={begin:t,end:a,contains:[\"self\"]\n},o=[e.COMMENT(\"--(?!\\\\[=*\\\\[)\",\"$\"),e.COMMENT(\"--\\\\[=*\\\\[\",a,{contains:[n],\nrelevance:10})];return{name:\"Lua\",keywords:{$pattern:e.UNDERSCORE_IDENT_RE,\nliteral:\"true false nil\",\nkeyword:\"and break do else elseif end for goto if in local not or repeat return then until while\",\nbuilt_in:\"_G _ENV _VERSION __index __newindex __mode __call __metatable __tostring __len __gc __add __sub __mul __div __mod __pow __concat __unm __eq __lt __le assert collectgarbage dofile error getfenv getmetatable ipairs load loadfile loadstring module next pairs pcall print rawequal rawget rawset require select setfenv setmetatable tonumber tostring type unpack xpcall arg self coroutine resume yield status wrap create running debug getupvalue debug sethook getmetatable gethook setmetatable setlocal traceback setfenv getinfo setupvalue getlocal getregistry getfenv io lines write close flush open output type read stderr stdin input stdout popen tmpfile math log max acos huge ldexp pi cos tanh pow deg tan cosh sinh random randomseed frexp ceil floor rad abs sqrt modf asin min mod fmod log10 atan2 exp sin atan os exit setlocale date getenv difftime remove time clock tmpname rename execute package preload loadlib loaded loaders cpath config path seeall string sub upper len gfind rep find match char dump gmatch reverse byte format gsub lower table setn insert getn foreachi maxn foreach concat sort remove\"\n},contains:o.concat([{className:\"function\",beginKeywords:\"function\",end:\"\\\\)\",\ncontains:[e.inherit(e.TITLE_MODE,{\nbegin:\"([_a-zA-Z]\\\\w*\\\\.)*([_a-zA-Z]\\\\w*:)?[_a-zA-Z]\\\\w*\"}),{className:\"params\",\nbegin:\"\\\\(\",endsWithParent:!0,contains:o}].concat(o)\n},e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:\"string\",\nbegin:t,end:a,contains:[n],relevance:5}])}}})());hljs.registerLanguage(\"d\",(()=>{\"use strict\";return e=>{const a={\n$pattern:e.UNDERSCORE_IDENT_RE,\nkeyword:\"abstract alias align asm assert auto body break byte case cast catch class const continue debug default delete deprecated do else enum export extern final finally for foreach foreach_reverse|10 goto if immutable import in inout int interface invariant is lazy macro mixin module new nothrow out override package pragma private protected public pure ref return scope shared static struct super switch synchronized template this throw try typedef typeid typeof union unittest version void volatile while with __FILE__ __LINE__ __gshared|10 __thread __traits __DATE__ __EOF__ __TIME__ __TIMESTAMP__ __VENDOR__ __VERSION__\",\nbuilt_in:\"bool cdouble cent cfloat char creal dchar delegate double dstring float function idouble ifloat ireal long real short string ubyte ucent uint ulong ushort wchar wstring\",\nliteral:\"false null true\"\n},d=\"((0|[1-9][\\\\d_]*)|0[bB][01_]+|0[xX]([\\\\da-fA-F][\\\\da-fA-F_]*|_[\\\\da-fA-F][\\\\da-fA-F_]*))\",n=\"\\\\\\\\(['\\\"\\\\?\\\\\\\\abfnrtv]|u[\\\\dA-Fa-f]{4}|[0-7]{1,3}|x[\\\\dA-Fa-f]{2}|U[\\\\dA-Fa-f]{8})|&[a-zA-Z\\\\d]{2,};\",t={\nclassName:\"number\",begin:\"\\\\b\"+d+\"(L|u|U|Lu|LU|uL|UL)?\",relevance:0},_={\nclassName:\"number\",\nbegin:\"\\\\b(((0[xX](([\\\\da-fA-F][\\\\da-fA-F_]*|_[\\\\da-fA-F][\\\\da-fA-F_]*)\\\\.([\\\\da-fA-F][\\\\da-fA-F_]*|_[\\\\da-fA-F][\\\\da-fA-F_]*)|\\\\.?([\\\\da-fA-F][\\\\da-fA-F_]*|_[\\\\da-fA-F][\\\\da-fA-F_]*))[pP][+-]?(0|[1-9][\\\\d_]*|\\\\d[\\\\d_]*|[\\\\d_]+?\\\\d))|((0|[1-9][\\\\d_]*|\\\\d[\\\\d_]*|[\\\\d_]+?\\\\d)(\\\\.\\\\d*|([eE][+-]?(0|[1-9][\\\\d_]*|\\\\d[\\\\d_]*|[\\\\d_]+?\\\\d)))|\\\\d+\\\\.(0|[1-9][\\\\d_]*|\\\\d[\\\\d_]*|[\\\\d_]+?\\\\d)|\\\\.(0|[1-9][\\\\d_]*)([eE][+-]?(0|[1-9][\\\\d_]*|\\\\d[\\\\d_]*|[\\\\d_]+?\\\\d))?))([fF]|L|i|[fF]i|Li)?|\"+d+\"(i|[fF]i|Li))\",\nrelevance:0},r={className:\"string\",begin:\"'(\"+n+\"|.)\",end:\"'\",illegal:\".\"},i={\nclassName:\"string\",begin:'\"',contains:[{begin:n,relevance:0}],end:'\"[cwd]?'\n},s=e.COMMENT(\"\\\\/\\\\+\",\"\\\\+\\\\/\",{contains:[\"self\"],relevance:10});return{\nname:\"D\",keywords:a,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,{\nclassName:\"string\",begin:'x\"[\\\\da-fA-F\\\\s\\\\n\\\\r]*\"[cwd]?',relevance:10},i,{\nclassName:\"string\",begin:'[rq]\"',end:'\"[cwd]?',relevance:5},{className:\"string\",\nbegin:\"`\",end:\"`[cwd]?\"},{className:\"string\",begin:'q\"\\\\{',end:'\\\\}\"'},_,t,r,{\nclassName:\"meta\",begin:\"^#!\",end:\"$\",relevance:5},{className:\"meta\",\nbegin:\"#(line)\",end:\"$\",relevance:5},{className:\"keyword\",\nbegin:\"@[a-zA-Z_][a-zA-Z_\\\\d]*\"}]}}})());hljs.registerLanguage(\"typescript\",(()=>{\"use strict\"\n;const e=\"[A-Za-z$_][0-9A-Za-z$_]*\",n=[\"as\",\"in\",\"of\",\"if\",\"for\",\"while\",\"finally\",\"var\",\"new\",\"function\",\"do\",\"return\",\"void\",\"else\",\"break\",\"catch\",\"instanceof\",\"with\",\"throw\",\"case\",\"default\",\"try\",\"switch\",\"continue\",\"typeof\",\"delete\",\"let\",\"yield\",\"const\",\"class\",\"debugger\",\"async\",\"await\",\"static\",\"import\",\"from\",\"export\",\"extends\"],a=[\"true\",\"false\",\"null\",\"undefined\",\"NaN\",\"Infinity\"],s=[].concat([\"setInterval\",\"setTimeout\",\"clearInterval\",\"clearTimeout\",\"require\",\"exports\",\"eval\",\"isFinite\",\"isNaN\",\"parseFloat\",\"parseInt\",\"decodeURI\",\"decodeURIComponent\",\"encodeURI\",\"encodeURIComponent\",\"escape\",\"unescape\"],[\"arguments\",\"this\",\"super\",\"console\",\"window\",\"document\",\"localStorage\",\"module\",\"global\"],[\"Intl\",\"DataView\",\"Number\",\"Math\",\"Date\",\"String\",\"RegExp\",\"Object\",\"Function\",\"Boolean\",\"Error\",\"Symbol\",\"Set\",\"Map\",\"WeakSet\",\"WeakMap\",\"Proxy\",\"Reflect\",\"JSON\",\"Promise\",\"Float64Array\",\"Int16Array\",\"Int32Array\",\"Int8Array\",\"Uint16Array\",\"Uint32Array\",\"Float32Array\",\"Array\",\"Uint8Array\",\"Uint8ClampedArray\",\"ArrayBuffer\",\"BigInt64Array\",\"BigUint64Array\",\"BigInt\"],[\"EvalError\",\"InternalError\",\"RangeError\",\"ReferenceError\",\"SyntaxError\",\"TypeError\",\"URIError\"])\n;function t(e){return r(\"(?=\",e,\")\")}function r(...e){return e.map((e=>{\nreturn(n=e)?\"string\"==typeof n?n:n.source:null;var n})).join(\"\")}return i=>{\nconst c={$pattern:e,\nkeyword:n.concat([\"type\",\"namespace\",\"typedef\",\"interface\",\"public\",\"private\",\"protected\",\"implements\",\"declare\",\"abstract\",\"readonly\"]),\nliteral:a,\nbuilt_in:s.concat([\"any\",\"void\",\"number\",\"boolean\",\"string\",\"object\",\"never\",\"enum\"])\n},o={className:\"meta\",begin:\"@[A-Za-z$_][0-9A-Za-z$_]*\"},l=(e,n,a)=>{\nconst s=e.contains.findIndex((e=>e.label===n))\n;if(-1===s)throw Error(\"can not find mode to replace\");e.contains.splice(s,1,a)\n},b=(i=>{const c=e,o={begin:/<[A-Za-z0-9\\\\._:-]+/,\nend:/\\/[A-Za-z0-9\\\\._:-]+>|\\/>/,isTrulyOpeningTag:(e,n)=>{\nconst a=e[0].length+e.index,s=e.input[a];\"<\"!==s?\">\"===s&&(((e,{after:n})=>{\nconst a=\"</\"+e[0].slice(1);return-1!==e.input.indexOf(a,n)})(e,{after:a\n})||n.ignoreMatch()):n.ignoreMatch()}},l={$pattern:e,keyword:n,literal:a,\nbuilt_in:s},b=\"\\\\.([0-9](_?[0-9])*)\",d=\"0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*\",g={\nclassName:\"number\",variants:[{\nbegin:`(\\\\b(${d})((${b})|\\\\.)?|(${b}))[eE][+-]?([0-9](_?[0-9])*)\\\\b`},{\nbegin:`\\\\b(${d})\\\\b((${b})\\\\b|\\\\.)?|(${b})\\\\b`},{\nbegin:\"\\\\b(0|[1-9](_?[0-9])*)n\\\\b\"},{\nbegin:\"\\\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\\\b\"},{\nbegin:\"\\\\b0[bB][0-1](_?[0-1])*n?\\\\b\"},{begin:\"\\\\b0[oO][0-7](_?[0-7])*n?\\\\b\"},{\nbegin:\"\\\\b0[0-7]+n?\\\\b\"}],relevance:0},u={className:\"subst\",begin:\"\\\\$\\\\{\",\nend:\"\\\\}\",keywords:l,contains:[]},E={begin:\"html`\",end:\"\",starts:{end:\"`\",\nreturnEnd:!1,contains:[i.BACKSLASH_ESCAPE,u],subLanguage:\"xml\"}},m={\nbegin:\"css`\",end:\"\",starts:{end:\"`\",returnEnd:!1,\ncontains:[i.BACKSLASH_ESCAPE,u],subLanguage:\"css\"}},y={className:\"string\",\nbegin:\"`\",end:\"`\",contains:[i.BACKSLASH_ESCAPE,u]},_={className:\"comment\",\nvariants:[i.COMMENT(/\\/\\*\\*(?!\\/)/,\"\\\\*/\",{relevance:0,contains:[{\nclassName:\"doctag\",begin:\"@[A-Za-z]+\",contains:[{className:\"type\",begin:\"\\\\{\",\nend:\"\\\\}\",relevance:0},{className:\"variable\",begin:c+\"(?=\\\\s*(-)|$)\",\nendsParent:!0,relevance:0},{begin:/(?=[^\\n])\\s/,relevance:0}]}]\n}),i.C_BLOCK_COMMENT_MODE,i.C_LINE_COMMENT_MODE]\n},p=[i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,E,m,y,g,i.REGEXP_MODE]\n;u.contains=p.concat({begin:/\\{/,end:/\\}/,keywords:l,contains:[\"self\"].concat(p)\n});const N=[].concat(_,u.contains),f=N.concat([{begin:/\\(/,end:/\\)/,keywords:l,\ncontains:[\"self\"].concat(N)}]),A={className:\"params\",begin:/\\(/,end:/\\)/,\nexcludeBegin:!0,excludeEnd:!0,keywords:l,contains:f};return{name:\"Javascript\",\naliases:[\"js\",\"jsx\",\"mjs\",\"cjs\"],keywords:l,exports:{PARAMS_CONTAINS:f},\nillegal:/#(?![$_A-z])/,contains:[i.SHEBANG({label:\"shebang\",binary:\"node\",\nrelevance:5}),{label:\"use_strict\",className:\"meta\",relevance:10,\nbegin:/^\\s*['\"]use (strict|asm)['\"]/\n},i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,E,m,y,_,g,{\nbegin:r(/[{,\\n]\\s*/,t(r(/(((\\/\\/.*$)|(\\/\\*(\\*[^/]|[^*])*\\*\\/))\\s*)*/,c+\"\\\\s*:\"))),\nrelevance:0,contains:[{className:\"attr\",begin:c+t(\"\\\\s*:\"),relevance:0}]},{\nbegin:\"(\"+i.RE_STARTERS_RE+\"|\\\\b(case|return|throw)\\\\b)\\\\s*\",\nkeywords:\"return throw case\",contains:[_,i.REGEXP_MODE,{className:\"function\",\nbegin:\"(\\\\([^()]*(\\\\([^()]*(\\\\([^()]*\\\\)[^()]*)*\\\\)[^()]*)*\\\\)|\"+i.UNDERSCORE_IDENT_RE+\")\\\\s*=>\",\nreturnBegin:!0,end:\"\\\\s*=>\",contains:[{className:\"params\",variants:[{\nbegin:i.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\\(\\s*\\)/,skip:!0\n},{begin:/\\(/,end:/\\)/,excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f}]}]\n},{begin:/,/,relevance:0},{className:\"\",begin:/\\s/,end:/\\s*/,skip:!0},{\nvariants:[{begin:\"<>\",end:\"</>\"},{begin:o.begin,\"on:begin\":o.isTrulyOpeningTag,\nend:o.end}],subLanguage:\"xml\",contains:[{begin:o.begin,end:o.end,skip:!0,\ncontains:[\"self\"]}]}],relevance:0},{className:\"function\",\nbeginKeywords:\"function\",end:/[{;]/,excludeEnd:!0,keywords:l,\ncontains:[\"self\",i.inherit(i.TITLE_MODE,{begin:c}),A],illegal:/%/},{\nbeginKeywords:\"while if switch catch for\"},{className:\"function\",\nbegin:i.UNDERSCORE_IDENT_RE+\"\\\\([^()]*(\\\\([^()]*(\\\\([^()]*\\\\)[^()]*)*\\\\)[^()]*)*\\\\)\\\\s*\\\\{\",\nreturnBegin:!0,contains:[A,i.inherit(i.TITLE_MODE,{begin:c})]},{variants:[{\nbegin:\"\\\\.\"+c},{begin:\"\\\\$\"+c}],relevance:0},{className:\"class\",\nbeginKeywords:\"class\",end:/[{;=]/,excludeEnd:!0,illegal:/[:\"[\\]]/,contains:[{\nbeginKeywords:\"extends\"},i.UNDERSCORE_TITLE_MODE]},{begin:/\\b(?=constructor)/,\nend:/[{;]/,excludeEnd:!0,contains:[i.inherit(i.TITLE_MODE,{begin:c}),\"self\",A]\n},{begin:\"(get|set)\\\\s+(?=\"+c+\"\\\\()\",end:/\\{/,keywords:\"get set\",\ncontains:[i.inherit(i.TITLE_MODE,{begin:c}),{begin:/\\(\\)/},A]},{begin:/\\$[(.]/}]\n}})(i)\n;return Object.assign(b.keywords,c),b.exports.PARAMS_CONTAINS.push(o),b.contains=b.contains.concat([o,{\nbeginKeywords:\"namespace\",end:/\\{/,excludeEnd:!0},{beginKeywords:\"interface\",\nend:/\\{/,excludeEnd:!0,keywords:\"interface extends\"\n}]),l(b,\"shebang\",i.SHEBANG()),l(b,\"use_strict\",{className:\"meta\",relevance:10,\nbegin:/^\\s*['\"]use strict['\"]/\n}),b.contains.find((e=>\"function\"===e.className)).relevance=0,Object.assign(b,{\nname:\"TypeScript\",aliases:[\"ts\",\"tsx\"]}),b}})());hljs.registerLanguage(\"javascript\",(()=>{\"use strict\"\n;const e=\"[A-Za-z$_][0-9A-Za-z$_]*\",n=[\"as\",\"in\",\"of\",\"if\",\"for\",\"while\",\"finally\",\"var\",\"new\",\"function\",\"do\",\"return\",\"void\",\"else\",\"break\",\"catch\",\"instanceof\",\"with\",\"throw\",\"case\",\"default\",\"try\",\"switch\",\"continue\",\"typeof\",\"delete\",\"let\",\"yield\",\"const\",\"class\",\"debugger\",\"async\",\"await\",\"static\",\"import\",\"from\",\"export\",\"extends\"],a=[\"true\",\"false\",\"null\",\"undefined\",\"NaN\",\"Infinity\"],s=[].concat([\"setInterval\",\"setTimeout\",\"clearInterval\",\"clearTimeout\",\"require\",\"exports\",\"eval\",\"isFinite\",\"isNaN\",\"parseFloat\",\"parseInt\",\"decodeURI\",\"decodeURIComponent\",\"encodeURI\",\"encodeURIComponent\",\"escape\",\"unescape\"],[\"arguments\",\"this\",\"super\",\"console\",\"window\",\"document\",\"localStorage\",\"module\",\"global\"],[\"Intl\",\"DataView\",\"Number\",\"Math\",\"Date\",\"String\",\"RegExp\",\"Object\",\"Function\",\"Boolean\",\"Error\",\"Symbol\",\"Set\",\"Map\",\"WeakSet\",\"WeakMap\",\"Proxy\",\"Reflect\",\"JSON\",\"Promise\",\"Float64Array\",\"Int16Array\",\"Int32Array\",\"Int8Array\",\"Uint16Array\",\"Uint32Array\",\"Float32Array\",\"Array\",\"Uint8Array\",\"Uint8ClampedArray\",\"ArrayBuffer\",\"BigInt64Array\",\"BigUint64Array\",\"BigInt\"],[\"EvalError\",\"InternalError\",\"RangeError\",\"ReferenceError\",\"SyntaxError\",\"TypeError\",\"URIError\"])\n;function r(e){return t(\"(?=\",e,\")\")}function t(...e){return e.map((e=>{\nreturn(n=e)?\"string\"==typeof n?n:n.source:null;var n})).join(\"\")}return i=>{\nconst c=e,o={begin:/<[A-Za-z0-9\\\\._:-]+/,end:/\\/[A-Za-z0-9\\\\._:-]+>|\\/>/,\nisTrulyOpeningTag:(e,n)=>{const a=e[0].length+e.index,s=e.input[a]\n;\"<\"!==s?\">\"===s&&(((e,{after:n})=>{const a=\"</\"+e[0].slice(1)\n;return-1!==e.input.indexOf(a,n)})(e,{after:a\n})||n.ignoreMatch()):n.ignoreMatch()}},l={$pattern:e,keyword:n,literal:a,\nbuilt_in:s},g=\"\\\\.([0-9](_?[0-9])*)\",b=\"0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*\",d={\nclassName:\"number\",variants:[{\nbegin:`(\\\\b(${b})((${g})|\\\\.)?|(${g}))[eE][+-]?([0-9](_?[0-9])*)\\\\b`},{\nbegin:`\\\\b(${b})\\\\b((${g})\\\\b|\\\\.)?|(${g})\\\\b`},{\nbegin:\"\\\\b(0|[1-9](_?[0-9])*)n\\\\b\"},{\nbegin:\"\\\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\\\b\"},{\nbegin:\"\\\\b0[bB][0-1](_?[0-1])*n?\\\\b\"},{begin:\"\\\\b0[oO][0-7](_?[0-7])*n?\\\\b\"},{\nbegin:\"\\\\b0[0-7]+n?\\\\b\"}],relevance:0},E={className:\"subst\",begin:\"\\\\$\\\\{\",\nend:\"\\\\}\",keywords:l,contains:[]},u={begin:\"html`\",end:\"\",starts:{end:\"`\",\nreturnEnd:!1,contains:[i.BACKSLASH_ESCAPE,E],subLanguage:\"xml\"}},_={\nbegin:\"css`\",end:\"\",starts:{end:\"`\",returnEnd:!1,\ncontains:[i.BACKSLASH_ESCAPE,E],subLanguage:\"css\"}},m={className:\"string\",\nbegin:\"`\",end:\"`\",contains:[i.BACKSLASH_ESCAPE,E]},y={className:\"comment\",\nvariants:[i.COMMENT(/\\/\\*\\*(?!\\/)/,\"\\\\*/\",{relevance:0,contains:[{\nclassName:\"doctag\",begin:\"@[A-Za-z]+\",contains:[{className:\"type\",begin:\"\\\\{\",\nend:\"\\\\}\",relevance:0},{className:\"variable\",begin:c+\"(?=\\\\s*(-)|$)\",\nendsParent:!0,relevance:0},{begin:/(?=[^\\n])\\s/,relevance:0}]}]\n}),i.C_BLOCK_COMMENT_MODE,i.C_LINE_COMMENT_MODE]\n},N=[i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,u,_,m,d,i.REGEXP_MODE]\n;E.contains=N.concat({begin:/\\{/,end:/\\}/,keywords:l,contains:[\"self\"].concat(N)\n});const A=[].concat(y,E.contains),f=A.concat([{begin:/\\(/,end:/\\)/,keywords:l,\ncontains:[\"self\"].concat(A)}]),p={className:\"params\",begin:/\\(/,end:/\\)/,\nexcludeBegin:!0,excludeEnd:!0,keywords:l,contains:f};return{name:\"Javascript\",\naliases:[\"js\",\"jsx\",\"mjs\",\"cjs\"],keywords:l,exports:{PARAMS_CONTAINS:f},\nillegal:/#(?![$_A-z])/,contains:[i.SHEBANG({label:\"shebang\",binary:\"node\",\nrelevance:5}),{label:\"use_strict\",className:\"meta\",relevance:10,\nbegin:/^\\s*['\"]use (strict|asm)['\"]/\n},i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,u,_,m,y,d,{\nbegin:t(/[{,\\n]\\s*/,r(t(/(((\\/\\/.*$)|(\\/\\*(\\*[^/]|[^*])*\\*\\/))\\s*)*/,c+\"\\\\s*:\"))),\nrelevance:0,contains:[{className:\"attr\",begin:c+r(\"\\\\s*:\"),relevance:0}]},{\nbegin:\"(\"+i.RE_STARTERS_RE+\"|\\\\b(case|return|throw)\\\\b)\\\\s*\",\nkeywords:\"return throw case\",contains:[y,i.REGEXP_MODE,{className:\"function\",\nbegin:\"(\\\\([^()]*(\\\\([^()]*(\\\\([^()]*\\\\)[^()]*)*\\\\)[^()]*)*\\\\)|\"+i.UNDERSCORE_IDENT_RE+\")\\\\s*=>\",\nreturnBegin:!0,end:\"\\\\s*=>\",contains:[{className:\"params\",variants:[{\nbegin:i.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\\(\\s*\\)/,skip:!0\n},{begin:/\\(/,end:/\\)/,excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f}]}]\n},{begin:/,/,relevance:0},{className:\"\",begin:/\\s/,end:/\\s*/,skip:!0},{\nvariants:[{begin:\"<>\",end:\"</>\"},{begin:o.begin,\"on:begin\":o.isTrulyOpeningTag,\nend:o.end}],subLanguage:\"xml\",contains:[{begin:o.begin,end:o.end,skip:!0,\ncontains:[\"self\"]}]}],relevance:0},{className:\"function\",\nbeginKeywords:\"function\",end:/[{;]/,excludeEnd:!0,keywords:l,\ncontains:[\"self\",i.inherit(i.TITLE_MODE,{begin:c}),p],illegal:/%/},{\nbeginKeywords:\"while if switch catch for\"},{className:\"function\",\nbegin:i.UNDERSCORE_IDENT_RE+\"\\\\([^()]*(\\\\([^()]*(\\\\([^()]*\\\\)[^()]*)*\\\\)[^()]*)*\\\\)\\\\s*\\\\{\",\nreturnBegin:!0,contains:[p,i.inherit(i.TITLE_MODE,{begin:c})]},{variants:[{\nbegin:\"\\\\.\"+c},{begin:\"\\\\$\"+c}],relevance:0},{className:\"class\",\nbeginKeywords:\"class\",end:/[{;=]/,excludeEnd:!0,illegal:/[:\"[\\]]/,contains:[{\nbeginKeywords:\"extends\"},i.UNDERSCORE_TITLE_MODE]},{begin:/\\b(?=constructor)/,\nend:/[{;]/,excludeEnd:!0,contains:[i.inherit(i.TITLE_MODE,{begin:c}),\"self\",p]\n},{begin:\"(get|set)\\\\s+(?=\"+c+\"\\\\()\",end:/\\{/,keywords:\"get set\",\ncontains:[i.inherit(i.TITLE_MODE,{begin:c}),{begin:/\\(\\)/},p]},{begin:/\\$[(.]/}]\n}}})());hljs.registerLanguage(\"r\",(()=>{\"use strict\";function e(...e){return e.map((e=>{\nreturn(a=e)?\"string\"==typeof a?a:a.source:null;var a})).join(\"\")}return a=>{\nconst n=/(?:(?:[a-zA-Z]|\\.[._a-zA-Z])[._a-zA-Z0-9]*)|\\.(?!\\d)/;return{name:\"R\",\nillegal:/->/,keywords:{$pattern:n,\nkeyword:\"function if in break next repeat else for while\",\nliteral:\"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10\",\nbuilt_in:\"LETTERS letters month.abb month.name pi T F abs acos acosh all any anyNA Arg as.call as.character as.complex as.double as.environment as.integer as.logical as.null.default as.numeric as.raw asin asinh atan atanh attr attributes baseenv browser c call ceiling class Conj cos cosh cospi cummax cummin cumprod cumsum digamma dim dimnames emptyenv exp expression floor forceAndCall gamma gc.time globalenv Im interactive invisible is.array is.atomic is.call is.character is.complex is.double is.environment is.expression is.finite is.function is.infinite is.integer is.language is.list is.logical is.matrix is.na is.name is.nan is.null is.numeric is.object is.pairlist is.raw is.recursive is.single is.symbol lazyLoadDBfetch length lgamma list log max min missing Mod names nargs nzchar oldClass on.exit pos.to.env proc.time prod quote range Re rep retracemem return round seq_along seq_len seq.int sign signif sin sinh sinpi sqrt standardGeneric substitute sum switch tan tanh tanpi tracemem trigamma trunc unclass untracemem UseMethod xtfrm\"\n},compilerExtensions:[(a,n)=>{if(!a.beforeMatch)return\n;if(a.starts)throw Error(\"beforeMatch cannot be used with starts\")\n;const i=Object.assign({},a);Object.keys(a).forEach((e=>{delete a[e]\n})),a.begin=e(i.beforeMatch,e(\"(?=\",i.begin,\")\")),a.starts={relevance:0,\ncontains:[Object.assign(i,{endsParent:!0})]},a.relevance=0,delete i.beforeMatch\n}],contains:[a.COMMENT(/#'/,/$/,{contains:[{className:\"doctag\",\nbegin:\"@examples\",starts:{contains:[{begin:/\\n/},{begin:/#'\\s*(?=@[a-zA-Z]+)/,\nendsParent:!0},{begin:/#'/,end:/$/,excludeBegin:!0}]}},{className:\"doctag\",\nbegin:\"@param\",end:/$/,contains:[{className:\"variable\",variants:[{begin:n},{\nbegin:/`(?:\\\\.|[^`\\\\])+`/}],endsParent:!0}]},{className:\"doctag\",\nbegin:/@[a-zA-Z]+/},{className:\"meta-keyword\",begin:/\\\\[a-zA-Z]+/}]\n}),a.HASH_COMMENT_MODE,{className:\"string\",contains:[a.BACKSLASH_ESCAPE],\nvariants:[a.END_SAME_AS_BEGIN({begin:/[rR]\"(-*)\\(/,end:/\\)(-*)\"/\n}),a.END_SAME_AS_BEGIN({begin:/[rR]\"(-*)\\{/,end:/\\}(-*)\"/\n}),a.END_SAME_AS_BEGIN({begin:/[rR]\"(-*)\\[/,end:/\\](-*)\"/\n}),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\\(/,end:/\\)(-*)'/\n}),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\\{/,end:/\\}(-*)'/\n}),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\\[/,end:/\\](-*)'/}),{begin:'\"',end:'\"',\nrelevance:0},{begin:\"'\",end:\"'\",relevance:0}]},{className:\"number\",relevance:0,\nbeforeMatch:/([^a-zA-Z0-9._])/,variants:[{\nmatch:/0[xX][0-9a-fA-F]+\\.[0-9a-fA-F]*[pP][+-]?\\d+i?/},{\nmatch:/0[xX][0-9a-fA-F]+([pP][+-]?\\d+)?[Li]?/},{\nmatch:/(\\d+(\\.\\d*)?|\\.\\d+)([eE][+-]?\\d+)?[Li]?/}]},{begin:\"%\",end:\"%\"},{\nbegin:e(/[a-zA-Z][a-zA-Z_0-9]*/,\"\\\\s+<-\\\\s+\")},{begin:\"`\",end:\"`\",contains:[{\nbegin:/\\\\./}]}]}}})());hljs.registerLanguage(\"yaml\",(()=>{\"use strict\";return e=>{\nvar n=\"true false yes no null\",a=\"[\\\\w#;/?:@&=+$,.~*'()[\\\\]]+\",s={\nclassName:\"string\",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/\"/,end:/\"/\n},{begin:/\\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:\"template-variable\",\nvariants:[{begin:/\\{\\{/,end:/\\}\\}/},{begin:/%\\{/,end:/\\}/}]}]},i=e.inherit(s,{\nvariants:[{begin:/'/,end:/'/},{begin:/\"/,end:/\"/},{begin:/[^\\s,{}[\\]]+/}]}),l={\nend:\",\",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},t={begin:/\\{/,\nend:/\\}/,contains:[l],illegal:\"\\\\n\",relevance:0},g={begin:\"\\\\[\",end:\"\\\\]\",\ncontains:[l],illegal:\"\\\\n\",relevance:0},b=[{className:\"attr\",variants:[{\nbegin:\"\\\\w[\\\\w :\\\\/.-]*:(?=[ \\t]|$)\"},{begin:'\"\\\\w[\\\\w :\\\\/.-]*\":(?=[ \\t]|$)'},{\nbegin:\"'\\\\w[\\\\w :\\\\/.-]*':(?=[ \\t]|$)\"}]},{className:\"meta\",begin:\"^---\\\\s*$\",\nrelevance:10},{className:\"string\",\nbegin:\"[\\\\|>]([1-9]?[+-])?[ ]*\\\\n( +)[^ ][^\\\\n]*\\\\n(\\\\2[^\\\\n]+\\\\n?)*\"},{\nbegin:\"<%[%=-]?\",end:\"[%-]?%>\",subLanguage:\"ruby\",excludeBegin:!0,excludeEnd:!0,\nrelevance:0},{className:\"type\",begin:\"!\\\\w+!\"+a},{className:\"type\",\nbegin:\"!<\"+a+\">\"},{className:\"type\",begin:\"!\"+a},{className:\"type\",begin:\"!!\"+a\n},{className:\"meta\",begin:\"&\"+e.UNDERSCORE_IDENT_RE+\"$\"},{className:\"meta\",\nbegin:\"\\\\*\"+e.UNDERSCORE_IDENT_RE+\"$\"},{className:\"bullet\",begin:\"-(?=[ ]|$)\",\nrelevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{\nclassName:\"number\",\nbegin:\"\\\\b[0-9]{4}(-[0-9][0-9]){0,2}([Tt \\\\t][0-9][0-9]?(:[0-9][0-9]){2})?(\\\\.[0-9]*)?([ \\\\t])*(Z|[-+][0-9][0-9]?(:[0-9][0-9])?)?\\\\b\"\n},{className:\"number\",begin:e.C_NUMBER_RE+\"\\\\b\",relevance:0},t,g,s],r=[...b]\n;return r.pop(),r.push(i),l.contains=r,{name:\"YAML\",case_insensitive:!0,\naliases:[\"yml\"],contains:b}}})());hljs.registerLanguage(\"vbnet\",(()=>{\"use strict\";function e(e){\nreturn e?\"string\"==typeof e?e:e.source:null}function n(...n){\nreturn n.map((n=>e(n))).join(\"\")}function t(...n){\nreturn\"(\"+n.map((n=>e(n))).join(\"|\")+\")\"}return e=>{\nconst a=/\\d{1,2}\\/\\d{1,2}\\/\\d{4}/,i=/\\d{4}-\\d{1,2}-\\d{1,2}/,s=/(\\d|1[012])(:\\d+){0,2} *(AM|PM)/,r=/\\d{1,2}(:\\d{1,2}){1,2}/,o={\nclassName:\"literal\",variants:[{begin:n(/# */,t(i,a),/ *#/)},{\nbegin:n(/# */,r,/ *#/)},{begin:n(/# */,s,/ *#/)},{\nbegin:n(/# */,t(i,a),/ +/,t(s,r),/ *#/)}]},l=e.COMMENT(/'''/,/$/,{contains:[{\nclassName:\"doctag\",begin:/<\\/?/,end:/>/}]}),c=e.COMMENT(null,/$/,{variants:[{\nbegin:/'/},{begin:/([\\t ]|^)REM(?=\\s)/}]});return{name:\"Visual Basic .NET\",\naliases:[\"vb\"],case_insensitive:!0,classNameAliases:{label:\"symbol\"},keywords:{\nkeyword:\"addhandler alias aggregate ansi as async assembly auto binary by byref byval call case catch class compare const continue custom declare default delegate dim distinct do each equals else elseif end enum erase error event exit explicit finally for friend from function get global goto group handles if implements imports in inherits interface into iterator join key let lib loop me mid module mustinherit mustoverride mybase myclass namespace narrowing new next notinheritable notoverridable of off on operator option optional order overloads overridable overrides paramarray partial preserve private property protected public raiseevent readonly redim removehandler resume return select set shadows shared skip static step stop structure strict sub synclock take text then throw to try unicode until using when where while widening with withevents writeonly yield\",\nbuilt_in:\"addressof and andalso await directcast gettype getxmlnamespace is isfalse isnot istrue like mod nameof new not or orelse trycast typeof xor cbool cbyte cchar cdate cdbl cdec cint clng cobj csbyte cshort csng cstr cuint culng cushort\",\ntype:\"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort\",\nliteral:\"true false nothing\"},\nillegal:\"//|\\\\{|\\\\}|endif|gosub|variant|wend|^\\\\$ \",contains:[{\nclassName:\"string\",begin:/\"(\"\"|[^/n])\"C\\b/},{className:\"string\",begin:/\"/,\nend:/\"/,illegal:/\\n/,contains:[{begin:/\"\"/}]},o,{className:\"number\",relevance:0,\nvariants:[{begin:/\\b\\d[\\d_]*((\\.[\\d_]+(E[+-]?[\\d_]+)?)|(E[+-]?[\\d_]+))[RFD@!#]?/\n},{begin:/\\b\\d[\\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\\dA-F_]+((U?[SIL])|[%&])?/},{\nbegin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{\nclassName:\"label\",begin:/^\\w+:/},l,c,{className:\"meta\",\nbegin:/[\\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\\b/,\nend:/$/,keywords:{\n\"meta-keyword\":\"const disable else elseif enable end externalsource if region then\"\n},contains:[c]}]}}})());"
  },
  {
    "path": "site-defaults/web/highlight-js/styles/vs.css",
    "content": "/*\n\nVisual Studio-like style based on original C# coloring by Jason Diamond <jason@diamond.name>\n\n*/\n.hljs {\n  display: block;\n  overflow-x: auto;\n  padding: 0.5em;\n  background: white;\n  color: black;\n}\n\n.hljs-comment,\n.hljs-quote,\n.hljs-variable {\n  color: #008000;\n}\n\n.hljs-keyword,\n.hljs-selector-tag,\n.hljs-built_in,\n.hljs-name,\n.hljs-tag {\n  color: #00f;\n}\n\n.hljs-string,\n.hljs-title,\n.hljs-section,\n.hljs-attribute,\n.hljs-literal,\n.hljs-template-tag,\n.hljs-template-variable,\n.hljs-type,\n.hljs-addition {\n  color: #a31515;\n}\n\n.hljs-deletion,\n.hljs-selector-attr,\n.hljs-selector-pseudo,\n.hljs-meta {\n  color: #2b91af;\n}\n\n.hljs-doctag {\n  color: #808080;\n}\n\n.hljs-attr {\n  color: #f00;\n}\n\n.hljs-symbol,\n.hljs-bullet,\n.hljs-link {\n  color: #00b0e8;\n}\n\n\n.hljs-emphasis {\n  font-style: italic;\n}\n\n.hljs-strong {\n  font-weight: bold;\n}\n"
  },
  {
    "path": "site-defaults/web/skel.htt",
    "content": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title><?title?> - Forum</title>\n<link rel=\"shortcut icon\" href=\"<?static:/favicon.ico?>\">\n<?extraheaders?>\n<link rel=\"stylesheet\" href=\"<?static:/css/style.css?>\">\n<link rel=\"stylesheet\" href=\"<?static:/css/dfeed.css?>\">\n</head>\n<body class=\"forum <?bodyclass?>\">\n<script>document.body.className += ' have-javascript'</script>\n\n<div id=\"top\">\n    <div class=\"site-header\">\n        <div class=\"logo\"><a href=\"/\">Forum</a></div>\n        <div id=\"search-box\">\n            <form method=\"get\" action=\"/search\">\n                <input id=\"q\" name=\"q\" placeholder=\"Search\">\n                <select id=\"search-scope\" name=\"scope\">\n                    <?search-options?>\n                </select>\n                <button type=\"submit\">Search</button>\n            </form>\n        </div>\n    </div>\n</div>\n\n<div class=\"container\">\n    <div class=\"subnav\">\n        <div class=\"head\">\n            <h2>Forums</h2>\n            <p><a href=\"/\">Forum Index</a></p>\n        </div>\n        <ul>\n            <li><?category1?> <ul>\n                <li<?class1?>><a href=\"<?url1?>\"><?title1?></a></li>\n                <li<?class2?>><a href=\"<?url2?>\"><?title2?></a></li>\n            </ul></li>\n            <li><?category2?> <ul>\n                <li<?class1?>><a href=\"<?url1?>\"><?title1?></a></li>\n                <li<?class2?>><a href=\"<?url2?>\"><?title2?></a></li>\n            </ul></li>\n        </ul>\n    </div>\n\n    <div id=\"content\">\n        <div class=\"smallprint\" id=\"tools\"><?tools?></div>\n        <div id=\"forum-content\"><?content?></div>\n\n        <div id=\"footernav\">\n            <a href=\"#top\">Top</a> |\n            <a href=\"/\">Forum index</a> |\n            <a href=\"/help\">Help</a>\n        </div>\n        <div class=\"smallprint\" id=\"copyright\">\n            Powered by <a href=\"https://github.com/CyberShadow/DFeed\">DFeed</a>\n        </div>\n    </div>\n</div>\n\n<script src=\"https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js\"></script>\n<script><?extrajs?></script>\n<script src=\"<?static:/js/dfeed.js?>\"></script>\n<link rel=\"stylesheet\" href=\"<?static:/css/highlight-js.css?>\">\n<script src=\"<?static:/js/highlight.js?>\"></script>\n</body>\n</html>\n"
  },
  {
    "path": "site-defaults/web/static/css/dfeed.css",
    "content": "/*\n * DFeed Forum Stylesheet\n *\n * Forum-specific styles that work alongside style.css (base styles).\n * The first section contains overrides for base styles.\n */\n\n/*************** Base style adjustments ***************/\n\ntable, table th {\n\tborder-style: none;\n}\n\ndiv#content {\n\tline-height: inherit;\n\ttext-align: left;\n\tmin-height: 0;\n}\n\ntable th, table caption {\n\ttext-align: center;\n}\n\ntable td {\n\ttext-align: left;\n}\n\ntable td, table th, table caption {\n\tvertical-align: inherit;\n\tpadding: 0;\n}\n\nh1 {\n\tmargin-bottom: 0.5em;\n}\n\ndiv#footernav {\n\tclear: none;\n}\n\ndiv#copyright {\n\tmargin-top: 1em;\n}\n\npre {\n\tbackground: inherit;\n\tborder: none;\n\tpadding: 0;\n\tborder-radius: 0;\n}\n\ntable td {\n\tborder-bottom: none;\n}\n\ntable td:not(:last-child), table th:not(:last-child) {\n\tpadding-right: 0;\n}\n\n/* Navigation menu */\n\n.subnav, .subnav-helper {\n\twidth: 7em;\n}\n\n.subnav > ul > li {\n\tmargin-top: 0.6em;\n}\n\n.subnav + #content {\n\tmargin-left: 10em;\n}\n\n/*************** Navigation toggle ***************/\n\nbody.navhidden #top,\nbody.navhidden .subnav,\nbody.navhidden .subnav-helper {\n\tdisplay: none;\n}\nbody.navhidden #content {\n\tmargin-left: 0;\n}\n\n/*************** General layout ***************/\n\n#top {\n\tposition: relative;\n}\n\n#tools {\n\toverflow: hidden;\n}\n#forum-tools-left {\n\tfloat: left;\n\twhite-space: pre;\n\tmax-width: 100%;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n#forum-tools-right {\n\tfloat: right;\n}\n\n#content {\n\toverflow: hidden;\n}\n#content > h1 {\n\tdisplay: none;\n}\n\n/*************** General rules ***************/\n\n#forum-content > table {\n\tposition: relative;\n}\n\n#forum-content table {\n\twidth: 100%;\n\tborder-spacing: 0;\n\tborder-collapse: collapse;\n}\n\n#forum-content table tr.table-fixed-dummy,\n#forum-content table tr.table-fixed-dummy td {\n\tmargin : 0 !important;\n\tpadding: 0 !important;\n\tborder : 0 !important;\n}\n\n#forum-content input[type=submit] {\n\tmargin: 0; /* for Chrome */\n}\n\n.temphide {\n\tdisplay: none !important;\n}\n\n.nowrap {\n\twhite-space: nowrap;\n}\n\n.avoid-wrap {\n\tdisplay: inline-block;\n}\n\nspan.truncated {\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tdisplay: inline-block;\n\tvertical-align: top;\n}\n\na span.truncated {\n\ttext-decoration: underline;\n}\n\ndiv.truncated {\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tmax-width: 100%;\n}\n\na.secretlink,\na.secretlink:hover,\na.secretlink:active,\na.secretlink:visited {\n\tcolor: inherit;\n\ttext-decoration: none;\n}\n\n/* A forum-table has a gray header and borders */\n\n.forum-table {\n\tborder: 2px solid #E6E6E6;\n\ttable-layout: fixed;\n}\n\n.forum-table > tbody > tr > th {\n\tbackground-color: #E6E6E6;\n}\n\n.forum-table > tbody > tr.subheader > th {\n\tbackground-color: #F5F5F5;\n}\n\n.forum-table > tbody > tr > td,\n.forum-table > tbody > tr > th {\n\tpadding: 0.33em;\n\tborder: 1px solid #E6E6E6;\n\toverflow: hidden;\n}\n\n.number-column {\n\ttext-align: center;\n}\n\n.forum-postsummary-author {\n\tcolor: #777;\n}\n\na.forum-postsummary-author {\n\tcolor: inherit;\n\ttext-decoration: none;\n}\n\na.forum-postsummary-author:hover {\n\ttext-decoration: underline;\n}\n\n#forum-content a img {\n\tborder: none;\n}\n\nimg.post-gravatar {\n\tdisplay: block;\n}\n\n/* Read/unread messages */\n\n.forum-unread,\n.forum-unread:visited {\n\tfont-weight: bold;\n}\n.forum-read,\n.forum-read:visited {\n\tfont-weight: normal;\n}\n\n.forum-unread:visited, .forum-read:visited {\n\tcolor: #723D39;\n}\n\n/* Use a thinner font for the split-mode index */\n\n.viewmode-horizontal-split {\n\tfont-family: 'Open Sans', Tahoma, 'Deja Vu', 'Bitstream Vera Sans', sans-serif;\n}\n\n/* \"Create thread\" button */\n\n.header-tools {\n\tposition: absolute;\n\ttop: 3px;\n\tright: 3px;\n}\n\n.header-tools .img {\n\tdisplay: none;\n\tmargin: 4px;\n}\n\n/* Pager */\n\n.pager-row {\n\tpadding: 0 !important;\n}\n\n.pager {\n\tfont-weight: normal;\n\tdisplay: flex;\n\tjustify-content: space-between;\n\talign-items: center;\n\tpadding: 5px 0.33em;\n}\n\n.viewmode-narrow-index .pager {\n\tpadding: 5px 0;\n}\n\n.pager-left {\n\tflex: 0 1 auto;\n\tmargin-right: 5px;\n\ttext-align: left;\n\twhite-space: nowrap;\n}\n\n.pager-numbers {\n\tflex: 1 1 auto;\n\tmargin: 0 5px;\n\ttext-align: center;\n}\n\n.pager-right {\n\tflex: 0 1 auto;\n\tmargin-left: 5px;\n\ttext-align: right;\n\twhite-space: nowrap;\n}\n\n.disabled-link {\n\tcolor: #999;\n}\n\n/* Forms */\n\n.forum-form input,\n.forum-form textarea {\n\tmax-width: 100%;\n\tbox-sizing: border-box;\n}\n\n.forum-form textarea {\n\t/* We are working relative to the default UA font-size */\n\tfont-size: 120%;\n}\n\n.post-form > label,\n.forum-form input[type=submit] {\n\tmargin-top: 0.75em !important;\n\tdisplay: block;\n}\n.post-form textarea {\n\tdisplay: block;\n}\n.post-form input[type=submit] {\n\tdisplay: inline-block;\n}\n\n.form-error {\n\tborder: 2px dotted red;\n\tcolor: red;\n\tpadding: 0.75em;\n\tmargin-bottom: 0.75em;\n}\n\n.forum-form .form-error input[type=submit],\n.forum-form .forum-notice input[type=submit] {\n\tmargin: 0 !important;\n}\n\n.form-error .lint-description {\n\tcolor: #333;\n}\n\n/* Notice */\n\n.forum-notice {\n\tborder: 2px solid #E0E0A0;\n\tbackground-color: #FFFFD8;\n\tpadding: 0.5em;\n\tmargin-bottom: 1em;\n\ttext-align: center;\n}\n\n/* Footer */\n\ndiv#footernav {\n\tmargin-top: 2em;\n\ttext-align: center;\n}\ndiv#footernav a {\n\twhite-space: nowrap;\n}\n\n/*************** Discussion index ***************/\n\n#forum-index-header {\n\tmargin: 0 0.5em 0.5em 0.5em;\n}\n\n#forum-index > tbody > tr > td:nth-child(even) {\n\tbackground-color: #FCFCFC;\n}\n\n#forum-index > tbody > tr > td:nth-child(1) { width: 60%; }\n#forum-index > tbody > tr > td:nth-child(2) { width: 40%; }\n#forum-index > tbody > tr > td:nth-child(3) { width: 5em; }\n#forum-index > tbody > tr > td:nth-child(4) { width: 5em; }\n#forum-index > tbody > tr > td:nth-child(5) { width: 6.2em; }\n\n.forum-index-description {\n\tcolor: #777;\n\tline-height: 1.2em;\n\theight: 2.4em;\n\toverflow: hidden;\n\n\tdisplay: -webkit-box;\n\t-webkit-line-clamp: 2;\n\t-webkit-box-orient: vertical;\n}\n\n.forum-index-description,\n.forum-index-col-lastpost {\n}\n\n.forum-postsummary-time {\n\tdisplay: block;\n\ttext-align: right;\n}\n\n#forum-index .focused {\n\toutline: 1px solid #F99 !important;\n\tbackground-color: #FEE;\n}\n#forum-index .focused > td {\n\tbackground: none !important;\n}\n\n/*************** Group index ***************/\n\n#group-index > tbody > tr > td:nth-child(even) {\n\tbackground-color: #FCFCFC;\n}\n\n#group-index > tbody > tr > td:nth-child(1) { width: 75%; }\n#group-index > tbody > tr > td:nth-child(2) { width: 25%; }\n#group-index > tbody > tr > td:nth-child(3) { width: 5.5em; }\n\n.forum-postsummary-time {\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n.group-index-col-first {\n\twidth: 70%;\n}\n\n.group-index-col-last {\n\ttext-align: right;\n}\n\n#group-index .focused {\n\toutline: 1px solid #F99 !important;\n\tbackground-color: #FEE;\n}\n#group-index .focused > td {\n\tbackground: none !important;\n}\n\n#group-index a.forum-postsummary-gravatar {\n\tfloat: left;\n\tmargin-right: 0.5em;\n\tdisplay: block;\n}\n\n#group-index a.forum-postsummary-gravatar img.post-gravatar {\n\twidth : 2.2rem;\n\theight: 2.2rem;\n\tborder-width: 0.3rem;\n\tmargin: 0;\n\toverflow: hidden;\n\tfont-size: 6pt;\n\ttext-align: center;\n}\n\n/*************** Thread view, individual posts ***************/\n\n/* Thread overview */\n\n#thread-overview {\n\tmargin-bottom: 0.75em;\n}\n\n#thread-overview .group-index-header > th {\n\toverflow: visible;\n\tfont-weight: normal;\n\ttext-align: left;\n}\n\n#thread-overview .group-index-header > th > a {\n\tcolor: black;\n}\n\n#thread-overview .group-index-header > th > a:hover {\n\ttext-decoration: underline;\n}\n\n.thread-overview-pager {\n\tfloat: right;\n\tposition: relative;\n}\n\n.thread-overview-pager-expanded {\n\tposition: absolute;\n\ttop: 100%;\n\tz-index: 5;\n\twhite-space: pre;\n\tright: 0;\n\tbackground-color: #E6E6E6;\n\tpadding: 0.5em;\n\tborder: 2px solid #CCC;\n\tbox-shadow: 0 0 10px rgba(128,128,128,0.25);\n}\n\n.thread-overview-pager-pageno {\n\twidth: 4em;\n}\n\n/* Thread overview - expandos */\n\nbody.have-javascript .forum-expand-container .forum-expand-toggle::after\n{\n    content: \" \\f0d7\"; /* caret down */\n    font-family: FontAwesome;\n}\nbody.have-javascript .forum-expand-container.open#thread-overview th > a.forum-expand-toggle::after,\nbody.have-javascript .forum-expand-container.open.thread-overview-pager .forum-expand-toggle::after\n{\n    content: \" \\f0d8\"; /* caret up */\n}\n\nbody.have-javascript .forum-expand-container .forum-expand-content,\n.thread-overview-pager-expanded\n{\n    display: none;\n}\n\nbody.have-javascript .forum-expand-container .forum-expand-toggle,\nbody.have-javascript .forum-expand-container.open#thread-overview > tbody > tr.forum-expand-content,\nbody.have-javascript .forum-expand-container.open.thread-overview-pager .forum-expand-content\n{\n    display: block;\n}\n\n/* Posts */\n\n#thread-posts > .post-wrapper:first-child > .post {\n\tmargin-top: 0;\n}\n.post-wrapper {\n\tposition: relative;\n}\n.post {\n\tmargin-top: 0.75em;\n}\n\n.post > tbody > tr > td {\n\tvertical-align: top;\n\tpadding: 0.75em;\n}\n\n.post > tbody > tr > td:nth-child(1) { width: 10em; }\n\n/* Header */\n\n.post-header > th {\n\ttext-align: left;\n}\n.post-header a {\n\ttext-decoration: none;\n\tcolor: inherit;\n}\n.post-header a:hover {\n\ttext-decoration: underline;\n}\n.post-time {\n\tfloat: right;\n\tfont-weight: normal;\n}\n\n/* Info */\n\n.post-info {\n\twidth: 20%;\n\tbackground-color: #F5F5F5;\n\tfont-size: 85%;\n\tword-wrap: break-word;\n}\n.post-author {\n\tfont-weight: bold;\n\tfont-size: 130%;\n}\n#forum-content a img.post-gravatar {\n\tmargin: 0.35em;\n\tborder: 5px solid #E6E6E6;\n}\n.post-info > hr {\n\tborder: 1px solid #E6E6E6;\n\tborder-bottom: none;\n\tmargin: 0.5em 0;\n}\n.post-info-bit {\n\tmargin-bottom: 1em;\n}\nul.post-info-parts {\n\tmargin: 0;\n\tpadding-left: 0;\n\tlist-style-position: inside;\n}\n.post-actions {\n\tposition: absolute;\n\tbottom: 1em;\n}\n.actionlink {\n\tdisplay: block;\n\twhite-space: pre;\n}\n.actionlink img {\n\tvertical-align: middle;\n\tmargin-right: 5px;\n}\n\n/* Horizontal info (responsive view) */\n\ntr.mini-post-info-cell > td {\n\tpadding: 0 !important;\n}\n\n/* Body */\n\n.post-error {\n\tcolor: red;\n}\n.post-text {\n\tmargin: 0;\n\tfont-family: Consolas, Lucida Console, Menlo, monospace;\n\tfont-size: 1em;\n\twhite-space: pre; /* old browser compat */\n\twhite-space: pre-wrap;\n\tword-wrap: break-word;\n}\n\n.forum-quote, .forum-signature {\n\tcolor: #666;\n}\n\n.forum-quote {\n\tdisplay: block;\n\tborder-left: 4px solid #E6E6E6;\n\tpadding-left: 4px;\n\t/* HACK: 0.6 (instead of 0.5) fixes Chrome without affecting Firefox. */\n\tborder-left: 0.6ch solid #E6E6E6;\n\tpadding-left: 1.5ch;\n}\n\n.forum-quote-prefix {\n\t/*\n\t\tMake it so the quote prefix is invisible and has zero width,\n\t\tbut is still copied when the user selects and copies text\n\t\tfrom the web page.\n\t\thttps://thejh.net/misc/website-terminal-copy-paste\n\t*/\n\tposition: absolute;\n\tleft: -1000px;\n\ttop: -1000px;\n}\n\n.forcewrap {\n\tword-break: break-all;\n\tdisplay: inline-block;\n}\n\na > .forcewrap {\n\ttext-decoration: underline;\n}\n\n/* Body (Markdown) */\n\n.post-text.markdown {\n\tline-height: normal;\n\twhite-space: normal;\n\tmargin: -15px 0;\n\n\tfont-family: \"Roboto Slab\", sans-serif;\n\tfont-size: 16px;\n\n\toverflow-wrap: anywhere;\n}\n.post-text.markdown pre {\n\tfont-family: Consolas, Lucida Console, Menlo, monospace;\n\tfont-size: inherit;\n\tbackground-color: #F5F5F5;\n\toutline: 1px solid #E6E6E6;\n\tpadding: 4px;\n}\n.post-text.markdown code {\n\tfont-family: Consolas, Lucida Console, Menlo, monospace;\n\tbackground-color: #F5F5F5;\n\toutline: 1px solid #E6E6E6;\n\tpadding: 0 1px;\n\tmargin: 0 1px;\n}\n.post-text.markdown pre code {\n\tfont-family: unset;\n\tbackground-color: unset;\n\toutline: unset;\n\tpadding: 0;\n\tmargin: 0;\n\ttab-size: 4;\n\t-moz-tab-size: 4;\n}\n#forum-content .post-text.markdown table {\n\tborder-spacing: initial;\n\tmargin: 16px 0;\n}\n#forum-content .post-text.markdown th {\n\tbackground-color: #F5F5F5;\n}\n#forum-content .post-text.markdown th,\n#forum-content .post-text.markdown td {\n\tborder: 1px solid #E6E6E6;\n\tpadding: 0.1em 0.3em;\n}\n#forum-content .post-text.markdown h1,\n#forum-content .post-text.markdown h2,\n#forum-content .post-text.markdown h3,\n#forum-content .post-text.markdown h4,\n#forum-content .post-text.markdown h5,\n#forum-content .post-text.markdown h6,\n#forum-content .post-text.markdown h7 {\n\t/* TODO: replace with \"all: revert;\" when that becomes more available */\n\tmargin: 16px 0;\n\tfont-weight: bold;\n}\n\n#forum-content .post-text.markdown img {\n\tmax-width: 100%;\n}\n\n/* Threading */\n\n.post-nester {\n\tborder-collapse: collapse;\n\twidth: 100%;\n}\n\n.post-nester > tbody > tr > td {\n\tpadding: 0;\n}\n\n.post-nester-bar {\n\tposition: relative;\n\twidth: 10px;\n\tvertical-align: top;\n\n\tbackground: url('/images/nestgrad.png');\n}\n\n.post-nester-bar a {\n\tdisplay: block;\n\tposition: absolute;\n\twidth: 10px;\n\n\ttop: 0;\n\tbottom: 0;\n\tborder-bottom: 1px solid #E6E6E6;\n}\n\n/* Pager */\n\n.post-pager {\n\tmargin-top: 10px;\n}\n\n/* Keyboard navigation */\n\n.post.focused {\n\tborder-left-color: #F99;\n}\n\n.linknav:after {\n\tcontent: \"[\" attr(data-num) \"]\";\n\tvertical-align: top;\n\tfont-size: 75%;\n/*\n\tposition: absolute;\n\tmargin-left: -1.5em;\n\tdisplay: inline-block;\n\tbackground-color: #FEA;\n\tborder: 1px solid #DC8;\n*/\n}\n\n/*************** Threaded group view ***************/\n\n.forum-table > tbody > tr > td.group-threads-cell {\n\tpadding: 2px 0 0 0;\n}\n\n#group-index-threaded > tbody > tr > th { width: 100%; }\n\n.group-threads table {\n\ttable-layout: fixed;\n}\n\n.group-threads > table {\n\twidth: 100%;\n}\n\n.group-threads > table > tbody > tr > td {\n\tborder-bottom: none;\n\tpadding-bottom: 1px;\n\tpadding-left: 2px !important;\n\tpadding-right: 2px;\n}\n.thread-start {\n\tborder: 2px solid #E6E6E6;\n\tmargin-bottom: 1px;\n}\n.thread-start > tbody > tr > th {\n\tbackground-color: #E6E6E6;\n}\n.thread-start > tbody > tr > td > div {\n\tpadding-right: 2px;\n}\n\n.thread-start > tbody > tr:nth-child(odd) {\n\tbackground-color: #F5F5F5;\n}\n.thread-start > tbody > tr:nth-child(even) {\n\tbackground-color: white;\n}\n.thread-start td {\n\ttext-align: left;\n}\n.thread-post-row div {\n\toverflow: hidden;\n}\n\n.thread-post-time {\n\tfloat: right;\n}\n\n#thread-index         .focused > td > div,\n#group-index-threaded .focused > td > div,\n#group-posts-vsplit   .focused {\n\toutline: 1px solid #F99 !important;\n\tbackground-color: #FEE;\n}\n\n#thread-index         .selected > td > div,\n#group-index-threaded .selected > td > div,\n#group-posts-vsplit   .selected {\n\tbackground-color: #D55;\n\tcolor: white;\n}\n\n#thread-index         .selected a,\n#group-index-threaded .selected a,\n#group-posts-vsplit   .selected a {\n\tcolor: inherit;\n}\n\n#thread-index         .selected .thread-post-time span,\n#group-index-threaded .selected .thread-post-time span,\n#group-posts-vsplit   .selected .thread-post-time span {\n\tcolor: inherit !important;\n}\n\n#thread-index {\n\tmargin-top: 20px;\n}\n\ntable.viewmode-threaded > tbody > tr > th {\n\twidth: 100%;\n}\n\n/*************** Horizontal split view ***************/\n\n#group-split {\n\ttable-layout: fixed;\n}\n#group-split-list {\n\twidth: 25em;\n\tvertical-align: top;\n}\n#group-split-list > div {\n\tposition: relative;\n}\n\n#group-split #group-split-message {\n\tpadding-left: 4px;\n\tvertical-align: top;\n\toverflow: auto;\n}\n\n#group-split-message .nojs {\n\tmargin-top: 10px;\n}\n\n.viewmode-horizontal-split .group-threads {\n\theight: 400px;\n\toverflow-x: hidden;\n\toverflow-y: auto;\n}\n\n/* The post itself */\n\n.split-post {\n\ttable-layout: fixed;\n}\n\n.split-post .post-body {\n\toverflow: auto;\n\tpadding: 0;\n\tborder-left: none;\n\tborder-right: none;\n\tborder-bottom: none;\n\tdisplay: block;\n}\n\n.split-post .post-text {\n\tmargin: 10px;\n}\n\n.split-post {\n}\n\n/* Horizontal info (full view) */\n\n.post-info-avatar {\n\tpadding-left: 5px;\n\tpadding-bottom: 2px;\n\tborder-right: none !important;\n\twidth: 1px;\n}\n\n.horizontal-post-info {\n\tborder-left: none !important;\n\tbackground-color: #F5F5F5;\n\tfont-size: 0.9em;\n}\n\n.horizontal-post-info-name {\n\tfont-weight: bold;\n\twhite-space: pre;\n}\n\ntable td.horizontal-post-info-name {\n\ttext-align: right;\n\tpadding: 0 10px;\n\twidth: 6em;\n}\n\n.horizontal-post-info-value .forum-unread {\n\t/* this just looks confusing and distracting */\n\tfont-weight: inherit;\n}\n\n.post-info-actions {\n\tvertical-align: bottom;\n\ttext-align: right;\n}\n\n/* Layout of scrollable section */\n\ntable.post-layout {\n\twidth: 100%;\n\theight: 100%;\n}\n\ntable.post-layout > tbody > tr > td {\n\tvertical-align: top;\n}\n\ntr.post-layout-header,\ntr.post-layout-footer {\n\theight: 1px; /* force shrink to fit */\n}\n\n/* Horizontal info (responsive view) */\n\ntable.mini-post-info {\n\tdisplay: none;\n}\n\ntable.mini-post-info a img.post-gravatar {\n\tborder-width: 3px !important;\n\tmargin: 0 0.5em;\n}\n\ntable.mini-post-info .post-info-actions {\n\tpadding: 0.5em;\n}\n\ntable.mini-post-info > tbody > tr > td:first-child {\n\twidth: 1px; /* force shrink to fit */\n}\n\ntable.mini-post-info {\n\tbackground-color: #F5F5F5;\n}\n\n.split-post table.mini-post-info {\n\tborder-bottom: 2px solid #E6E6E6;\n}\n\n/* Footer (responsive view only) */\n\ntable.post-footer {\n\tbackground-color: #F5F5F5;\n\tborder-top: 2px solid #E6E6E6;\n\tdisplay: none;\n}\n\ntable.post-footer > tbody > tr > td {\n\tmargin: 0;\n}\n\ntd.post-footer-info {\n\tpadding: 0.5em 0.75em;\n}\n\ntd.post-footer-nav {\n\tbackground-color: #E6E6E6;\n\ttext-align: center;\n}\n\ntd.post-footer-nav a {\n\tdisplay: block;\n\theight: 100%;\n\tpadding: 1em;\n\tmargin: 0;\n}\n\n/* Placeholder */\n\n.group-split-message-none {\n\ttext-align: center;\n\tvertical-align: middle !important;\n\toverflow: hidden;\n\twidth: 100%;\n}\n\n.keyboardhelp {\n\twidth: auto !important;\n\tmargin: 0 auto;\n}\n.keyboardhelp td {\n\tpadding-top: 10px;\n}\n.keyboardhelp td:first-child {\n\ttext-align: right;\n\tpadding-right: 10px;\n}\n.keyboardhelp td:last-child {\n\ttext-align: left;\n}\n.keyboardhelp th {\n\tdisplay: none;\n}\n\nkbd { /* stolen from http://en.wikipedia.org/wiki/Template:Key_press */\n\tborder: 1px solid;\n\tborder-color: #ddd #bbb #bbb #ddd;\n\tborder-bottom-width: 2px;\n\t-moz-border-radius: 3px;\n\t-webkit-border-radius: 3px;\n\tborder-radius: 3px;\n\tbackground-color: #f9f9f9;\n\tpadding: 1px 3px;\n\tfont-family: inherit;\n\tfont-size: 0.9em;\n\twhite-space: nowrap;\n\n\tdisplay: inline-block;\n\ttext-align: center;\n\tmin-width: 1em;\n\tmargin: 0 1px;\n\tbackground-color: white;\n\tfont-family: \"Arial Unicode MS\",\"Microsoft Sans Serif\",\"Free Sans\",\"Gentium Plus\",\"Gentium Basic\",\"Gentium\",\"GentiumAlt\",\"DejaVu Sans\",\"DejaVu Serif\",\"Free Serif\",\"TITUS Cyberbit Basic\",\"Bitstream Cyberbit\",\"Bitstream CyberBase\",\"Doulos SIL\",\"Code2000\",\"Code2001\";\n}\n\n.keyboardhelp-popup {\n\tposition: fixed;\n\tbackground-color: #F5F5F5;\n\twidth: 35em;\n\theight: 20em;\n\tleft: 50%;\n\ttop: 50%;\n\tmargin-left: -20em;\n\tmargin-top: -12.5em;\n\tborder: 0.25em solid #CCC;\n\tpadding: 2.5em;\n\tbox-shadow: 0px 0px 20px #000;\n}\n.keyboardhelp-popup .keyboardhelp th {\n\tdisplay: table-cell;\n}\n.keyboardhelp-popup-closetext {\n\ttext-align: center;\n\tposition: absolute;\n\twidth: 100%;\n\tbottom: 1.5em;\n\tleft: 0;\n\tcolor: #999;\n\tfont-size: 0.8em;\n}\n\n\n/*************** Vertical split view ***************/\n\n#group-posts-vsplit {\n\tfont-size: 90%;\n}\n\n#group-index-vsplit > tbody > tr > th:first-child {\n\twidth: 100%;\n}\n\n#group-posts-vsplit > tbody > tr > td:nth-child(1) { width: 70%; }\n#group-posts-vsplit > tbody > tr > td:nth-child(2) { width: 30%; }\n#group-posts-vsplit > tbody > tr > td:nth-child(3) { width: 8.5em; }\n\n#group-posts-vsplit > tbody > tr > td {\n\twhite-space: pre;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n#group-index-vsplit div.group-threads {\n\theight: 30em;\n\toverflow: auto;\n}\n\n#group-vsplit .post-header {\n\tdisplay: none;\n}\n\n/*************** Posting ***************/\n\n#postform-captcha {\n\tmargin-top: 10px;\n}\n\n.formdoc #content {\n/*\n\tdisplay: inline-block;\n\tmargin-left: 0 !important;\n*/\n}\n\n#postform {\n\tdisplay: block;\n\tmax-width: 50em;\n}\n\n#postform textarea,\n#postform-subject {\n\tbox-sizing: border-box;\n\twidth: 100%;\n}\n\n.postform-action-left {\n\tfloat: left;\n}\n.postform-action-left label {\n\tpadding-left: 1em;\n}\n.postform-action-left input[type=checkbox] {\n\tposition: relative;\n\ttop: 0.1em;\n}\n\n.postform-action-right {\n\tfloat: right;\n}\n\n.autosave-notice {\n\tmargin-left: 1em;\n}\n\n/*************** Login ***************/\n\n.forum-form.loginform {\n\twidth: 320px;\n\tmargin: 70px auto 0 auto;\n}\n\n.forum-form.loginform input[type=checkbox] {\n\tmargin-top: 1em;\n}\n\n.loginform-error {\n\tmargin: 0 !important;\n}\n\n.loginform-cell {\n\tpadding: 30px 80px !important;\n}\n\n.loginform-info {\n\ttext-align: center;\n}\n\n/*************** Misc views ***************/\n\n.forum-no-data {\n\ttext-align: center;\n}\n\n.forum-table-message {\n\ttext-align: center;\n\tpadding: 50px 0 !important;\n}\n\n.forum-table-message pre {\n\ttext-align: left;\n\tfont-size: 8pt;\n\tmargin: 50px 30px 0 30px;\n}\n\n/*************** Settings ***************/\n\ntable#subscriptions td:first-child {\n\twidth: 100%;\n}\n\nselect[name=trigger-content-groups] {\n}\n\n#trigger-content input[type=checkbox] {\n\tposition: relative;\n\ttop: 0.1em;\n}\n\ntable#account-settings input {\n\twidth: 100%;\n}\n\ntable#account-settings td {\n\tpadding: 0.25em 0;\n}\n\n#settings-form tr:hover {\n\tbackground-color: #f5f5f5;\n}\n\nhr {\n\tborder: 0.15em solid #CCC;\n}\n\n/*************** Responsive overrides ***************/\n\nbody {\n\t-webkit-text-size-adjust: none;\n\t-moz-text-size-adjust: none;\n\t-ms-text-size-adjust: none;\n\ttext-size-adjust: none;\n}\n\n@media only screen and (max-width: 66em) { /* Narrow layout stage 1 */\n\t.subnav {\n\t\twidth: auto;\n\t}\n\t.subnav > ul {\n\t\t-moz-column-width: 9em;\n\t\t-webkit-column-width: 9em;\n\t\tcolumn-width: 9em;\n\n\t\t-webkit-column-gap: 1em;\n\t\t-moz-column-gap: 1em;\n\t\tcolumn-gap: 1em;\n\n\t\tmax-width: 29em; /* = 3 * column-width + 2 * column-gap */\n\t}\n\t.subnav > ul > li {\n\t\t/* avoid column breaks inside li */\n\t\t-webkit-column-break-inside: avoid; /* Chrome, Safari, Opera */\n\t\tpage-break-inside: avoid; /* Firefox */\n\t\tbreak-inside: avoid; /* IE 10+ */\n\t}\n\t.subnav > ul > li:first-child {\n\t\tmargin-top: 0;\n\t}\n\t.subnav + #content {\n\t\tmargin-left: 0;\n\t}\n}\n\n@media only screen and (max-width: 60em) {\n\t.viewmode-horizontal-split .thread-post-time {\n\t\tdisplay: none;\n\t}\n\t.viewmode-horizontal-split .pager-numbers {\n\t\tdisplay: none;\n\t}\n\t.viewmode-horizontal-split .header-tools .btn {\n\t\tdisplay: none;\n\t}\n\t.viewmode-horizontal-split .header-tools .img {\n\t\tdisplay: inline-block;\n\t}\n\n\tbody #group-split-list {\n\t\twidth: 15em;\n\t}\n}\n\n@media only screen and (max-width: 50em) {\n\t/* remove padding around content */\n\tbody > .container {\n\t\tpadding-left: 0;\n\t\tpadding-right: 0;\n\t}\n\n\t/* ... but add it back to non-table top-level elements */\n\t#tools,\n\t#forum-index-header {\n\t\tmargin-left: 1em;\n\t\tmargin-right: 1em;\n\t}\n\t.subnav {\n\t\tmargin-left: 0;\n\t\tmargin-right: 0;\n\t}\n\n\t.forum-form,\n\t#settings-form,\n\t#subscription-form,\n\t#postform {\n\t\tmax-width: 100%;\n\t}\n\n\t#forum-index > tbody > tr > *:nth-child(3),\n\t#forum-index > tbody > tr > *:nth-child(4),\n\t#forum-index > tbody > tr > *:nth-child(5) { display: none; }\n\n\tdiv#footernav, div#copyright {\n\t\tdisplay: none;\n\t}\n\t#content {\n\t\tpadding-bottom: 0;\n\t}\n\n\t.horizontal-post-info {\n\t\tdisplay: none;\n\t}\n\n\t.split-post table.mini-post-info,\n\ttable.post-footer {\n\t\tdisplay: table;\n\t}\n}\n\n@media only screen and (max-width: 30em) {\n\t#forum-content .post > tbody > tr.mini-post-info-cell > td > table.mini-post-info {\n\t\tdisplay: table !important;\n\t}\n\t#forum-content .post > tbody > tr > td.post-info,\n\t#forum-content .post > tbody > tr:first-child > td:first-child {\n\t\tdisplay: none;\n\t}\n\t#forum-content .post > tbody > tr:first-child > td:nth-child(2) {\n\t\twidth: 100%;\n\t}\n\n\t#group-posts-vsplit > tbody > tr > td:nth-child(3) { width: 0; }\n\n\t#group-index > tbody > tr > td:nth-child(2) { width: 7em; }\n\t#group-index > tbody > tr > td:nth-child(3) { width: 4.5em; white-space: pre; padding: 0; }\n\t#group-index a.forum-postsummary-gravatar { display: none; }\n}\n\n/*************** Frame view ***************/\n\nbody.frame {\n\tfont-size: 90%;\n}\n\nbody.frame:before {\n\tdisplay: none;\n}\n\nbody.frame > div, body.frame > div.container > *, body.frame div#tools {\n\tdisplay: none;\n}\n\nbody.frame > div.container, body.frame div#content {\n\tdisplay: block;\n\tmargin: 0;\n\tpadding: 0;\n}\n\nbody.frame div#content {\n\tdisplay: block;\n\tmargin: 0;\n\tmax-width: none;\n\tborder: 0;\n\tpadding: 0;\n\tbackground: none;\n\tmin-height: 0;\n\tfont-size: 12px;\n\tline-height: 16px;\n}\n\nbody.frame .forum-table > tbody {\n\tdisplay: block;\n\tmax-height: 162px;\n\toverflow-y: auto;\n}\nbody.frame .forum-table > tbody,\nbody.frame .forum-table > tbody > tr,\nbody.frame .forum-table > tbody > tr > td {\n\t/* HACK */\n\twidth: 249px;\n}\nbody.frame .forum-table > thead > tr > th,\nbody.frame .forum-table > tbody > tr > td,\nbody.frame .forum-table {\n\tborder: 1px solid #CCC;\n\tbackground: none;\n}\nbody.frame .forum-table > thead > tr > th,\nbody.frame .forum-table > tbody > tr > td {\n\tborder-left: 0;\n\tborder-right: 0;\n\tborder-top: 0;\n}\nbody.frame .forum-table > thead > tr > th {\n\tfont-size: 14px; /* copy Twitter CSS */\n\ttext-align: left;\n\tpadding: 10px;\n}\nbody.frame .forum-table > thead > tr > th .feed-icon {\n\tdisplay: block;\n\tfloat: right;\n}\n\nbody.frame #forum-content table.forum-table {\n\t-moz-border-radius: 4px;\n\t-webkit-border-radius: 4px;\n\tborder-radius: 4px;\n\tborder-collapse: separate;\n\tbackground-color: white;\n\tcolor: #999;\n}\n\nbody.frame a {\n\tcolor: #333;\n\ttext-decoration: none;\n}\n\nbody.frame .forum-postsummary-subject {\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tdisplay: inline-block;\n\tvertical-align: top;\n\twidth: 180px;\n}\n\nbody.frame .forum-postsummary-author {\n\tcolor: #333;\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tdisplay: inline-block;\n\tvertical-align: top;\n\tmax-width: 100px;\n}\n\nbody.frame div.forum-postsummary-info {\n\tdisplay: block;\n\tfloat: right;\n\tcolor: #999;\n}\n\nbody.frame .forum-unread {\n\tfont-weight: 600;\n}\n\nbody.frame .forum-postsummary-gravatar {\n\tfloat: left;\n}\n\nbody.frame .post-gravatar {\n/*\n\tborder: 2px solid #444 !important;\n\tmargin: -2px 5px -2px 0;\n*/\n\tborder: 0 !important;\n\tmargin: 0 5px 0 0 !important;\n\twidth: 32px;\n\theight: 32px;\n\tdisplay: block;\n\t-moz-border-radius: 5px;\n\t-webkit-border-radius: 5px;\n\tborder-radius: 5px;\n}\n\n.viewmode-narrow-index a {\n\ttext-decoration-line: none;\n}\n\n.viewmode-narrow-index a * {\n\ttext-decoration-line: inherit;\n}\n\n.viewmode-narrow-index a:hover {\n\ttext-decoration-line: underline;\n}\n\n.viewmode-narrow-index a:hover * {\n\ttext-decoration-line: inherit;\n}\n\n.viewmode-narrow-index .group-index-header {\n\tdisplay: flex;\n\talign-items: center;\n\tline-height: 1.5em;\n\theight: 4em;\n\tborder-bottom: 1px solid lightgray;\n\tjustify-content: space-between;\n}\n\n@media only screen and (max-width: 50em) {\n\t.viewmode-narrow-index {\n\t\tpadding: 0 10px;\n\t}\n}\n\n.viewmode-narrow-index .group-index-header .title {\n\tfont-weight: bold;\n}\n\n.viewmode-narrow-index .group-index-header .create-thread {\n\ttext-align: right;\n}\n\n.viewmode-narrow-index .group-index-header .create-thread img {\n\theight: 12px;\n}\n\n.viewmode-narrow-index .group-index-header a.button {\n\tdisplay: inline-block;\n\tpadding: 2px 5px;\n\tcolor: initial;\n\tbackground-color: rgb(240, 240, 240);\n\ttext-decoration: none;\n\tborder-radius: 5px;\n\tborder: 1px solid lightgray;\n}\n\n.viewmode-narrow-index .group-index-header a.button:hover {\n\tbackground-color: rgb(224, 224, 224);\n\ttext-decoration: none;\n}\n\n.viewmode-narrow-index .thread {\n\tdisplay: grid;\n\tgrid-template-columns: auto minmax(0, 1fr) 10ch 25ch;\n\tgrid-template-rows: auto;\n\tgrid-template-areas:\n\t\t\"firstpost-author-image thread-title replies lastpost\"\n\t\t\"firstpost-author-image firstpost    replies lastpost\";\n\tborder-bottom: 1px solid lightgray;\n\tpadding: 5px 0;\n}\n\n.viewmode-narrow-index .firstpost-time {\n\tdisplay: none;\n}\n\n@media only screen and (max-width: 50em) {\n\t.viewmode-narrow-index .thread {\n\t\tgrid-template-columns: auto minmax(0, 0.925fr) minmax(0, 1fr) minmax(0, 1.075fr);\n\t\tgrid-template-rows: auto;\n\t\tgrid-template-areas:\n\t\t\t\"firstpost-author-image thread-title thread-title thread-title\"\n\t\t\t\"firstpost-author-image firstpost    replies      lastpost\";\n\t}\n\n\t.viewmode-narrow-index .thread .firstpost-time {\n\t\tdisplay: block;\n\t}\n}\n\n.viewmode-narrow-index .thread > * {\n\talign-self: center;\n}\n\n.viewmode-narrow-index .thread .title {\n\tgrid-area: thread-title;\n\tmargin-bottom: .25em;\n\tfont-size: 1em;\n}\n\n.viewmode-narrow-index .thread :not(:first-child) {\n\tfont-size: small;\n}\n\n.viewmode-narrow-index .thread .firstpost {\n\tgrid-area: firstpost;\n}\n\n.viewmode-narrow-index .thread .firstpost-time .short {\n\tdisplay: none;\n}\n\n.viewmode-narrow-index .thread .firstpost-time .long {\n\tdisplay: inline-block;\n}\n\n.viewmode-narrow-index .thread .firstpost-author-image {\n\tgrid-area: firstpost-author-image;\n\twidth: 3em;\n\theight: 3em;\n\tborder-radius: 50%;\n\tmargin-top: 0.75ch;\n\tmargin-right: 10px;\n\talign-self: start;\n}\n\n.viewmode-narrow-index .thread .firstpost-author-name {\n\tgrid-area: firstpost-author-name;\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n\tcolor: gray;\n}\n\n.viewmode-narrow-index .thread .replies {\n\tgrid-area: replies;\n}\n\n.viewmode-narrow-index .thread .replies-total {\n\ttext-align: center;\n}\n\n.viewmode-narrow-index .thread .replies-total .short {\n\tdisplay: none;\n}\n\n.viewmode-narrow-index .thread .replies-total .long {\n\tdisplay: inline;\n}\n\n.viewmode-narrow-index .thread .replies-new {\n\ttext-align: center;\n}\n\n.viewmode-narrow-index .thread .replies-new .short {\n\tdisplay: none;\n}\n\n.viewmode-narrow-index .thread .replies-new .long {\n\tdisplay: inline;\n}\n\n@media only screen and (max-width: 40em) {\n\t.viewmode-narrow-index .thread .replies-total .short {\n\t\tdisplay: inline;\n\t}\n\t\n\t.viewmode-narrow-index .thread .replies-total .long {\n\t\tdisplay: none;\n\t}\n\t\n\t.viewmode-narrow-index .thread .replies-new .short {\n\t\tdisplay: inline;\n\t}\n\t\n\t.viewmode-narrow-index .thread .replies-new .long {\n\t\tdisplay: none;\n\t}\n}\n\n.viewmode-narrow-index .thread .lastpost {\n\tgrid-area: lastpost;\n}\n\n.viewmode-narrow-index .thread .lastpost-time {\n\ttext-align: right;\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n.viewmode-narrow-index .thread .lastpost-time .short {\n\tdisplay: none;\n}\n\n.viewmode-narrow-index .thread .lastpost-time .long {\n\tdisplay: inline;\n}\n\n@media only screen and (max-width: 50em) {\n\t.viewmode-narrow-index .thread .firstpost-time .short {\n\t\tdisplay: inline;\n\t}\n\t\n\t.viewmode-narrow-index .thread .firstpost-time .long {\n\t\tdisplay: none;\n\t}\n\t\n\t.viewmode-narrow-index .thread .lastpost-time .short {\n\t\tdisplay: inline;\n\t}\n\t\n\t.viewmode-narrow-index .thread .lastpost-time .long {\n\t\tdisplay: none;\n\t}\n}\n\n.viewmode-narrow-index .thread .lastpost-time .last {\n\tdisplay: none;\n}\n\n.viewmode-narrow-index .thread .lastpost-time .last-post {\n\tdisplay: inline;\n}\n\n@media only screen and (max-width: 40em) {\n\t.viewmode-narrow-index .thread .lastpost-time .last {\n\t\tdisplay: inline;\n\t}\n\t\n\t.viewmode-narrow-index .thread .lastpost-time .last-post {\n\t\tdisplay: none;\n\t}\n}\n\n.viewmode-narrow-index .thread .lastpost {\n\tgrid-area: lastpost;\n}\n\n.viewmode-narrow-index .thread .lastpost-author {\n\ttext-align: right;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: right;\n\tcolor: gray;\n}\n\n.viewmode-narrow-index .thread .lastpost-author-image {\n\twidth: 1.5em;\n\theight: 1.5em;\n\tborder-radius: 50%;\n\tmargin-right: 0.5ch;\n}\n\n.viewmode-narrow-index .thread .lastpost-author-name {\n\twhite-space: nowrap;\n\toverflow: hidden;\n\ttext-overflow: ellipsis;\n}\n\n/*************** User Profile ***************/\n\n.user-profile {\n\tmargin-bottom: 1em;\n}\n\n.user-profile-header {\n\tdisplay: flex;\n\talign-items: flex-start;\n\tgap: 1.5em;\n}\n\n.user-profile-info h1 {\n\tmargin-top: 0;\n\tmargin-bottom: 0.5em;\n}\n\n.user-profile-actions {\n\tmargin-left: 0.35em;\n\tmargin-right: 0.35em;\n}\n\n.user-profile-stats {\n\tmargin-bottom: 1em;\n}\n\n.user-profile-stats td:first-child {\n\tcolor: #666;\n\tpadding-right: 1em;\n}\n\n.user-profile-stats td:last-child {\n\tfont-weight: 500;\n}\n\n.user-profile-seealso {\n\tmargin-top: 1.5em;\n}\n\n.user-profile-seealso h2 {\n\tmargin-bottom: 0.5em;\n}\n\n.user-profile-seealso a.forum-postsummary-gravatar {\n\tfloat: left;\n\tmargin-right: 0.5em;\n\tdisplay: block;\n}\n\n#forum-content .user-profile-seealso a.forum-postsummary-gravatar img.post-gravatar {\n\twidth: 2.2rem;\n\theight: 2.2rem;\n\tborder-width: 0.3rem;\n\tmargin: 0;\n\toverflow: hidden;\n}\n\n.seealso-lastseen {\n\ttext-align: center;\n\twhite-space: nowrap;\n}\n\n.user-profile-posts {\n\tmargin-top: 1.5em;\n}\n\n.user-profile-posts h2 {\n\tmargin-bottom: 0.5em;\n}\n\n.user-profile-posts .forum-table {\n\twidth: 100%;\n}\n\n.user-profile-posts .forum-table th {\n\ttext-align: left;\n\tpadding: 0.3em 0.5em;\n\tbackground: #f5f5f5;\n\tborder-bottom: 1px solid #ddd;\n}\n\n.user-profile-posts .forum-table td {\n\tpadding: 0.3em 0.5em;\n\tborder-bottom: 1px solid #eee;\n}\n"
  },
  {
    "path": "site-defaults/web/static/css/style.css",
    "content": "/*\n * DFeed Default Stylesheet\n *\n * Provides basic styling for a default DFeed installation.\n * Site-specific deployments can override this with their own style.css.\n */\n\n/* ============== Base Typography ============== */\n\nbody {\n    font-family: -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif;\n    font-size: 15px;\n    line-height: 1.5;\n    color: #333;\n    background: #fff;\n    margin: 0;\n    padding: 0;\n}\n\npre, code, .tt {\n    font-family: Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n    font-size: 14px;\n}\n\na {\n    color: #0066cc;\n    text-decoration: none;\n}\n\na:hover {\n    text-decoration: underline;\n}\n\na:visited {\n    color: #551a8b;\n}\n\nh1, h2, h3, h4, h5, h6 {\n    margin: 0.5em 0;\n    font-weight: 600;\n}\n\nh1 { font-size: 1.75em; }\nh2 { font-size: 1.5em; }\nh3 { font-size: 1.25em; }\n\n/* ============== Top Header Bar ============== */\n\n#top {\n    background: #4a76a8;\n    border-bottom: 1px solid #3a5a88;\n    padding: 0;\n}\n\n.site-header {\n    max-width: 76em;\n    margin: 0 auto;\n    padding: 0.5em 1em;\n    display: flex;\n    justify-content: space-between;\n    align-items: center;\n    flex-wrap: wrap;\n    gap: 0.5em;\n}\n\n.site-header .logo a {\n    color: white;\n    font-size: 1.5em;\n    font-weight: bold;\n    text-decoration: none;\n}\n\n.site-header .logo a:hover {\n    text-decoration: none;\n    opacity: 0.9;\n}\n\n/* ============== Search Box ============== */\n\n#search-box {\n    display: flex;\n    gap: 0.25em;\n}\n\n#search-box input,\n#search-box select,\n#search-box button {\n    padding: 0.4em 0.6em;\n    border: 1px solid #ccc;\n    border-radius: 3px;\n    font-size: 14px;\n}\n\n#search-box input#q {\n    width: 200px;\n}\n\n#search-box button {\n    background: #f5f5f5;\n    color: #333;\n    cursor: pointer;\n}\n\n#search-box button:hover {\n    background: #e6e6e6;\n    color: #333;\n}\n\n/* ============== Main Layout ============== */\n\n.container {\n    max-width: 76em;\n    margin: 0 auto;\n    padding: 1em;\n    display: flex;\n    gap: 2em;\n}\n\n/* ============== Side Navigation ============== */\n\n.subnav {\n    flex: 0 0 180px;\n    min-width: 0;\n}\n\n.subnav .head h2 {\n    font-size: 1.1em;\n    margin: 0 0 0.25em 0;\n}\n\n.subnav .head p {\n    margin: 0 0 1em 0;\n}\n\n.subnav ul {\n    list-style: none;\n    margin: 0;\n    padding: 0;\n}\n\n.subnav > ul > li {\n    margin-bottom: 1em;\n}\n\n.subnav > ul > li > ul {\n    margin-left: 0.5em;\n    padding-left: 0.5em;\n    border-left: 2px solid #e6e6e6;\n}\n\n.subnav li {\n    margin: 0.25em 0;\n}\n\n.subnav li.active > a {\n    font-weight: bold;\n}\n\n/* ============== Content Area ============== */\n\n#content {\n    flex: 1;\n    min-width: 0;\n    overflow: hidden;\n}\n\n#forum-content {\n    margin-bottom: 1.5em;\n}\n\n/* ============== Footer ============== */\n\n#footernav {\n    margin-top: 2em;\n    padding-top: 1em;\n    border-top: 1px solid #e6e6e6;\n    color: #666;\n    font-size: 0.9em;\n}\n\n#copyright {\n    margin-top: 0.5em;\n    color: #999;\n    font-size: 0.85em;\n}\n\n.smallprint {\n    font-size: 0.9em;\n    color: #666;\n}\n\n/* ============== Tables ============== */\n\ntable {\n    border-collapse: collapse;\n}\n\ntable th,\ntable td {\n    padding: 0.5em;\n    text-align: left;\n    vertical-align: top;\n}\n\n/* ============== Forms ============== */\n\ninput[type=\"text\"],\ninput[type=\"email\"],\ninput[type=\"password\"],\ninput[type=\"search\"],\ntextarea,\nselect {\n    padding: 0.5em;\n    border: 1px solid #ccc;\n    border-radius: 3px;\n    font-family: inherit;\n    font-size: inherit;\n}\n\ninput[type=\"text\"]:focus,\ninput[type=\"email\"]:focus,\ninput[type=\"password\"]:focus,\ninput[type=\"search\"]:focus,\ntextarea:focus,\nselect:focus {\n    outline: none;\n    border-color: #4a76a8;\n    box-shadow: 0 0 3px rgba(74, 118, 168, 0.3);\n}\n\nbutton,\ninput[type=\"submit\"],\ninput[type=\"button\"] {\n    padding: 0.5em 1em;\n    background: #4a76a8;\n    color: white;\n    border: none;\n    border-radius: 3px;\n    cursor: pointer;\n    font-family: inherit;\n    font-size: inherit;\n}\n\nbutton:hover,\ninput[type=\"submit\"]:hover,\ninput[type=\"button\"]:hover {\n    background: #3a5a88;\n}\n\n/* ============== Notices and Errors ============== */\n\n.forum-notice {\n    padding: 0.75em 1em;\n    margin: 0.5em 0;\n    background: #fff3cd;\n    border: 1px solid #ffc107;\n    border-radius: 3px;\n    color: #856404;\n}\n\n.form-error {\n    padding: 0.75em 1em;\n    margin: 0.5em 0;\n    background: #f8d7da;\n    border: 1px solid #f5c6cb;\n    border-radius: 3px;\n    color: #721c24;\n}\n\n/* ============== Utility Classes ============== */\n\n.tip {\n    font-size: 0.85em;\n    color: #666;\n    margin: 0.5em 0;\n}\n\n/* ============== Tools Bar ============== */\n\n#tools {\n    overflow: hidden;\n    margin-bottom: 1em;\n}\n\n#forum-tools-left {\n    float: left;\n}\n\n#forum-tools-right {\n    float: right;\n}\n\n#forum-tools-right .tip {\n    display: inline;\n    margin: 0 0 0 1em;\n}\n\n/* ============== Responsive ============== */\n\n@media (max-width: 768px) {\n    .container {\n        flex-direction: column;\n        gap: 1em;\n    }\n\n    .subnav {\n        flex: none;\n        width: 100%;\n    }\n\n    .subnav > ul {\n        display: flex;\n        flex-wrap: wrap;\n        gap: 0.5em 1em;\n    }\n\n    .subnav > ul > li {\n        margin: 0;\n    }\n\n    .subnav > ul > li > ul {\n        display: none;\n    }\n\n    .site-header {\n        flex-direction: column;\n        text-align: center;\n    }\n\n    #search-box {\n        width: 100%;\n        justify-content: center;\n    }\n\n    #search-box input#q {\n        flex: 1;\n        max-width: 300px;\n    }\n}\n"
  },
  {
    "path": "site-defaults/web/static/js/dfeed.js",
    "content": "function _(s) {\n\tif (s in localization)\n\t\treturn localization[s];\n\tconsole.log('Unlocalized string: ' + JSON.stringify(s));\n\treturn s;\n}\n\n$(document).ready(function() {\n\tif (enableKeyNav) {\n\t\t// Chrome does not pass Ctrl+keys to keypress - but in many\n\t\t// other browsers keydown does not repeat\n\t\t$(document).keydown (onKeyDown );\n\t\t$(document).keypress(onKeyPress);\n\t}\n\n\tif ($('#group-split').length || $('#group-vsplit').length)\n\t\tinitSplitView();\n\n\tif ($('#postform').length)\n\t\tinitPosting();\n\n\tif ('localStorage' in window && localStorage.getItem('usingKeyNav')) {\n\t\tinitKeyNav();\n\t\tlocalStorage.removeItem('usingKeyNav');\n\t}\n\n\tif ($('#thread-posts').length) {\n\t\tinitThreadUrlFixer();\n\t}\n\n\t$('.forum-expand-toggle').click(function(e) {\n\t    var container = $(this).closest('.forum-expand-container');\n\t    container.toggleClass('open');\n\t    return false;\n\t});\n\n\tsyntaxHighlight($(document));\n});\n\n// **************************************************************************\n// Thread view\n\nvar updateUrlOnFocus = false;\n\nfunction initThreadUrlFixer() {\n\tif ('history' in window && 'replaceState' in window.history) {\n\t\tupdateUrlOnFocus = true;\n\n\t\tif (document.location.hash.length) {\n\t\t\tvar el = document.getElementById(document.location.hash.substr(1));\n\t\t\tif (el) {\n\t\t\t\t$post = $(el).filter('.post');\n\t\t\t\tif ($post.length) {\n\t\t\t\t\t// Chrome is not scrolling to the hash if\n\t\t\t\t\t// we call replaceState inside the load event.\n\t\t\t\t\tsetTimeout(focusRow, 0, $post, FocusScroll.none, false);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t$('.post').live('click', function() {\n\t\t\tif (!$(this).is('.focused'))\n\t\t\t\tfocusRow($(this), FocusScroll.none, false);\n\t\t\treturn true;\n\t\t});\n\t}\n}\n\n// **************************************************************************\n// Split view\n\nfunction initSplitView() {\n\t$('.postlink').live('click', function() {\n\t\tvar path = $(this).attr('href');\n\t\treturn !selectMessage(path);\n\t});\n\t$('tr.thread-post-row').live('mousedown', function(e) {\n\t\tif (e.which == 1)\n\t\t\treturn selectRow($(this));\n\t\treturn true;\n\t}).css('cursor', 'default');\n\n\t$(window).resize(onResize);\n\tupdateSize(true);\n\tfocusRow($('tr.thread-post-row').last(), FocusScroll.scroll, false);\n\n\t$(window).bind('popstate', onPopState);\n\tonPopState();\n\n\ttoolsTemplate =\n\t\t$('<a class=\"tip\">')\n\t\t.attr('href', 'javascript:toggleNav()')\n\t\t.text(_('Toggle navigation'))\n\t\t[0].outerHTML\n\t\t+ ' '\n\t\t+ toolsTemplate;\n\tupdateTools();\n\n\tshowNav(localStorage.getItem('navhidden') == 'true');\n}\n\nfunction selectMessage(path) {\n\tvar id = idFromPath(path);\n\tif (id && findInTree(path)) {\n\t\twindow.history.replaceState(null, id, path);\n\t\tonPopState();\n\t\treturn true;\n\t}\n\treturn false;\n}\n\nfunction idFromPath(path) {\n\tif (path.substr(0, 6) == '/post/')\n\t\treturn path.substr(6);\n\treturn null;\n}\n\nfunction findInTree(path) {\n\tvar a = $('.group-threads').find('a[href=\"'+path+'\"]');\n\tif (a.length)\n\t\treturn a.first().closest('tr.thread-post-row');\n\telse\n\t\treturn null;\n}\n\nfunction getPath() {\n\tvar path = window.location.pathname;\n\n\t// Work around Opera bug?\n\tif (path.substr(0, 6) == '/post/')\n\t\tpath = path.substr(0, 6) + path.substr(6).replace(/\\//g, '%2F');\n\n\treturn path;\n}\n\nvar currentRequest = null;\nvar currentID = null;\n\nfunction onPopState() {\n\tvar path = getPath();\n\tvar id = idFromPath(path);\n\tvar $row = findInTree(path);\n\n\tif (id && id == currentID && $row.find('.forum-unread').length==0)\n\t\treturn;\n\telse\n\tif (id && $row) {\n\t\tif (currentRequest) {\n\t\t\tcurrentRequest.abort();\n\t\t\tcurrentRequest = null;\n\t\t}\n\n\t\t$('.group-threads .selected').removeClass('selected');\n\t\t$row.addClass('selected');\n\t\tfocusRow($row, FocusScroll.withMargin, false);\n\t\tcurrentID = id;\n\n\t\tshowText(_('Loading message')+'\\n<'+id+'> ...');\n\n\t\t//var resource = $('#group-vsplit').length ? '/vsplit-post/' : '/split-post/';\n\t\tvar resource = '/split-post/';\n\t\tcurrentRequest = $.get(resource + id, function(result) {\n\t\t\tcurrentRequest = null;\n\t\t\t$row.find('.forum-unread').removeClass('forum-unread').addClass('forum-read');\n\n\t\t\tshowPost(result);\n\t\t});\n\t\tcurrentRequest.error(function(jqXHR, textStatus, errorThrown) {\n\t\t\tcurrentRequest = null;\n\t\t\tshowText('XHR ' + textStatus + (errorThrown ? ': ' + errorThrown : ''));\n\t\t});\n\t} else {\n\t\tif (window.history.pushState)\n\t\t\tshowHtml(keyboardHelp);\n\t\telse\n\t\t\tshowHtml(_('Your browser does not support HTML5 pushState.'));\n\t}\n\n\tupdateTools();\n}\n\nfunction updateTools() {\n\t$('#forum-tools-right').html(toolsTemplate.replace(/__URL__/g, encodeURIComponent(document.location.href)));\n}\n\nfunction showPost(postHtml) {\n\tvar $container = $('#group-split-message');\n\t$container\n\t\t.html(postHtml)\n\t\t.removeClass('group-split-message-none');\n\tupdateSize(false);\n\taddLinkNavigation();\n\tsyntaxHighlight($container);\n}\n\nfunction showText(text) {\n\t$('#group-split-message')\n\t.addClass('group-split-message-none')\n\t.html(\n\t\t$('<span>')\n\t\t\t.text(text)\n\t);\n\tupdateSize(false);\n}\n\nfunction showHtml(text) {\n\t$('#group-split-message')\n\t\t.html(text)\n\t\t.addClass('group-split-message-none');\n}\n\n// **************************************************************************\n// Navigation toggle\n\nfunction toggleNav() {\n\tvar hidden = localStorage.getItem('navhidden') == 'true';\n\thidden = !hidden;\n\tlocalStorage.setItem('navhidden', hidden);\n\tshowNav(hidden);\n}\n\nfunction showNav(hidden) {\n\t$('body').toggleClass('navhidden', hidden);\n\tupdateSize(true);\n}\n\n// **************************************************************************\n// Resizing\n\n// This *might* be possible with just CSS, but so far all my attempts failed.\n\nvar resizeTimeout = null;\n\nfunction updateSize(resized) {\n\tresizeTimeout = null;\n\n\tvar vertical = $('#group-vsplit').length;\n\n\tif (!resized && !vertical && $.browser.mozilla) {\n\t\t// Firefox speed hack\n\t\tvar $outer = $('#group-split-message > *');\n\t\tvar $inner = $('.split-post .post-body');\n\t\tvar $posts = $('#group-split-list');\n\t\t$outer.css('height', '');\n\t\t$inner.height(100);\n\t\t$inner.height($posts.height() - ($outer.height() - 100));\n\t\treturn;\n\t}\n\n\tvar $focused = $('.focused');\n\tvar wasFocusedInView = false;\n\tif ($focused.length)\n\t\twasFocusedInView = isRowInView($focused);\n\n\tvar $container = getSelectablesContainer();\n\tif ($container.length)\n\t\tvar containerScrollTop = $container.scrollTop();\n\n\tvar resizees =\n\t\tvertical\n\t\t?\t[\n\t\t\t\t{ $outer : $('#group-vsplit-list   > div'), $inner : $('.group-threads')},\n\t\t\t\t{ $outer : $('#group-split-message'      ), $inner : $('.split-post .post-body, .group-split-message-none')},\n\t\t\t]\n\t\t:\t[\n\t\t\t\t{ $outer : $('#group-split-list    > div'), $inner : $('.group-threads')},\n\t\t\t\t{ $outer : $('#group-split-message > *'  ), $inner : $('.split-post .post-body')},\n\t\t\t]\n\t\t;\n\tvar verticalSplit = [0.35, 0.65];\n\n\tfor (var i in resizees)\n\t\tresizees[i].$outer.css('height', '');\n\n\tvar $bottommost = $('#content');\n\tvar totalWindowSpace = $(window).height();\n\n\tfunction getFreeSpace() {\n\t\tvar usedWindowSpace = $bottommost.offset().top + $bottommost.outerHeight(true);\n\t\tusedWindowSpace = Math.floor(usedWindowSpace);\n\n\t\tvar freeWindowSpace  = totalWindowSpace - usedWindowSpace;\n\t\treturn freeWindowSpace - 1 /*pixel fraction*/ ;\n\t}\n\n\tfunction getFreeSpaceFor(fDoShow) {\n\t\tfor (var i in resizees)\n\t\t\tif (fDoShow(i))\n\t\t\t\tresizees[i].$outer.show();\n\t\t\telse\n\t\t\t\tresizees[i].$outer.hide();\n\t\treturn getFreeSpace();\n\t}\n\n\tvar dummyHeight = 300;\n\n\tfor (var i in resizees)\n\t\tresizees[i].$inner.height(dummyHeight);\n\n\t// Shrink content to a fixed height, so we can calculate how much space we have to grow.\n\n\tvar growSpace = [];\n\tfor (var i in resizees)\n\t\tgrowSpace.push(getFreeSpaceFor(function(j) { return i==j; }));\n//\tvar growSpaceAll  = getFreeSpaceFor(function(j) { return true; });\n\tvar growSpaceNone = getFreeSpaceFor(function(j) { return false; });\n//\tvar growSpaceMin  = Math.min.apply(null, growSpace);\n//\tvar growSpaceMax  = Math.max.apply(null, growSpace);\n\tvar heights = [];\n\tfor (var i in resizees)\n\t\theights.push(growSpaceNone - growSpace[i]);\n\n\tfor (var i in resizees)\n\t\tresizees[i].$outer.show();\n\n\t//var obj = {}; ['growSpace', 'heights', 'growSpaceAll', 'growSpaceNone', 'growSpaceMax', 'growSpaceMax'].forEach(function(n) { obj[n]=eval(n); }); console.log(JSON.stringify(obj));\n\n\tfor (var i in resizees) {\n\t\tvar newHeight = dummyHeight;\n\n\t\tif (vertical)\n\t\t\tnewHeight = growSpaceNone * verticalSplit[i] - heights[i] + dummyHeight;\n\t\telse\n\t\t\tnewHeight += growSpace[i];\n\n\t\tresizees[i].$inner.height(newHeight);\n\t\t//console.log(i, ':', newHeight);\n\t}\n\n\tif ($container.length)\n\t\t$container.scrollTop(containerScrollTop);\n\n\tif ($focused.length && wasFocusedInView)\n\t\tfocusRow($focused, FocusScroll.withMargin, false);\n}\n\nfunction onResize() {\n\tif (resizeTimeout)\n\t\tclearTimeout(resizeTimeout);\n\tresizeTimeout = setTimeout(updateSize, 10, true);\n}\n\n// **************************************************************************\n// Utility\n\nfunction nestedOffset(element, container) {\n    if (element === document.body) element = window;\n\tif (element.offsetParent === container)\n\t\treturn element.offsetTop;\n\telse\n\tif (element.offsetParent === container.offsetParent)\n\t\treturn 0;\n\telse\n\t\treturn element.offsetTop + nestedOffset(element.offsetParent, container);\n}\n\nfunction isInView($element, $container) {\n\tvar containerTop = $container.scrollTop();\n\tvar containerHeight = $container.height();\n\tvar containerBottom = containerTop + containerHeight;\n\n\tvar elemTop = nestedOffset($element[0], $container[0]);\n\tvar elemBottom = elemTop + $element.height();\n\n\treturn elemTop > containerTop && elemBottom < containerBottom;\n}\n\nfunction scrollIntoView($element, $container, withMargin) {\n\tvar containerTop = $container.scrollTop();\n\tvar containerHeight = $container.height();\n\tvar containerBottom = containerTop + containerHeight;\n\t//var elemTop = element.offsetTop;\n\tvar elemTop = nestedOffset($element[0], $container[0]);\n\tvar elemBottom = elemTop + $element.height();\n\tvar scrollMargin = withMargin ? containerHeight/4 : 10;\n\tif (elemTop < containerTop) {\n\t\t$container.scrollTop(Math.max(0, elemTop - scrollMargin));\n\t\t//$container.scrollTo(elemTop, 200)\n\t} else if (elemBottom > containerBottom) {\n\t\t$container.scrollTop(elemBottom - containerHeight + scrollMargin);\n\t\t//$container.scrollTo(elemBottom - $container.height(), 200)\n\t}\n}\n\nfunction syntaxHighlight($root) {\n\tif (hljs === undefined)\n\t\treturn;\n\t$root.find('.post-text.markdown pre code').each(function () {\n\t\tif (/\\bhljs\\b/.exec(this.className))\n\t\t\treturn;\n\t\tvar match = /(?:^|\\s)language-([^\\s]*)(?:$|\\s)/.exec(this.className);\n\t\tif (match) {\n\t\t\tvar language = match[1];\n\t\t\ttry {\n\t\t\t\tthis.innerHTML = hljs.highlight(this.textContent, {\n\t\t\t\t\tlanguage : language,\n\t\t\t\t\tignoreIllegals : true\n\t\t\t\t}).value;\n\t\t\t} catch (e) {\n\t\t\t\tconsole.log('Error highlighting', this, ':', e);\n\t\t\t}\n\t\t}\n\t});\n}\n\n// **************************************************************************\n// Keyboard navigation\n\nfunction isRowInView($row) {\n\treturn isInView($row, getSelectablesContainer());\n}\n\nvar FocusScroll = { none:0, scroll:1, withMargin:2 };\n\nfunction focusRow($row, focusScroll, byKeyboard) {\n\t$('.focused').removeClass('focused');\n\t$row.addClass('focused');\n\tif (focusScroll)\n\t\tscrollIntoView($row, getSelectablesContainer(), focusScroll == FocusScroll.withMargin);\n\n\tif (byKeyboard && $('#group-split').length == 0 && $('#group-vsplit').length == 0)\n\t\taddLinkNavigation();\n\n\tif (updateUrlOnFocus)\n\t\twindow.history.replaceState(null, '', $row.find('.permalink').attr('href'));\n}\n\nfunction selectRow(row) {\n\treturn getSelectableLink(row)[0].click();\n}\n\nfunction getSelectables() {\n\tif ($('#group-split').length || $('#group-vsplit').length) {\n\t\treturn $('tr.thread-post-row');\n\t} else if ($('#forum-index').length) {\n\t\treturn $('#forum-index > tbody > tr.group-row');\n\t} else if ($('#group-index').length) {\n\t\treturn $('#group-index > tbody > tr.thread-row');\n\t} else if ($('#group-index-threaded').length) {\n\t\treturn $('#group-index-threaded tr.thread-post-row');\n\t} else if ($('#thread-index').length) {\n\t\treturn $('#thread-index tr.thread-post-row');\n\t} else if ($('.post').length) {\n\t\treturn $('.post');\n\t} else {\n\t\treturn [];\n\t}\n}\n\nfunction getSelectedPost() {\n\tif ($('.split-post').length) {\n\t\treturn $('.split-post .post-text');\n\t} else if ($('.focused.post').length) {\n\t\treturn $('.focused.post .post-text');\n\t} else {\n\t\treturn null;\n\t}\n}\n\nfunction getSelectablesContainer() {\n\tif ($('#group-split').length || $('#group-vsplit').length) {\n\t\treturn $('.group-threads');\n\t} else /*if ($('#forum-index').length)*/ {\n\t\treturn $(window);\n\t}\n}\n\nfunction getSelectableLink(row) {\n\tif ($('#group-split').length) {\n\t\treturn row.find('a.postlink');\n\t} else if ($('#group-index').length) {\n\t\treturn row.find('a.forum-postsummary-subject');\n\t} else {\n\t\treturn row.find('a').first();\n\t}\n}\n\nfunction getReplyLink() {\n\tif ($('#group-split').length || $('#group-vsplit').length || $('.viewmode-threaded').length) {\n\t\treturn $('a.replylink');\n\t} else {\n\t\treturn $('.focused a.replylink');\n\t}\n}\n\nfunction getPostScrollable() {\n\tif ($('#group-split').length || $('#group-vsplit').length) {\n\t\treturn $('.post-body');\n\t}\n\treturn null;\n}\n\nfunction isAutoOpenApplicable() {\n\treturn $('#group-index-threaded').length || $('#thread-index').length || $('#group-split').length || $('#group-vsplit').length;\n}\n\nvar autoOpenTimer = 0;\n\nfunction focusNext(offset, onlyUnread) {\n\tif (autoOpenTimer)\n\t\tclearTimeout(autoOpenTimer);\n\tif (autoOpen && isAutoOpenApplicable())\n\t\tautoOpenTimer = setTimeout(selectFocused, 500);\n\n\tif (typeof onlyUnread == 'undefined')\n\t\tonlyUnread = false;\n\n\tvar $all = getSelectables();\n\tvar count = $all.length;\n\tvar $current = $('.focused');\n\tvar index;\n\tif ($current.length == 0) {\n\t\tindex = offset>0 ? offset-1 : count-offset;\n\t} else if (Math.abs(offset) == Infinity) {\n\t\tindex = offset>0 ? count-1 : 0;\n\t} else {\n\t\tindex = $all.index($current);\n\t\tif (index < 0)\n\t\t\tindex = 0;\n\t\telse\n\t\tif (!onlyUnread)\n\t\t\tindex = (index + offset + count) % count;\n\t}\n\n\tfor (var i=0; i<count; i++) {\n\t\tvar $row = $all.eq(index);\n\t\tvar isUnread = $row.find('.forum-unread').length > 0;\n\t\tif (!onlyUnread || isUnread) {\n\t\t\t//row.mousedown();\n\t\t\tfocusRow($row, FocusScroll.scroll, true);\n\t\t\treturn true;\n\t\t}\n\n\t\tindex = (index + offset + count) % count;\n\t}\n\n\treturn false;\n}\n\nfunction selectFocused() {\n\tif (autoOpenTimer)\n\t\tclearTimeout(autoOpenTimer);\n\n\tvar focused = $('.focused');\n\tif (focused.length) {\n\t\tselectRow(focused);\n\t\treturn true;\n\t}\n\treturn false;\n}\n\nfunction markUnread() {\n\tvar focused = $('.focused');\n\tif (focused.length && focused.find('.forum-read').length > 0) {\n\t\tvar path = focused.find('a.postlink, a.permalink').attr('href').replace(\"/post/\", \"/mark-unread/\");\n\t\t$.get(path, function(result) {\n\t\t\tif (result == \"OK\")\n\t\t\t\tfocused.find('.forum-read').removeClass('forum-read').addClass('forum-unread');\n\t\t});\n\t\treturn true;\n\t}\n\treturn false;\n}\n\n// Show keyboard navigation UI immediately if we got to this page via keynav\nfunction initKeyNav() {\n\taddLinkNavigation();\n\tif ($('.focused').length == 0)\n\t\tfocusNext(+1);\n}\n\nfunction addLinkNavigation() {\n\tif (!enableKeyNav)\n\t\treturn;\n\n\t$post = getSelectedPost();\n\tif (!$post)\n\t\treturn;\n\n\t$('.linknav').remove();\n\tvar counter = 1;\n\t$post.find('a').each(function() {\n\t\tif (counter > 9) return;\n\t\t$(this).after(\n\t\t\t$('<span>')\n\t\t\t.addClass('linknav')\n\t\t\t.attr('data-num', counter++)\n\t\t);\n\t});\n}\n\nfunction followLink(n) {\n\tvar url = $('.linknav[data-num='+n+']').prev('a').attr('href');\n\tif (url) {\n\t\twindow.open(url, '_blank');\n\t\treturn true;\n\t}\n\treturn false;\n}\n\nvar keyboardHelp =\n\t'<table class=\"keyboardhelp\">' +\n\t\t'<tr><th colspan=\"2\">' + _('Keyboard shortcuts') + '</th></tr>' +\n\t\t'<tr><td><kbd>j</kbd> / <kbd>' + _('Ctrl') + '</kbd><kbd title=\"' + _('Down Arrow') + '\">&darr;</kbd></td><td>' + _('Select next message') + '</td></tr>' +\n\t\t'<tr><td><kbd>k</kbd> / <kbd>' + _('Ctrl') + '</kbd><kbd title=\"' + _('Up Arrow') + '\">&uarr;</kbd></td><td>' + _('Select previous message') + '</td></tr>' +\n\t\t'<tr><td><kbd>l</kbd> / <kbd>' + _('Ctrl') + '</kbd><kbd title=\"' + _('Right Arrow') + '\">&rarr;</kbd></td><td>' + _('Next page') + '</td></tr>' +\n\t\t'<tr><td><kbd>h</kbd> / <kbd>' + _('Ctrl') + '</kbd><kbd title=\"' + _('Left Arrow') + '\">&larr;</kbd></td><td>' + _('Previous page') + '</td></tr>' +\n\t\t'<tr><td><kbd title=\"' + _('Enter / Return') + '\">&crarr;</kbd></td><td>' + _('Open selected message') + '</td></tr>' +\n\t\t'<tr><td><kbd>n</kbd></td><td>' + _('Create thread') + '</td></tr>' +\n\t\t'<tr><td><kbd>r</kbd></td><td>' + _('Reply') + '</td></tr>' +\n\t\t'<tr><td><kbd>u</kbd></td><td>' + _('Mark as unread') + '</td></tr>' +\n\t\t'<tr><td><kbd>1</kbd> &middot;&middot;&middot; <kbd>9</kbd></td><td>' + _('Open link') + ' [1] &hellip; [9]</td></tr>' +\n\t\t'<tr><td><kbd title=\"' + _('Space Bar') + '\" style=\"width: 70px\">&nbsp;</kbd></td><td>' + _('Scroll message / Open next unread message') + '</td></tr>' +\n\t'</table>';\n\nfunction showHelp() {\n\t$('<div class=\"keyboardhelp-popup\">')\n\t\t.html(keyboardHelp + '<div class=\"keyboardhelp-popup-closetext\">' + _('(press any key or click to close)') + '</div>')\n\t\t.click(closeHelp)\n\t\t.appendTo($('body'))\n\t\t.hide()\n\t\t.fadeIn();\n\treturn true;\n}\n\nfunction closeHelp() {\n\t$('.keyboardhelp-popup').fadeOut(function() { $('.keyboardhelp-popup').remove(); });\n}\n\nfunction onKeyDown(e) {\n\tvar result = onKey(e, true);\n\tif (result && 'localStorage' in window)\n\t\tlocalStorage.setItem('usingKeyNav', 'true');\n\treturn !result; // event handlers return \"false\" if the event was handled\n}\n\nfunction onKeyPress(e) {\n\treturn !onKey(e, false);\n}\n\n// Return true if the event was handled,\n// and false if it wasn't and it should be processed by the browser.\nfunction onKey(e, keyDown) {\n\tif ($(e.target).is('input, textarea')) {\n\t\treturn false;\n\t}\n\n\tif ($('.keyboardhelp-popup').length) {\n\t\tcloseHelp();\n\t\treturn true;\n\t}\n\n\tvar c = String.fromCharCode(e.which);\n\tif ($.browser.webkit) c = c.toLowerCase();\n\tvar pageSize = $('.group-threads').height() / $('.thread-post-row').eq(0).height();\n\n\tif (!keyDown && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {\n\t\tswitch (c) {\n\t\t\tcase 'j':\n\t\t\t\treturn focusNext(+1);\n\t\t\tcase 'k':\n\t\t\t\treturn focusNext(-1);\n\t\t\tcase '\\x0D':\n\t\t\t\treturn selectFocused();\n\t\t\tcase ' ':\n\t\t\t{\n\t\t\t\tvar p = getPostScrollable();\n\t\t\t\tif (!p || !p.length) return false;\n\t\t\t\tvar dest = p.scrollTop()+p.height();\n\t\t\t\tif (dest < p[0].scrollHeight) {\n\t\t\t\t\tp.animate({scrollTop : dest}, 200);\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t\treturn focusNext(+1, true) && selectFocused();\n\t\t\t}\n\t\t\tcase 'n':\n\t\t\t{\n\t\t\t\tvar $form = $('form[name=new-post-form]');\n\t\t\t\t$form.submit();\n\t\t\t\treturn $form.length > 0;\n\t\t\t}\n\t\t\tcase 'r':\n\t\t\t{\n\t\t\t\tvar replyLink = getReplyLink();\n\t\t\t\tif (replyLink.length) {\n\t\t\t\t\tdocument.location.href = replyLink.attr('href');\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tcase 'u':\n\t\t\t\treturn markUnread();\n\t\t\tcase '1':\n\t\t\tcase '2':\n\t\t\tcase '3':\n\t\t\tcase '4':\n\t\t\tcase '5':\n\t\t\tcase '6':\n\t\t\tcase '7':\n\t\t\tcase '8':\n\t\t\tcase '9':\n\t\t\t\treturn followLink(c);\n\t\t\tcase 'h':\n\t\t\t\treturn pagePrev();\n\t\t\tcase 'l':\n\t\t\t\treturn pageNext();\n\t\t}\n\t}\n\n\tif (!keyDown && !e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) {\n\t\tswitch (c.toUpperCase()) {\n\t\t\tcase 'J':\n\t\t\t\treturn focusNext(+1) && selectFocused();\n\t\t\tcase 'K':\n\t\t\t\treturn focusNext(-1) && selectFocused();\n\t\t\tcase '?':\n\t\t\t\treturn showHelp();\n\t\t}\n\t}\n\n\tif (keyDown && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) {\n\t\tswitch (e.keyCode) {\n\t\t\tcase 13: // ctrl+enter == enter\n\t\t\t\treturn selectFocused();\n\t\t\tcase 38: // up arrow\n\t\t\t\treturn focusNext(-1);\n\t\t\tcase 40: // down arrow\n\t\t\t\treturn focusNext(+1);\n\t\t\tcase 37: // left arrow\n\t\t\t\treturn pagePrev();\n\t\t\tcase 39: // right arrow\n\t\t\t\treturn pageNext();\n\t\t\tcase 33: // page up\n\t\t\t\treturn focusNext(-pageSize);\n\t\t\tcase 34: // page down\n\t\t\t\treturn focusNext(+pageSize);\n\t\t\tcase 36: // home\n\t\t\t\treturn focusNext(-Infinity);\n\t\t\tcase 35: // end\n\t\t\t\treturn focusNext(+Infinity);\n\t\t}\n\t}\n\n\treturn false;\n}\n\n/* These are linked to in responsive horizontal-split post footer */\nfunction navPrev() { focusNext(-1) && selectFocused(); }\nfunction navNext() { focusNext(+1) && selectFocused(); }\n\nfunction pagePrev() {\n\tvar $link = $('.pager-left a').last();\n\tif ($link.length) {\n\t\twindow.location.href = $link.attr('href');\n\t\treturn true;\n\t}\n\treturn false;\n}\n\nfunction pageNext() {\n\tvar $link = $('.pager-right a').first();\n\tif ($link.length) {\n\t\twindow.location.href = $link.attr('href');\n\t\treturn true;\n\t}\n\treturn false;\n}\n\n// **************************************************************************\n// Posting\n\n// http://stackoverflow.com/a/4716021/21501\nfunction moveCaretToEnd(el) {\n\tel.focus();\n\tif (typeof el.selectionStart == \"number\") {\n\t\tel.selectionStart = el.selectionEnd = el.value.length;\n\t} else if (typeof el.createTextRange != \"undefined\") {\n\t\tvar range = el.createTextRange();\n\t\trange.collapse(false);\n\t\trange.select();\n\t}\n}\n\nfunction initPosting() {\n\tinitAutoSave();\n\tvar textarea = $('#postform textarea')[0];\n\tif (textarea.value.length)\n\t\tmoveCaretToEnd(textarea);\n\telse\n\t\t$('#postform-subject').focus();\n}\n\nfunction initAutoSave() {\n\tvar autoSaveCooldown = 2500;\n\n\tvar $textarea = $('#postform textarea');\n\tvar oldValue = $textarea.val();\n\tvar timer = 0;\n\n\tfunction autoSave() {\n\t\ttimer = 0;\n\t\t$('.autosave-notice').remove();\n\t\t$.post('/auto-save', $('#postform').serialize(), function(data, status, xhr) {\n\t\t\t$('<span>')\n\t\t\t\t.text(xhr.status == 200 ? _('Draft saved.') : _('Error auto-saving draft.'))\n\t\t\t\t.addClass('autosave-notice')\n\t\t\t\t.insertAfter($('#postform input[name=action-send]').parent().children().last())\n\t\t\t\t.fadeOut(autoSaveCooldown)\n\t\t});\n\t}\n\n\t$textarea.bind('input propertychange', function() {\n\t\tvar value = $textarea.val();\n\t\tif (value != oldValue) {\n\t\t\toldValue = value;\n\t\t\t$('.autosave-notice').remove();\n\t\t\tif (timer)\n\t\t\t\tclearTimeout(timer);\n\t\t\ttimer = setTimeout(autoSave, autoSaveCooldown);\n\t\t}\n\t});\n}\n"
  },
  {
    "path": "site-defaults/web/static/robots_private.txt",
    "content": "User-agent: *\nDisallow: /\n"
  },
  {
    "path": "site-defaults/web/static/robots_public.txt",
    "content": "User-agent: *\nDisallow: /discussion/set\nDisallow: /discussion/newpost\nDisallow: /discussion/reply\nDisallow: /set\nDisallow: /newpost\nDisallow: /reply\nDisallow: /source/\nDisallow: /search\n"
  },
  {
    "path": "src/dfeed/backup.d",
    "content": "/*  Copyright (C) 2011, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.backup;\n\nimport std.datetime;\nimport std.exception;\nimport std.file;\nimport std.process;\n\nimport ae.net.shutdown;\nimport ae.sys.file;\nimport ae.sys.log;\nimport ae.sys.timing;\nimport ae.utils.time;\n\nimport dfeed.database;\n\nclass Backup\n{\n\tstatic struct Config { int hour, minute; }\n\timmutable Config config;\n\n\tLogger log;\n\n\tthis(Config config)\n\t{\n\t\tthis.config = config;\n\t\tlog = createLogger(\"Backup\");\n\t\tauto backupTask = setInterval(&checkBackup, 1.minutes);\n\t\taddShutdownHandler((scope const(char)[] reason) { backupTask.cancel(); });\n\t}\n\n\tvoid checkBackup()\n\t{\n\t\tauto now = Clock.currTime();\n\t\tif (now.hour == config.hour && now.minute == config.minute)\n\t\t\trunBackup();\n\t}\n\n\tenum backupDir = \"data/backup/\";\n\tenum dataFile = \"data/db/dfeed.s3db\";\n\tenum lastFile = dataFile ~ \".last\";\n\tenum thisFile = dataFile ~ \".this\";\n\tenum baseFile = backupDir ~ \"base.s3db\";\n\n\tvoid runBackup()\n\t{\n\t\tif (transactionDepth)\n\t\t{\n\t\t\tlog(\"Transaction in progress, delaying backup.\");\n\t\t\tsetTimeout(&runBackup, 1.minutes);\n\t\t\treturn;\n\t\t}\n\n\t\tlog(\"Starting backup.\");\n\n\t\tif (!baseFile.exists)\n\t\t{\n\t\t\tlog(\"Creating base backup.\");\n\t\t\tensurePathExists(baseFile);\n\t\t\tatomicCopy(dataFile, baseFile);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tauto base = lastFile.exists ? lastFile : baseFile;\n\t\t\tlog(\"Using \" ~ base ~ \" as base file.\");\n\n\t\t\tlog(\"Copying database\");\n\t\t\t// No locks required as this will run in the main thread and block DB access.\n\t\t\tcopy(dataFile, thisFile);\n\n\t\t\tauto deltaFile = backupDir ~ \"dfeed-\" ~ Clock.currTime.formatTime!`Ymd-His` ~ \".vcdiff\";\n\t\t\tlog(\"Creating delta file: \" ~ deltaFile);\n\n\t\t\tauto deltaTmpFile = deltaFile ~ \".tmp\";\n\t\t\tauto pid = spawnProcess([\"xdelta3\", \"-e\", \"-s\", base, thisFile, deltaTmpFile]);\n\t\t\tenforce(pid.wait() == 0, \"xdelta3 failed.\");\n\n\t\t\tlog(\"Delta file created.\");\n\t\t\trename(deltaTmpFile, deltaFile);\n\t\t\trename(thisFile, lastFile);\n\t\t}\n\n\t\tlog(\"Backup complete.\");\n\t}\n}\n\nBackup backup;\n\nstatic this()\n{\n\timport dfeed.common : createService;\n\tbackup = createService!Backup(\"backup\");\n}\n"
  },
  {
    "path": "src/dfeed/bayes.d",
    "content": "/*  Copyright (C) 2015, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Common code for the naive Bayes classifier.\n\nmodule dfeed.bayes;\n\nstruct BayesModel\n{\n\tstruct Word\n\t{\n\t\tulong spamCount, hamCount;\n\t}\n\t \n\tWord[string] words;\n\tulong spamPosts, hamPosts;\n}\n\nauto splitWords(string s)\n{\n\timport std.algorithm.iteration;\n\timport std.algorithm.sorting;\n\timport std.array;\n\timport std.conv;\n\timport std.string;\n\timport std.uni;\n\n\treturn s\n\t\t.map!(c => dchar(isAlpha(c) ? toLower(c) : ' '))\n\t\t.array\n\t\t.split()\n\t\t.map!(to!string)\n\t\t.array\n\t\t.sort\n\t\t.uniq;\n}\n\nvoid train(R)(ref BayesModel model, R words, bool isSpam, int weight = 1)\n{\n\tforeach (word; words)\n\t{\n\t\tauto pWord = word in model.words;\n\t\tif (!pWord)\n\t\t{\n\t\t\tmodel.words[word] = BayesModel.Word();\n\t\t\tpWord = word in model.words;\n\t\t}\n\t\tif (isSpam)\n\t\t\tpWord.spamCount += weight;\n\t\telse\n\t\t\tpWord.hamCount += weight;\n\t}\n\tif (isSpam)\n\t\tmodel.spamPosts += weight;\n\telse\n\t\tmodel.hamPosts += weight;\n}\n\ndouble checkMessage(in ref BayesModel model, string s)\n{\n\tif (model.spamPosts == 0 || model.hamPosts == 0)\n\t\treturn 0.5;\n\n\timport std.math;\n\tdebug(bayes) import std.stdio;\n\n\t// Adapted from https://github.com/rejectedsoftware/antispam/blob/master/source/antispam/filters/bayes.d\n\tdouble plsum = 0;\n\tauto bias = 1 / double(model.spamPosts + model.hamPosts + 1);\n\tforeach (w; s.splitWords)\n\t\tif (auto pWord = w in model.words)\n\t\t{\n\t\t\tauto p_w_s = (pWord.spamCount + bias) / model.spamPosts;\n\t\t\tauto p_w_h = (pWord.hamCount + bias) / model.hamPosts;\n\t\t\tauto p_w_t = p_w_s + p_w_h;\n\t\t\tif (p_w_t == 0)\n\t\t\t\tcontinue;\n\t\t\tauto prob = p_w_s / p_w_t;\n\t\t\tplsum += log(1 - prob) - log(prob);\n\t\t\tdebug(bayes) writefln(\"%s: %s (%d/%d vs. %d/%d)\", w, prob,\n\t\t\t\tpWord.spamCount, model.spamPosts,\n\t\t\t\tpWord.hamCount, model.hamPosts\n\t\t\t);\n\t\t}\n\t\telse\n\t\t\tdebug(bayes) writefln(\"%s: unknown word\", w);\n\tauto prob = 1 / (1 + exp(plsum));\n\tdebug(bayes) writefln(\"---- final probability %s (%s)\", prob, plsum);\n\treturn prob;\n}\n"
  },
  {
    "path": "src/dfeed/bitly.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.bitly;\n\nimport std.uri;\nimport std.file;\nimport std.string;\nimport std.exception;\n\nimport ae.net.http.client;\nimport ae.sys.log;\n\nvoid shortenURL(string url, void delegate(string) handler)\n{\n\tif (urlShortener)\n\t\turlShortener.shorten(url, handler);\n\telse\n\t\thandler(url);\n}\n\n// **************************************************************************\n\nprivate:\n\nclass UrlShortener { abstract void shorten(string url, void delegate(string) handler); }\nUrlShortener urlShortener;\n\nclass Bitly : UrlShortener\n{\n\tstatic struct Config { string login, apiKey; }\n\timmutable Config config;\n\tthis(Config config) { this.config = config; }\n\n\tLogger log;\n\n\toverride void shorten(string url, void delegate(string) handler)\n\t{\n\t\thttpGet(\n\t\t\tformat(\n\t\t\t\t\"http://api.bitly.com/v3/shorten?login=%s&apiKey=%s&longUrl=%s&format=txt&domain=j.mp\",\n\t\t\t\tconfig.login,\n\t\t\t\tconfig.apiKey,\n\t\t\t\tstd.uri.encodeComponent(url)\n\t\t\t), (string shortened) {\n\t\t\t\tshortened = shortened.strip();\n\t\t\t\tenforce(shortened.startsWith(\"http://\"), \"Unexpected bit.ly output: \" ~ shortened);\n\t\t\t\thandler(shortened);\n\t\t\t}, (string error) {\n\t\t\t\tif (!log)\n\t\t\t\t\tlog = createLogger(\"bitly\");\n\t\t\t\tlog(\"Error while shortening \" ~ url ~ \": \" ~ error);\n\n\t\t\t\thandler(url);\n\t\t\t});\n\t}\n}\n\nstatic this()\n{\n\timport dfeed.common : createService;\n\turlShortener = createService!Bitly(\"apis/bitly\");\n}\n"
  },
  {
    "path": "src/dfeed/common.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2014, 2015, 2018, 2020, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.common;\n\nimport std.datetime;\npublic import std.typecons;\n\nimport ae.sys.log;\nimport ae.net.shutdown;\n\nalias quiet = ae.sys.log.quiet;\n\n// ***************************************************************************\n\nabstract class Post\n{\n\t/// Asynchronously summarise this post to a single line, ready to be sent to IRC.\n\tabstract void formatForIRC(void delegate(string) handler);\n\n\tenum Importance\n\t{\n\t\t/// Never announce.\n\t\tnone,\n\n\t\t/// Replies to threads, general activity.\n\t\t/// Should only be shown in \"all activity\" feeds.\n\t\tlow,\n\n\t\t/// Suitable to be announced on general feeds.\n\t\tnormal,\n\n\t\t/// Project announcements and other important posts.\n\t\thigh,\n\t}\n\n\tImportance getImportance() { return Importance.normal; }\n\n\tthis()\n\t{\n\t\ttime = Clock.currTime();\n\t}\n\n\tSysTime time;\n}\n\nabstract class NewsSource\n{\n\tthis(string name)\n\t{\n\t\tthis.name = name;\n\t\tlog = createLogger(name).asyncLogger();\n\t\tnewsSources[name] = this;\n\t}\n\n\tabstract void start();\n\tabstract void stop();\n\nprotected:\n\tLogger log;\n\npublic:\n\tstring name;\n}\n\nalias Fresh = Flag!q{Fresh};\n\nabstract class NewsSink\n{\n\tthis()\n\t{\n\t\tnewsSinks ~= this;\n\t}\n\n\tabstract void handlePost(Post p, Fresh fresh);\n}\n\ninterface ModerationSink\n{\n\tvoid handleModeration(Post p, Flag!\"ban\" ban);\n}\n\nprivate NewsSource[string] newsSources;\nprivate NewsSink[] newsSinks;\n\nvoid startNewsSources()\n{\n\tforeach (source; newsSources)\n\t\tsource.start();\n\n\taddShutdownHandler((scope const(char)[] reason){\n\t\tforeach (source; newsSources)\n\t\t\tsource.stop();\n\t});\n}\n\nvoid announcePost(Post p, Fresh fresh)\n{\n\tforeach (sink; newsSinks)\n\t\tsink.handlePost(p, fresh);\n}\n\nvoid handleModeration(Post p, Flag!\"ban\" ban)\n{\n\tforeach (sink; newsSinks)\n\t\tif (auto mod = cast(ModerationSink)sink)\n\t\t\tmod.handleModeration(p, ban);\n}\n\n// ***************************************************************************\n\n/// Some zero-width control/formatting sequence codes inserted into names\n/// to disrupt IRC highlight.\nenum ircHighlightBreaker = \"\\u200E\"; // LEFT-TO-RIGHT MARK\n\n/// Filter a name in an announcement to avoid an IRC highlight.\nstring filterIRCName(string name)\n{\n\timport std.algorithm;\n\timport std.array;\n\timport std.conv;\n\timport std.string;\n\n\tname = name\n\t\t.to!dstring\n\t\t.split(\" \"d)\n\t\t.map!(s =>\n\t\t\ts.length < 2\n\t\t\t?\n\t\t\t\ts\n\t\t\t:\n\t\t\t\ts[0..$/2] ~ ircHighlightBreaker ~ s[$/2..$]\n\t\t)\n\t\t.join(\" \"d)\n\t\t.to!string;\n\n\t// Split additional keywords\n\tforeach (word; [\"Cyber\"])\n\t\tif (name.indexOf(word) >= 0)\n\t\t\tname = name.replace(word, word[0..$/2] ~ ircHighlightBreaker ~ word[$/2..$]);\n\n\treturn name;\n}\n\nunittest\n{\n\tassert(filterIRCName(\"Vladimir Panteleev\") == \"Vlad\" ~ ircHighlightBreaker ~ \"imir Pant\" ~ ircHighlightBreaker ~ \"eleev\");\n\tassert(filterIRCName(\"CyberShadow\") == \"Cy\" ~ ircHighlightBreaker ~ \"ber\" ~ ircHighlightBreaker ~ \"Shadow\");\n\tassert(filterIRCName(\"Rémy\") == \"Ré\" ~ ircHighlightBreaker ~ \"my\");\n}\n\n// ***************************************************************************\n\nimport ae.utils.sini;\nimport std.file;\nimport std.path;\nimport dfeed.paths : resolveSiteFile, siteSearchPaths;\n\ntemplate services(C)\n{\n\tC[string] services;\n}\n\n/// Create a Class instance if the corresponding .ini file exists.\nClass createService(Class)(string configName)\n{\n\tauto fn = resolveSiteFile(\"config/\" ~ configName ~ \".ini\");\n\tif (fn.exists)\n\t\treturn new Class(loadIni!(Class.Config)(fn));\n\treturn null;\n}\n\n/// Create one instance of Class for each .ini configuration file\n/// found in the specified config subdirectory.\n/// Searches both site/ and site-defaults/ directories, with site/ taking priority.\nvoid createServices(Class, Args...)(string configDir, Args args)\n{\n\tbool[string] loaded;  // Track which configs have been loaded\n\n\tforeach (base; siteSearchPaths)\n\t{\n\t\tauto dir = buildPath(base, \"config\", configDir);\n\t\tif (!dir.exists)\n\t\t\tcontinue;\n\t\tforeach (de; dir.dirEntries(\"*.ini\", SpanMode.breadth))\n\t\t{\n\t\t\tauto name = de.baseName.stripExtension;\n\t\t\tif (name in loaded)\n\t\t\t\tcontinue;  // Already loaded from higher-priority path\n\t\t\tauto config = loadIni!(Class.Config)(de.name);\n\t\t\tservices!Class[name] = new Class(config, args);\n\t\t\tloaded[name] = true;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/database.d",
    "content": "/*  Copyright (C) 2011, 2015, 2016, 2017, 2018, 2020, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.database;\n\nimport std.exception;\nimport std.file : rename, exists;\n\nimport ae.sys.file : ensurePathExists;\nimport ae.sys.sqlite3 : SQLite;\npublic import ae.sys.sqlite3 : SQLiteException;\n\nimport ae.sys.database;\n\nSQLite.PreparedStatement query(string sql)() { return database.stmt!sql(); }\nSQLite.PreparedStatement query(string sql)   { return database.stmt(sql);  }\nalias selectValue = ae.sys.database.selectValue;\n@property SQLite db() { return database.db; }\n\n// ***************************************************************************\n\nprivate Database database;\n\nenum databasePath = \"data/db/dfeed.s3db\";\n\nstatic this()\n{\n\timport std.file;\n\n\tenum oldDatabasePath = \"data/dfeed.s3db\";\n\tif (!databasePath.exists && oldDatabasePath.exists)\n\t{\n\t\tensurePathExists(databasePath);\n\t\trename(oldDatabasePath, databasePath);\n\t\tversion(Posix) symlink(\"db/dfeed.s3db\", oldDatabasePath);\n\t}\n\n\tensurePathExists(databasePath);\n\n\tdatabase = Database(databasePath, [\n\t\t// Initial version\n\t\treadText(\"schema_v1.sql\"),\n\n\t\t// Add missing index\n\t\tq\"SQL\nCREATE INDEX [SubscriptionUser] ON [Subscriptions] ([Username]);\nSQL\",\n\n\t\t// Add covering index for COUNT(DISTINCT AuthorEmail) queries with Time filter\n\t\tq\"SQL\nCREATE INDEX [PostTimeAuthorEmail] ON [Posts] ([Time] DESC, [AuthorEmail]);\nSQL\",\n\n\t\t// Add index for user profile queries filtering by Author and AuthorEmail, with Time for ORDER BY\n\t\tq\"SQL\nCREATE INDEX [PostAuthorAuthorEmailTime] ON [Posts] ([Author], [AuthorEmail], [Time] DESC);\nSQL\",\n\t]);\n\n\t// Enable WAL mode for better concurrency and performance on COW filesystems\n\t// Must be set outside of transactions, so done here rather than in migrations\n\tdb.exec(\"PRAGMA journal_mode = WAL;\");\n\n\t// Set per-connection performance settings\n\tdb.exec(\"PRAGMA synchronous = NORMAL;\");  // Balance safety vs performance (1 fsync instead of 2)\n\tdb.exec(\"PRAGMA cache_size = -50000;\");   // 50MB cache (reduces disk I/O)\n}\n\nint transactionDepth;\n\nenum DB_TRANSACTION = q{\n\tif (transactionDepth++ == 0) query!\"BEGIN TRANSACTION\".exec();\n\tscope(failure) if (--transactionDepth == 0) query!\"ROLLBACK TRANSACTION\".exec();\n\tscope(success) if (--transactionDepth == 0) query!\"COMMIT TRANSACTION\".exec();\n};\n\nbool flushTransactionEvery(int count)\n{\n\tstatic int calls = 0;\n\n\tassert(transactionDepth, \"Not in a transaction\");\n\n\tif (count && ++calls % count == 0 && transactionDepth == 1)\n\t{\n\t\tquery!\"COMMIT TRANSACTION\";\n\t\tquery!\"BEGIN TRANSACTION\";\n\t\treturn true;\n\t}\n\telse\n\t\treturn false;\n}\n"
  },
  {
    "path": "src/dfeed/debugging.d",
    "content": "/*  Copyright (C) 2023  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n// See https://github.com/CyberShadow/ShellUtils/commit/f033d102ce968b71ffa5c0d721922a99d41d302a\nmodule dfeed.debugging;\n\nimport core.stdc.stdio;\n\nimport dfeed.web.web.page;\nimport dfeed.web.web.request;\n\nconst(char)* _t15_getDebugInfo() @nogc\n{\n\t__gshared char[65536] buf = void;\n\tauto currentURL = currentRequest ? currentRequest.resource : \"(no current request)\";\n\tsnprintf(\n\t\tbuf.ptr,\n\t\tbuf.length,\n\t\t(\n\t\t\t\"Current URL: %.*s\\n\" ~\n\t\t\t\"html buffer size: %zu\\n\"\n\t\t).ptr,\n\t\tcast(int)currentURL.length, currentURL.ptr,\n\t\thtml.length,\n\t);\n\treturn buf.ptr;\n}\n"
  },
  {
    "path": "src/dfeed/groups.d",
    "content": "/*  Copyright (C) 2015, 2017, 2018, 2021, 2024  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.groups;\n\nimport std.exception;\nimport std.string;\n\nstruct Config\n{\n\tstruct Set\n\t{\n\t\tstring name, shortName;\n\t\tbool visible = true;\n\t}\n\tOrderedMap!(string, Set) sets;\n\n\tstruct AlsoVia\n\t{\n\t\tstring name, url;\n\t}\n\n\tstruct Group\n\t{\n\t\tstring internalName, publicName, navName, urlName, groupSet, description, postMessage, notice, sinkType, sinkName;\n\t\tstring[] urlAliases;\n\t\tOrderedMap!(string, AlsoVia) alsoVia;\n\t\tbool subscriptionRequired = true;\n\t\tbool announce;\n\t\tstring captcha;\n\n\t\t// Rate limiting: reject posts if threshold is met under time limit (in seconds)\n\t\tuint postThrottleRejectTime = 30;\n\t\tuint postThrottleRejectCount = 3;\n\n\t\t// Rate limiting: challenge with CAPTCHA if threshold is met under time limit (in seconds)\n\t\tuint postThrottleCaptchaTime = 180;  // 3 minutes\n\t\tuint postThrottleCaptchaCount = 3;\n\t}\n\tOrderedMap!(string, Group) groups;\n}\nimmutable Config config;\n\nimport ae.utils.aa;\nimport ae.utils.sini;\nimport dfeed.paths : resolveSiteFile;\n\nshared static this() { config = cast(immutable)loadIni!Config(resolveSiteFile(\"config/groups.ini\")); }\n\nstruct GroupSet\n{\n\tConfig.Set set;\n\talias set this;\n\timmutable Config.Group[] groups;\n}\nimmutable GroupSet[] groupHierarchy;\n\nalias GroupInfo = immutable(Config.Group)*;\n\nshared static this()\n{\n\timport std.algorithm;\n\timport std.range;\n\n\tgroupHierarchy =\n\t\tconfig.sets.length.iota\n\t\t.map!(setIndex => GroupSet(\n\t\t\tconfig.sets.values[setIndex],\n\t\t\tconfig.groups.values.filter!(group => group.groupSet == config.sets.keys[setIndex]).array\n\t\t)).array;\n}\n\nGroupInfo getGroupInfoByField(string field, CaseSensitive cs=CaseSensitive.yes)(string value)\n{\n\tforeach (set; groupHierarchy)\n\t\tforeach (ref group; set.groups)\n\t\t{\n\t\t\tauto fieldValue = mixin(\"group.\" ~ field);\n\t\t\tif (cs ? fieldValue == value : icmp(fieldValue, value) == 0)\n\t\t\t\treturn &group;\n\t\t}\n\treturn null;\n}\n\nalias getGroupInfo             = getGroupInfoByField!(q{internalName}, CaseSensitive.no);\nalias getGroupInfoByUrl        = getGroupInfoByField!q{urlName};\nalias getGroupInfoByPublicName = getGroupInfoByField!q{publicName};\n"
  },
  {
    "path": "src/dfeed/loc/english.d",
    "content": "/*  Copyright (C) 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.loc.english;\n\nenum languageName = \"English\";\nenum languageCode = \"en\";\nenum digitGroupingSeparator = ',';\n\nprivate string pluralOf(string unit)\n{\n\tswitch (unit)\n\t{\n\t\tcase \"second\":\n\t\tcase \"minute\":\n\t\tcase \"hour\":\n\t\tcase \"day\":\n\t\tcase \"week\":\n\t\tcase \"month\":\n\t\tcase \"year\":\n\n\t\tcase \"thread\":\n\t\tcase \"post\":\n\t\tcase \"forum post\":\n\t\tcase \"subscription\":\n\t\tcase \"unread post\":\n\t\tcase \"registered user\":\n\t\tcase \"visit\":\n\t\t\treturn unit ~ \"s\";\n\n\t\tcase \"new reply\":\n\t\t\treturn \"new replies\";\n\n\t\tcase \"user has created\":\n\t\t\treturn \"users have created\";\n\n\t\tdefault:\n\t\t\tassert(false, \"Unknown unit: \" ~ unit);\n\t}\n}\n\nstring plural(string unit)(long amount)\n{\n\tstatic immutable unitPlural = pluralOf(unit);\n\treturn amount == 1 ? unit : unitPlural;\n}\n"
  },
  {
    "path": "src/dfeed/loc/package.d",
    "content": "/*  Copyright (C) 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.loc;\n\nimport std.algorithm.iteration;\nimport std.algorithm.searching;\nimport std.datetime;\nimport std.string;\nimport std.traits : staticMap, EnumMembers;\n\nimport ae.utils.array;\nimport ae.utils.json;\nimport ae.utils.meta;\nimport ae.utils.time.common;\nimport ae.utils.time.format;\n\nstatic import dfeed.loc.english;\nstatic import dfeed.loc.turkish;\n\nenum Language\n{\n\t// English should be first\n\tenglish,\n\t// Sort rest alphabetically\n\tturkish,\n}\nLanguage currentLanguage;\n\nimmutable string[enumLength!Language] languageNames = [\n\tdfeed.loc.english.languageName,\n\tdfeed.loc.turkish.languageName,\n];\n\nimmutable string[enumLength!Language] languageCodes = [\n\tdfeed.loc.english.languageCode,\n\tdfeed.loc.turkish.languageCode,\n];\n\nimmutable char[enumLength!Language] digitGroupingSeparators = [\n\tdfeed.loc.english.digitGroupingSeparator,\n\tdfeed.loc.turkish.digitGroupingSeparator,\n];\n\nstatic immutable string[][4][enumLength!Language] timeStrings = [\n\t[\n\t\tae.utils.time.common.WeekdayLongNames,\n\t\tae.utils.time.common.MonthLongNames,\n\t\tae.utils.time.common.WeekdayShortNames,\n\t\tae.utils.time.common.MonthShortNames,\n\t],\n\t[\n\t\tdfeed.loc.turkish.WeekdayLongNames,\n\t\tdfeed.loc.turkish.MonthLongNames,\n\t\tdfeed.loc.turkish.WeekdayShortNames,\n\t\tdfeed.loc.turkish.MonthShortNames,\n\t],\n];\n\nprivate template translate(string s, Language language)\n{\n\tstatic if (language == Language.english)\n\t\tenum translation = s;\n\telse\n\tstatic if (language == Language.turkish)\n\t\tenum translation = dfeed.loc.turkish.translate(s);\n\telse\n\t\tenum translation = string.init;\n\n\tstatic if (translation is null)\n\t{\n\t\timport std.conv : text;\n\t\tpragma(msg, \"Untranslated \", text(language), \" string: \", s);\n\t\tenum translate = s;\n\t}\n\telse\n\t\tenum translate = translation;\n}\n\nstring _(string s)()\n{\n\tenum translation(Language language) = translate!(s, language);\n\tstatic string[enumLength!Language] translations = [staticMap!(translation, EnumMembers!Language)];\n\treturn translations[currentLanguage];\n}\n\nenum pluralMany = 99;\n\nstring plural(string unit)(long amount)\n{\n\tfinal switch (currentLanguage)\n\t{\n\t\tcase Language.english:\n\t\t\treturn dfeed.loc.english.plural!unit(amount);\n\t\tcase Language.turkish:\n\t\t\treturn dfeed.loc.turkish.plural!unit(amount);\n\t}\n}\n\nauto withLanguage(Language language)\n{\n\tstruct WithLanguage\n\t{\n\t\tLanguage oldLanguage;\n\t\t@disable this(this);\n\t\t~this() { currentLanguage = oldLanguage; }\n\t}\n\tauto oldLanguage = currentLanguage;\n\tcurrentLanguage = language;\n\treturn WithLanguage(oldLanguage);\n}\n\nLanguage detectLanguage(string acceptLanguage)\n{\n\tforeach (pref; acceptLanguage.splitter(\",\"))\n\t{\n\t\tauto code = pref.findSplit(\";\")[0].findSplit(\"-\")[0].strip;\n\t\tauto i = languageCodes[].countUntil(code);\n\t\tif (i >= 0)\n\t\t\treturn cast(Language)i;\n\t}\n\treturn Language.init;\n}\n\nstring formatTimeLoc(string timeFormat)(SysTime time)\n{\n\tstring s = time.formatTime!timeFormat();\n\tif (!currentLanguage)\n\t\treturn s;\n\n\tbool[4] needStrings;\n\tforeach (c; timeFormat)\n\t\tswitch (c)\n\t\t{\n\t\t\tcase TimeFormatElement.dayOfWeekName:\n\t\t\t\tneedStrings[0] = true;\n\t\t\t\tbreak;\n\t\t\tcase TimeFormatElement.monthName:\n\t\t\t\tneedStrings[1] = true;\n\t\t\t\tbreak;\n\t\t\tcase TimeFormatElement.dayOfWeekNameShort:\n\t\t\t\tneedStrings[2] = true;\n\t\t\t\tbreak;\n\t\t\tcase TimeFormatElement.monthNameShort:\n\t\t\t\tneedStrings[3] = true;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tbreak;\n\t\t}\n\n\tstring[] sourceStrings, targetStrings;\n\tforeach (i, b; needStrings)\n\t\tif (b)\n\t\t{\n\t\t\tsourceStrings ~= timeStrings[Language.init  ][i];\n\t\t\ttargetStrings ~= timeStrings[currentLanguage][i];\n\t\t}\n\n\tstring result;\nmainLoop:\n\twhile (s.length)\n\t{\n\t\tforeach (i, sourceString; sourceStrings)\n\t\t\tif (s.skipOver(sourceString))\n\t\t\t{\n\t\t\t\tresult ~= targetStrings[i];\n\t\t\t\tcontinue mainLoop;\n\t\t\t}\n\t\tresult ~= s.shift;\n\t}\n\treturn result;\n}\n\n/// List of strings used in dfeed.js.\nimmutable jsStrings = [\n\t`Toggle navigation`,\n\t`Loading message`,\n\t`Your browser does not support HTML5 pushState.`,\n\t`Keyboard shortcuts`,\n\t`Ctrl`,\n\t`Down Arrow`,\n\t`Select next message`,\n\t`Up Arrow`,\n\t`Select previous message`,\n\t`Enter / Return`,\n\t`Open selected message`,\n\t`Create thread`,\n\t`Reply`,\n\t`Mark as unread`,\n\t`Open link`,\n\t`Space Bar`,\n\t`Scroll message / Open next unread message`,\n\t`(press any key or click to close)`,\n\t`Draft saved.`,\n\t`Error auto-saving draft.`,\n];\n\nstring getJsStrings()\n{\n\tstring[enumLength!Language] translations;\n\tif (!translations[currentLanguage])\n\t{\n\t\tstring[string] object;\n\t\tforeach (i; RangeTuple!(jsStrings.length))\n\t\t\tobject[jsStrings[i]] = _!(jsStrings[i]);\n\t\ttranslations[currentLanguage] = object.toJson();\n\t}\n\treturn translations[currentLanguage];\n}\n"
  },
  {
    "path": "src/dfeed/loc/turkish.d",
    "content": "/*  Copyright (C) 2020, 2021, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.loc.turkish;\n\nenum languageName = \"Türkçe\";\nenum languageCode = \"tr\";\nenum digitGroupingSeparator = '.';\n\nstring translate(string s)\n{\n\tswitch (s)\n\t{\n\t\tcase `Please enter a name`:\n\t\t\treturn `Lütfen bir isim giriniz`;\n\t\tcase `Please enter an email address`:\n\t\t\treturn `Lütfen bir e-posta adresi giriniz`;\n\t\tcase `Please enter a message subject`:\n\t\t\treturn `Lütfen bir mesaj konusu giriniz`;\n\t\tcase `Please enter a message`:\n\t\t\treturn `Lütfen bir mesaj giriniz`;\n\t\tcase `CAPTCHA error:`:\n\t\t\treturn `CAPTCHA hatası:`;\n\t\tcase `You can't post to this group.`:\n\t\t\treturn `Bu gruba mesaj gönderemezsiniz.`;\n\t\tcase `NNTP connection error:`:\n\t\t\treturn `NNTP bağlantı hatası:`;\n\t\tcase `NNTP error:`:\n\t\t\treturn `NNTP hatası:`;\n\t\tcase `Posting is disabled`:\n\t\t\treturn `Gönderim hizmet dışı`;\n\t\tcase `SMTP error:`:\n\t\t\treturn `SMTP hatası:`;\n\t\tcase `Post not found`:\n\t\t\treturn `Gönderi bulunamadı`;\n\t\tcase `Invalid attachment`:\n\t\t\treturn `Geçersiz ek`;\n\t\tcase `Guest`:\n\t\t\treturn `Misafir`;\n\t\tcase `Welcome back,`:\n\t\t\treturn `Tekrar hoşgeldiniz,`;\n\t\tcase `Welcome,`:\n\t\t\treturn `Hoşgeldiniz,`;\n\t\tcase `You have %d %s to %syour posts%s.`:\n\t\t\treturn `%3$sGönderilerinize%4$s %1$d %2$s var.`;\n\t\tcase `No new %sreplies%s to %syour posts%s.`:\n\t\t\treturn `%3$sGönderilerinize%4$s yeni %1$syanıt yok%2$s.`;\n\t\tcase `You have %d %s matching your %s%s subscription%s (%s).`:\n\t\t\treturn `%3$s%4$s aboneliğinizle%5$s eşleşen %1$d %2$s var (%6$s).`;\n\t\tcase `No new posts matching your %s%s%s.`:\n\t\t\treturn `%s%s%s ile eşleşen yeni gönderi yok.`;\n\t\tcase `If you %screate an account%s, you can track replies to %syour posts%s.`:\n\t\t\treturn `%sHesap açtığınızda%s %sgönderilerinize%s gelen yanıtları izleyebilirsiniz.`;\n\t\tcase `You can read and post on this forum without %screating an account%s, but creating an account offers %sa few benefits%s.`:\n\t\t\treturn `Bu forumu hesap açmadan da kullanabilirsiniz. Ancak, %shesap açmanın%s bazı %syararları vardır%s.`;\n\t\tcase `%d %s %-(%s and %)`:\n\t\t\treturn `%d %s %-(%s ve %) oluşturuldu.`;\n\t\tcase `No new forum activity`:\n\t\t\treturn `Yeni forum etkinliği yok`;\n\t\tcase `since your last visit (%s).`:\n\t\t\treturn `(Son ziyaretinizden beri (%s)).`;\n\t\tcase `in the last 24 hours.`:\n\t\t\treturn `(son 24 saat içinde).`;\n\t\tcase `There are %s %s, %s %s, and %s %s on this forum.`:\n\t\t\treturn `Bu forumda %s %s, %s %s ve %s %s var.`;\n\t\tcase `You have read a total of %s %s during your %s.`:\n\t\t\treturn `%3$s sırasında toplam %1$s %2$s okudunuz.`;\n\t\tcase `Random tip:`:\n\t\t\treturn `Bilgi:`;\n\t\tcase `This forum has several different <a href=\"/help#view-modes\">view modes</a>. Try them to find one you like best. You can change the view mode in the <a href=\"/settings\">settings</a>.`:\n\t\t\treturn `Bu forumun birkaç farklı <a href=\"/help#view-modes\">görünümü</a> bulunuyor. Kullanışlı bulduğunuz birini seçin. Görünüm seçeneğini <a href=\"/settings\">ayarlardan</a> değiştirebilirsiniz.`;\n\t\tcase `This forum supports <a href=\"/help#keynav\">keyboard shortcuts</a>. Press <kbd>?</kbd> to view them.`:\n\t\t\treturn `Bu forum <a href=\"/help#keynav\">klavye kısayolları</a> kullanır. Görüntülemek için <kbd>?</kbd> tuşuna basın.`;\n\t\tcase `You can focus a message with <kbd>j</kbd>/<kbd>k</kbd> and press <kbd>u</kbd> to mark it as unread, to remind you to read it later.`:\n\t\t\treturn `Bir mesajı <kbd>j</kbd> ve <kbd>k</kbd> tuşları ile seçebilir ve <kbd>u</kbd> tuşu ile okunmadı olarak işaretleyebilirsiniz.`;\n\t\tcase `The <a href=\"/help#avatars\">avatars on this forum</a> are provided by Gravatar, which allows associating a global avatar with an email address.`:\n\t\t\treturn `<a href=\"/help#avatars\">Bu forumdaki avatarlar</a>, global bir avatarın bir e-posta adresiyle ilişkilendirilmesini sağlayan Gravatar tarafından sağlanmaktadır.`;\n\t\tcase `This forum remembers your read post history on a per-post basis. If you are logged in, the post history is saved on the server, and in a compressed cookie otherwise.`:\n\t\t\treturn `Bu forum, okumuş olduğunuz gönderileri hatırlar. Bu bilgi, giriş yapmışsanız sunucuda, aksi takdirde sıkıştırılmış bir çerez olarak tarayıcınızda saklanır.`;\n\t\tcase `Much of this forum's content is also available via classic mailing lists or NNTP - see the \"Also via\" column on the forum index.`:\n\t\t\treturn `Bu forumun içeriğinin çoğuna e-posta listeleri veya NNTP aracılığıyla da erişilebilir - forum dizinindeki \"Ayrıca\" sütununa bakınız.`;\n\t\tcase `If you create a Gravatar profile with the email address you post with, it will be accessible when clicking your avatar.`:\n\t\t\treturn `Gönderdiğiniz e-posta adresiyle bir Gravatar profili oluşturursanız, avatarınıza tıkladığınızda erişilebilir olacaktır.`;\n\t\tcase `To subscribe to a thread, click the \"Subscribe\" link on that thread's first post. You need to be logged in to create subscriptions.`:\n\t\t\treturn `Bir konuya abone olmak için o konunun ilk gönderisindeki \"Abone ol\" bağlantısını tıklayın. Abonelik oluşturmak için giriş yapmış olmalısınız.`;\n\t\tcase `To search the forum, use the search widget at the top, or you can visit <a href=\"/search\">the search page</a> directly.`:\n\t\t\treturn `Forumda arama yapmak için üstteki arama olanağını kullabilirsiniz veya doğrudan <a href=\"/search\">arama sayfasına</a> gidebilirsiniz.`;\n\t\tcase `This forum is open-source! Read or fork the code <a href=\"https://github.com/CyberShadow/DFeed\">on GitHub</a>.`:\n\t\t\treturn `Bu forum açık kaynaklıdır! Kaynak kodunu <a href=\"https://github.com/CyberShadow/DFeed\">GitHub'da</a> okuyun veya çatallayın.`;\n\t\tcase `If you encounter a bug or need a missing feature, you can <a href=\"https://github.com/CyberShadow/DFeed/issues\">create an issue on GitHub</a>.`:\n\t\t\treturn `Farkettiğiniz hataları ve eksik özellikleri <a href=\"https://github.com/CyberShadow/DFeed/issues\">GitHub'da bildirebilirsiniz</a>.`;\n\t\tcase `Group`:\n\t\t\treturn `Grup`;\n\t\tcase `Last Post`:\n\t\t\treturn `Son Gönderi`;\n\t\tcase `Threads`:\n\t\t\treturn `Konu`;\n\t\tcase `Posts`:\n\t\t\treturn `Gönderi`;\n\t\tcase `Also via`:\n\t\t\treturn `Ayrıca`;\n\t\tcase `Create thread`:\n\t\t\treturn `Yeni konu`;\n\t\tcase `Invalid page`:\n\t\t\treturn `Geçersiz sayfa`;\n\t\tcase `by`:\n\t\t\treturn ``;\n\t\tcase `Thread / Thread Starter`:\n\t\t\treturn `Konu / Konuyu Başlatan`;\n\t\tcase `Replies`:\n\t\t\treturn `Yanıt`;\n\t\tcase `replies`:\n\t\t\treturn `yanıt`;\n\t\tcase `Loading...`:\n\t\t\treturn `Yükleniyor...`;\n\t\tcase `Sorry, this view requires JavaScript.`:\n\t\t\treturn `Üzgünüz, bu görünüm JavaScript gerektirmektedir.`;\n\t\tcase `Unknown group:`:\n\t\t\treturn `Bilinmeyen grup:`;\n\t\tcase `Can't find thread's page`:\n\t\t\treturn `Konu sayfası bulunamadı`;\n\t\tcase `Can't find post's page`:\n\t\t\treturn `Gönderi sayfası bulunamadı`;\n\t\tcase `The specified resource cannot be found on this server.`:\n\t\t\treturn `Belirtilen kaynak bu sunucuda bulunamıyor.`;\n\t\tcase `XSRF secret verification failed. Are your cookies enabled?`:\n\t\t\treturn `XSRF gizli doğrulaması başarısız oldu. Çerezleriniz etkin mi?`;\n\t\tcase `No action specified`:\n\t\t\treturn `Eylem belirtilmedi`;\n\t\tcase `Subscription undeleted.`:\n\t\t\treturn `Aboneliğin silinmesi geri alındı.`;\n\t\tcase `Subscription saved.`:\n\t\t\treturn `Abonelik kaydedildi.`;\n\t\tcase `Subscription created.`:\n\t\t\treturn `Abonelik oluşturuldu.`;\n\t\tcase `This subscription doesn't exist.`:\n\t\t\treturn `Bu abonelik mevcut değil.`;\n\t\tcase `Subscription deleted.`:\n\t\t\treturn `Abonelik silindi.`;\n\t\tcase `Unknown action:`:\n\t\t\treturn `Bilinmeyen eylem:`;\n\t\tcase `Settings`:\n\t\t\treturn `Ayarlar`;\n\t\tcase `User Interface`:\n\t\t\treturn `Kullanıcı arayüzü`;\n\t\tcase `Language:`:\n\t\t\treturn `Dil:`;\n\t\tcase `View mode:`:\n\t\t\treturn `Görünüm:`;\n\t\tcase `Enable keyboard shortcuts`:\n\t\t\treturn `Klavye kısayollarını etkinleştir`;\n\t\tcase `Automatically open messages after selecting them.`:\n\t\t\treturn `Seçilen mesaj otomatik olarak açılır.`;\n\t\tcase `Applicable to threaded, horizontal-split and vertical-split view modes.`:\n\t\t\treturn `Gönderi listesi, yatay bölünmüş ve dikey bölünmüş görünümlere uygulanabilir.`;\n\t\tcase `Focus follows message`:\n\t\t\treturn `Mesaj otomatik açılsın`;\n\t\tcase `Save`:\n\t\t\treturn `Kaydet`;\n\t\tcase `Cancel`:\n\t\t\treturn `İptal et`;\n\t\tcase `Subscriptions`:\n\t\t\treturn `Konu abonelikleri`;\n\t\tcase `Subscription`:\n\t\t\treturn `Seçim`;\n\t\tcase `Actions`:\n\t\t\treturn `Eylemler`;\n\t\tcase `View posts`:\n\t\t\treturn `Gönderileri göster`;\n\t\tcase `Get ATOM feed`:\n\t\t\treturn `ATOM beslemesini indir`;\n\t\tcase `Edit`:\n\t\t\treturn `Düzenle`;\n\t\tcase `Delete`:\n\t\t\treturn `Sil`;\n\t\tcase `You have no subscriptions.`:\n\t\t\treturn `Aboneliğiniz yok.`;\n\t\tcase `Create new content alert subscription`:\n\t\t\treturn `Konu aboneliği oluştur`;\n\t\tcase `Please %slog in%s to manage your subscriptions and account settings.`:\n\t\t\treturn `Aboneliklerinizi ve hesap ayarlarınızı yönetebilmek için lütfen %sgiriş yapınız%s.`;\n\t\tcase `Account settings`:\n\t\t\treturn `Hesap ayarları`;\n\t\tcase `Change the password used to log in to this account.`:\n\t\t\treturn `Bu hesabın şifresini değiştir.`;\n\t\tcase `Change password`:\n\t\t\treturn `Şifre değiştir`;\n\t\tcase `Download a file containing all data tied to this account.`:\n\t\t\treturn `Bu hesaba bağlı tüm verileri içeren bir dosya indir.`;\n\t\tcase `Export data`:\n\t\t\treturn `Verileri dışa aktar`;\n\t\tcase `Permanently delete this account.`:\n\t\t\treturn `Bu hesabı kalıcı olarak sil.`;\n\t\tcase `Delete account`:\n\t\t\treturn `Hesabı sil`;\n\t\tcase `Edit subscription`:\n\t\t\treturn `Aboneliği düzenle`;\n\t\tcase `Condition`:\n\t\t\treturn `Seçim`;\n\t\tcase `This action is only meaningful for logged-in users.`:\n\t\t\treturn `Bu işlem yalnızca giriş yapmış kullanıcılar için anlamlıdır.`;\n\t\tcase `Here you can change the password used to log in to this %s account.`:\n\t\t\treturn `Burada, bu %s hesabının şifresini değiştirebilirsiniz.`;\n\t\tcase `Please pick your new password carefully, as there are no password recovery options.`:\n\t\t\treturn `Şifre kurtarma seçeneği olmadığından, lütfen yeni şifrenizi dikkatlice seçin.`;\n\t\tcase `Current password:`:\n\t\t\treturn `Mevcut şifre:`;\n\t\tcase `New password:`:\n\t\t\treturn `Yeni şifre:`;\n\t\tcase `New password (confirm):`:\n\t\t\treturn `Yeni şifre (onaylayın):`;\n\t\tcase `XSRF secret verification failed`:\n\t\t\treturn `XSRF gizli doğrulaması başarısız oldu`;\n\t\tcase `The current password you entered is incorrect`:\n\t\t\treturn `Girdiğiniz mevcut şifre yanlış`;\n\t\tcase `New passwords do not match`:\n\t\t\treturn `Yeni şifreler uyuşmuyor`;\n\t\tcase `Password successfully changed.`:\n\t\t\treturn `Şifre başarıyla değiştirildi.`;\n\t\tcase `Export account data`:\n\t\t\treturn `Hesap verilerini dışa aktar`;\n\t\tcase `Here you can export the information regarding your account from the %s database.`:\n\t\t\treturn `Burada hesabınızla ilgili bilgileri %s veritabanından dışa aktarabilirsiniz.`;\n\t\tcase `Export`:\n\t\t\treturn `Dışa aktar`;\n\t\tcase `Here you can permanently delete your %s account and associated data from the database.`:\n\t\t\treturn `Burada %s hesabınızı ve ilişkili verileri veritabanından kalıcı olarak silebilirsiniz.`;\n\t\tcase `After deletion, the account username will become available for registration again.`:\n\t\t\treturn `Silme işleminden sonra, hesap kullanıcı adı tekrar kayıt için uygun hale gelecektir.`;\n\t\tcase `To confirm deletion, please enter your account username and password.`:\n\t\t\treturn `Silme işlemini onaylamak için lütfen hesap kullanıcı adınızı ve şifrenizi giriniz.`;\n\t\tcase `Account username:`:\n\t\t\treturn `Hesap kullanıcı adı:`;\n\t\tcase `Account password:`:\n\t\t\treturn `Hesap şifresi:`;\n\t\tcase `Delete this account`:\n\t\t\treturn `Bu hesabı sil`;\n\t\tcase `The username you entered does not match the current logged-in account`:\n\t\t\treturn `Girdiğiniz kullanıcı adı, mevcut oturum açmış hesapla eşleşmiyor`;\n\t\tcase `The password you entered is incorrect`:\n\t\t\treturn `Girdiğiniz şifre yanlış`;\n\t\tcase `Account successfully deleted!`:\n\t\t\treturn `Hesap başarıyla silindi!`;\n\t\tcase `Latest threads on %s`:\n\t\t\treturn `%s üzerindeki son konular`;\n\t\tcase `Latest posts on %s`:\n\t\t\treturn `%s üzerindeki son gönderiler`;\n\t\tcase `Latest threads`:\n\t\t\treturn `Son konular`;\n\t\tcase `Latest posts`:\n\t\t\treturn `Son gönderiler`;\n\t\tcase `%s subscription (%s)`:\n\t\t\treturn `%s aboneliği (%s)`;\n\t\tcase `No such subscription`:\n\t\t\treturn `Böyle bir abonelik yok`;\n\t\tcase `Not logged in`:\n\t\t\treturn `Giriş yapmadınız`;\n\t\tcase `No such user subscription`:\n\t\t\treturn `Böyle bir kullanıcı aboneliği yok`;\n\t\tcase `reply`:\n\t\t\treturn `yanıt`;\n\t\tcase \"new\":\n\t\t\treturn \"yeni\";\n\t\tcase `Replies to your posts`:\n\t\t\treturn `Gönderilerinize verilen yanıtlar`;\n\t\tcase `%s replied to your post in the thread \"%s\"`:\n\t\t\treturn `%s, \"%s\" konusundaki gönderinize yanıt verdi`;\n\t\tcase `%s has just replied to your %s post in the thread titled \"%s\" in the %s group of %s.`:\n\t\t\treturn `%1$s, %5$s sunucusunun %4$s grubundaki \"%3$s\" başlıklı konusundaki %2$s gönderinize az önce yanıt verdi.`;\n\t\tcase `When someone replies to your posts:`:\n\t\t\treturn `Birisi gönderilerinize yanıt verdiğinde:`;\n\t\tcase `thread`:\n\t\t\treturn `konu`;\n\t\tcase `Replies to the thread`:\n\t\t\treturn `Konusu:`;\n\t\tcase `%s replied to the thread \"%s\"`:\n\t\t\treturn `%s, \"%s\" konusuna yanıt verdi`;\n\t\tcase `%s has just replied to a thread you have subscribed to titled \"%s\" in the %s group of %s.`:\n\t\t\treturn `%1$s, %4$s sunucusunun %3$s grubundaki abone olduğunuz \"%2$s\" başlıklı konuya yanıt verdi.`;\n\t\tcase `When someone posts a reply to the thread`:\n\t\t\treturn `Birisi şu konuya yanıt gönderdiğinde:`;\n\t\tcase `No such post`:\n\t\t\treturn `Böyle bir gönderi yok`;\n\t\tcase `content`:\n\t\t\treturn `içerik`;\n\t\tcase `New threads`:\n\t\t\treturn `Yeni konu veya gönderi;`;\n\t\tcase `New posts`:\n\t\t\treturn `Yeni gönderi;`;\n\t\tcase `in`:\n\t\t\treturn `grup(lar):`;\n\t\tcase `and`:\n\t\t\treturn `ve`;\n\t\tcase `more`:\n\t\t\treturn `tane daha`;\n\t\tcase `from`:\n\t\t\treturn `göndereni:`;\n\t\tcase `from email`:\n\t\t\treturn `e-postası:`;\n\t\tcase `titled`:\n\t\t\treturn `konusu:`;\n\t\tcase `containing`:\n\t\t\treturn `içeriği:`;\n\t\tcase `%s %s the thread \"%s\" in %s`:\n\t\t\treturn `%1$s, %4$s grubunun \"%3$s\" konusunu %2$s`;\n\t\tcase `replied to`:\n\t\t\treturn `yanıtladı`;\n\t\tcase `created`:\n\t\t\treturn `oluşturdu`;\n\t\tcase `%s matching %s`:\n\t\t\treturn `%s ile eşleşen %s`;\n\t\tcase \"%s has just %s a thread titled \\\"%s\\\" in the %s group of %s.\\n\\n%s matches a content alert subscription you have created (%s).\":\n\t\t\treturn \"%1$s, %5$s sunucusunun %4$s grubundaki \\\"%3$s\\\" başlıklı konuyu %2$s.\\n\\n%6$s, oluşturduğunuz bir konu aboneliğiyle eşleşiyor (%7$s).\";\n\t\tcase `This post`:\n\t\t\treturn `Bu gönderi`;\n\t\tcase `This thread`:\n\t\t\treturn `Bu konu`;\n\t\tcase `When someone`:\n\t\t\treturn `Birisi`;\n\t\tcase `posts or replies to a thread`:\n\t\t\treturn `yeni konu açarsa veya yanıtlarsa`;\n\t\tcase `posts a new thread`:\n\t\t\treturn `yeni konu açarsa`;\n\t\tcase `only in the groups:`:\n\t\t\treturn `grup şunlardan birisiyse:<br>(birden fazla seçmek için Ctrl-tık):`;\n\t\tcase `and when the`:\n\t\t\treturn `ve eğer`;\n\t\tcase `contains the string`:\n\t\t\treturn `şu dizgiyi içerirse:`;\n\t\tcase `matches the regular expression`:\n\t\t\treturn `şu düzenli ifadeyle (regex) eşleşirse:`;\n\t\tcase `case sensitive`:\n\t\t\treturn `küçük/büyük harfe duyarlı`;\n\t\tcase `author name`:\n\t\t\treturn `yazar adı`;\n\t\tcase `author email`:\n\t\t\treturn `yazar e-postası`;\n\t\tcase `subject`:\n\t\t\treturn `konu başlığı`;\n\t\tcase `message`:\n\t\t\treturn `ileti`;\n\t\tcase `No %s search term specified`:\n\t\t\treturn `%s arama terimi belirtilmedi`;\n\t\tcase \"Invalid %s regex `%s`: %s\":\n\t\t\treturn \"Geçersiz %s düzenli ifadesi `%s`: %s\";\n\t\tcase `No groups selected`:\n\t\t\treturn `Grup seçilmedi`;\n\t\tcase `Unknown subscription trigger type:`:\n\t\t\treturn `Bilinmeyen abonelik tetikleyici türü:`;\n\t\tcase `Send a private message to`:\n\t\t\treturn `Şu kişiye özel mesaj gönder:`;\n\t\tcase `on the`:\n\t\t\treturn `sunucusu üzerinde`;\n\t\tcase `IRC network`:\n\t\t\treturn `IRC ağı`;\n\t\tcase `No nickname indicated`:\n\t\t\treturn `Takma ad belirtilmedi`;\n\t\tcase `Invalid character in nickname.`:\n\t\t\treturn `Takma adda geçersiz karakter.`;\n\t\tcase `Send an email to`:\n\t\t\treturn `Şu adrese e-posta gönder:`;\n\t\tcase `Error:`:\n\t\t\treturn `Hata:`;\n\t\tcase `Howdy %1$s,`:\n\t\t\treturn `Merhaba %1$s,`;\n\t\tcase `This %3$s is located at:`:\n\t\t\treturn `Bu %3$:`;\n\t\tcase `Here is the message that has just been posted:`:\n\t\t\treturn `Yeni gönderi:`;\n\t\tcase `To reply to this message, please visit this page:`:\n\t\t\treturn `Bu gönderiyi yanıtlamak için lütfen şu sayfayı ziyaret edin:`;\n\t\tcase `There may also be other messages matching your subscription, but you will not receive any more notifications for this subscription until you've read all messages matching this subscription:`:\n\t\t\treturn `Aboneliğinizle eşleşen başka mesajlar da olabilir, ancak bu abonelikle eşleşen tüm mesajları okuyana kadar bu abonelik için başka bildirim almayacaksınız:`;\n\t\tcase `All the best,`:\n\t\t\treturn `Herşey gönlünüzce olsun,`;\n\t\tcase `Unsubscription information:`:\n\t\t\treturn `Abonelik iptali bilgileri:`;\n\t\tcase `To stop receiving emails for this subscription, please visit this page:`:\n\t\t\treturn `Bu abonelik için e-posta almayı durdurmak için lütfen şu sayfayı ziyaret edin:`;\n\t\tcase `Or, visit your settings page to edit your subscriptions:`:\n\t\t\treturn `Veya aboneliklerinizi düzenlemek için ayarlar sayfanızı ziyaret edin:`;\n\t\tcase `post`:\n\t\t\treturn `İleti`;\n\t\tcase `Invalid email address`:\n\t\t\treturn `Geçersiz e-posta adresi`;\n\t\tcase `Additionally, you can %ssubscribe to an ATOM feed of matched posts%s, or %sread them online%s.`:\n\t\t\treturn `Ek olarak, seçilen gönderiler için bir %sATOM feed aboneliği başlatabilir%s veya gönderileri %sburadan okuyabilirsiniz%s.`;\n\t\tcase `No such post:`:\n\t\t\treturn `Böyle bir gönderi yok:`;\n\t\tcase `Post #%d of thread %s not found`:\n\t\t\treturn `#%d numaralı gönderi %s numaralı konuda bulunamadı`;\n\t\tcase `Jump to page:`:\n\t\t\treturn `Sayfaya atla:`;\n\t\tcase `Page`:\n\t\t\treturn `Sayfa`;\n\t\tcase `Go`:\n\t\t\treturn `Git`;\n\t\tcase `Thread overview`:\n\t\t\treturn `Konuya genel bakış`;\n\t\tcase `Thread not found`:\n\t\t\treturn `Konu bulunamadı`;\n\t\tcase `Permalink`:\n\t\t\treturn `Kalıcı bağlantı`;\n\t\tcase `Canonical link to this post. See \"Canonical links\" on the Help page for more information.`:\n\t\t\treturn `Bu gönderiye kalıcı bağlantı. Daha fazla bilgi için Yardım sayfasındaki \"Kalıcı bağlantılar\" bölümüne bakınız.`;\n\t\tcase `Reply`:\n\t\t\treturn `Yanıtla`;\n\t\tcase `Reply to this post`:\n\t\t\treturn `Bu gönderiyi yanıtla`;\n\t\tcase `Subscribe`:\n\t\t\treturn `Abone ol`;\n\t\tcase `Subscribe to this thread`:\n\t\t\treturn `Bu konuya abone ol`;\n\t\tcase `Flag`:\n\t\t\treturn `Bildir`;\n\t\tcase `Flag this post for moderator intervention`:\n\t\t\treturn `Bu gönderiyi yönetici müdahalesi için işaretleyin`;\n\t\tcase `Source`:\n\t\t\treturn `Kaynak`;\n\t\tcase `View this message's source code`:\n\t\t\treturn `Bu mesajın kaynak kodunu görüntüle`;\n\t\tcase `Moderate`:\n\t\t\treturn `Yönet`;\n\t\tcase `Perform moderation actions on this post`:\n\t\t\treturn `Bu gönderi üzerinde denetim işlemleri gerçekleştirin`;\n\t\tcase `%s's Gravatar profile`:\n\t\t\treturn `%s kullanıcısının Gravatar profili`;\n\t\tcase `Posted by %s`:\n\t\t\treturn `Gönderen: %s`;\n\t\tcase `in reply to`:\n\t\t\treturn `Yanıtlanan: `;\n\t\tcase `part`:\n\t\t\treturn `Bölüm`;\n\t\tcase `Posted in reply to`:\n\t\t\treturn `Yanıtlanan: `;\n\t\tcase `Attachments:`:\n\t\t\treturn `Ekler:`;\n\t\tcase `View in thread`:\n\t\t\treturn `Konusu içinde görüntüle`;\n\t\tcase `Replies to %s's post from %s`:\n\t\t\treturn `%s tarafından gönderilen %s gönderisine yanıtlar`;\n\t\tcase `Permanent link to this post`:\n\t\t\treturn `Bu gönderiye kalıcı bağlantı`;\n\t\tcase `%s's Gravatar profile\"`:\n\t\t\treturn `Gravatar profili (%s)\"`;\n\t\tcase `You seem to be posting using an unusual user-agent`:\n\t\t\treturn `Garip bir kullanıcı programı (user-agent) kullanıyorsunuz`;\n\t\tcase `Your subject contains a suspicious keyword or character sequence`:\n\t\t\treturn `Konu başlığınız şüpheli bir anahtar kelime veya karakter dizisi içeriyor`;\n\t\tcase `Your post contains a suspicious keyword or character sequence`:\n\t\t\treturn `Gönderiniz şüpheli bir anahtar kelime veya karakter dizisi içeriyor`;\n\t\tcase `Your top-level post is suspiciously short`:\n\t\t\treturn `Konu başlığınız ve gönderiniz şüpheli derecede kısa`;\n\t\tcase `Your post looks like spam (%d%% spamicity)`:\n\t\t\treturn `Gönderiniz spam gibi görünüyor (%d%% spamicity)`;\n\t\tcase `from the future`:\n\t\t\treturn `gelecekten`;\n\t\tcase `just now`:\n\t\t\treturn `az önce`;\n\t\tcase `%d %s ago`:\n\t\t\treturn `%d %s önce`;\n\t\tcase `basic`:\n\t\t\treturn `Temel`;\n\t\tcase `narrow-index`:\n\t\t\treturn `Dar dizin`;\n\t\tcase `threaded`:\n\t\t\treturn `Gönderi listesi`;\n\t\tcase `horizontal-split`:\n\t\t\treturn `Yatay bölünmüş`;\n\t\tcase `vertical-split`:\n\t\t\treturn `Dikey bölünmüş`;\n\t\tcase `Unknown view mode`:\n\t\t\treturn `Bilinmeyen görünüm`;\n\t\tcase `You have an %sunsent draft message from %s%s.`:\n\t\t\treturn `%s%s tarihinden kalmış bir taslak mesajınız%s var.`;\n\t\tcase `This message is awaiting moderation.`:\n\t\t\treturn `Bu mesaj bir yönetici tarafından denetlenmeyi bekliyor.`;\n\t\tcase `This message has already been posted.`:\n\t\t\treturn `Bu mesaj zaten gönderilmiş.`;\n\t\tcase `Can't post to archive`:\n\t\t\treturn `Arşive gönderilemiyor`;\n\t\tcase `Note: you are posting to a mailing list.`:\n\t\t\treturn `Not: Bir posta listesine gönderiyorsunuz.`;\n\t\tcase `Your message will not go through unless you %ssubscribe to the mailing list%s first.`:\n\t\t\treturn `%sPosta listesine abone olmadığınız%s sürece mesajınız gönderilmeyecek.`;\n\t\tcase `You must then use the same email address when posting here as the one you used to subscribe to the list.`:\n\t\t\treturn `Buradan gönderirken listeye abone olmak için kullandığınız e-posta adresini kullanmanız gerekir.`;\n\t\tcase `If you do not want to receive mailing list mail, you can disable mail delivery at the above link.`:\n\t\t\treturn `Posta listesi gönderisi almak istemiyorsanız, yukarıdaki bağlantıdan posta teslimini devre dışı bırakabilirsiniz.`;\n\t\tcase `Warning: the post you are replying to is from`:\n\t\t\treturn `Uyarı: yanıtladığınız gönderi eski:`;\n\t\tcase `Posting to`:\n\t\t\treturn ``;\n\t\tcase `unknown post`:\n\t\t\treturn `bilinmeyen gönderi`;\n\t\tcase `Your name:`:\n\t\t\treturn `Adınız:`;\n\t\tcase `Your email address`:\n\t\t\treturn `E-posta adresiniz`;\n\t\tcase `Subject:`:\n\t\t\treturn `Konu:`;\n\t\tcase `Message:`:\n\t\t\treturn `İleti:`;\n\t\tcase `Save and preview`:\n\t\t\treturn `Kaydet ve önizle`;\n\t\tcase `Send`:\n\t\t\treturn `Gönder`;\n\t\tcase `Discard draft`:\n\t\t\treturn `Taslağı sil`;\n\t\tcase `This message has already been sent.`:\n\t\t\treturn `Bu mesaj zaten gönderilmiş.`;\n\t\tcase `Automatic fix applied.`:\n\t\t\treturn `Otomatik düzeltme uygulandı.`;\n\t\tcase `Undo`:\n\t\t\treturn `Geri al`;\n\t\tcase `Sorry, a problem occurred while attempting to fix your post`:\n\t\t\treturn `Üzgünüz, gönderinizi düzeltmeye çalışırken bir sorun oluştu`;\n\t\tcase `Undo information not found.`:\n\t\t\treturn `Geri alma bilgisi bulunamadı.`;\n\t\tcase `Automatic fix undone.`:\n\t\t\treturn `Otomatik düzeltme geri alındı.`;\n\t\tcase `Warning:`:\n\t\t\treturn `Uyarı:`;\n\t\tcase `Ignore`:\n\t\t\treturn `Göz ardı et`;\n\t\tcase `Explain`:\n\t\t\treturn `Açıkla`;\n\t\tcase `Fix it for me`:\n\t\t\treturn `Benim için düzelt`;\n\t\tcase `You've attempted to post %d times in the past %s. Please wait a little bit before trying again.`:\n\t\t\treturn `Geçmiş %2$s süresi içinde %1$d kez göndermeye çalıştınız. Tekrar denemeden önce lütfen biraz bekleyin.`;\n\t\tcase `You've attempted to post %d times in the past %s. Please solve a CAPTCHA to continue.`:\n\t\t\treturn `Geçmiş %2$s süresi içinde %1$d kez göndermeye çalıştınız. Devam etmek için lütfen bir CAPTCHA çözünüz.`;\n\t\tcase `Your message has been saved, and will be posted after being approved by a moderator.`:\n\t\t\treturn `Mesajınız kaydedildi; bir yönetici tarafından onaylandıktan sonra gönderilecek.`;\n\t\tcase `Unknown action`:\n\t\t\treturn `Bilinmeyen eylem`;\n\t\tcase `Posting status`:\n\t\t\treturn `Gönderi durumu`;\n\t\tcase `Validating...`:\n\t\t\treturn `Doğrulanıyor...`;\n\t\tcase `Verifying reCAPTCHA...`:\n\t\t\treturn `reCAPTCHA doğrulanıyor ...`;\n\t\tcase `Connecting to server...`:\n\t\t\treturn `Sunucuya baglanıyor...`;\n\t\tcase `Sending message to server...`:\n\t\t\treturn `Sunucuya mesaj gönderiliyor...`;\n\t\tcase `Message sent.`:\n\t\t\treturn `Mesaj gönderildi.`;\n\t\tcase `Waiting for message announcement...`:\n\t\t\treturn `Mesaj duyurusu bekleniyor...`;\n\t\tcase `Message posted! Redirecting...`:\n\t\t\treturn `Mesaj gönderildi! Yönlendiriliyor...`;\n\t\tcase `%s. Please solve a CAPTCHA to continue.`:\n\t\t\treturn `%s. Devam etmek için lütfen bir CAPTCHA çözünüz.`;\n\t\tcase `Spam check error:`:\n\t\t\treturn `Spam denetimi hatası:`;\n\t\tcase `Try clearing your browser's cookies. Create an account to avoid repeated incidents.`:\n\t\t\treturn `Tarayıcınızın çerezlerini temizlemeyi deneyin. Bu gibi tekrarlardan kaçınmak için bir hesap oluşturun.`;\n\t\tcase `Malformed Base64 in read post history cookie.`:\n\t\t\treturn `Okuma geçmişi çerezinde hatalı Base64.`;\n\t\tcase `Malformed deflated data in read post history cookie`:\n\t\t\treturn `Okuma geçmişi çerezinde hatalı sıkıştırılmış veri`;\n\t\tcase `Please enter a username`:\n\t\t\treturn `Lütfen bir kullanıcı adı giriniz`;\n\t\tcase `Username too long`:\n\t\t\treturn `Kullanıcı adı çok uzun`;\n\t\tcase `Password too long`:\n\t\t\treturn `Şifre çok uzun`;\n\t\tcase `Already logged in`:\n\t\t\treturn `Zaten giriş yapılmış`;\n\t\tcase `Already registered`:\n\t\t\treturn `Zaten kayıtlı`;\n\t\tcase `Can't edit this message. It has already been sent.`:\n\t\t\treturn `Bu mesajı düzenleyemezsiniz. Zaten gönderildi.`;\n\t\tcase `Can't edit this message. It has already been submitted for moderation.`:\n\t\t\treturn `Bu mesajı düzenleyemezsiniz. Zaten denetim için gönderildi.`;\n\t\tcase `StopForumSpam API error:`:\n\t\t\treturn `StopForumSpam API hatası:`;\n\t\tcase `StopForumSpam thinks you may be a spammer (%s last seen: %s, frequency: %s)`:\n\t\t\treturn `StopForumSpam, spam yapan birisi olduğunuzdan şüpheleniyor (%s son görülme: %s, sıklık: %s)`;\n\t\tcase `Log in`:\n\t\t\treturn `Oturum aç`;\n\t\tcase `Username:`:\n\t\t\treturn `Kullanıcı adı:`;\n\t\tcase `Password:`:\n\t\t\treturn `Şifre:`;\n\t\tcase `Remember me`:\n\t\t\treturn `Beni Hatırla`;\n\t\tcase `Register`:\n\t\t\treturn `Kayıt ol`;\n\t\tcase `to keep your preferences<br>and read post history on the server.`:\n\t\t\treturn `(Ayarlarınızı ve gönderi geçmişinizi sunucuda saklamak için.)`;\n\t\tcase `Confirm:`:\n\t\t\treturn `Onaylayın:`;\n\t\tcase `Please pick your password carefully.`:\n\t\t\treturn `Lütfen şifrenizi dikkatlice seçin.`;\n\t\tcase `There are no password recovery options.`:\n\t\t\treturn `Şifre kurtarma seçeneği yoktur.`;\n\t\tcase `Passwords do not match`:\n\t\t\treturn `Şifre uyuşmuyor`;\n\t\tcase `First`:\n\t\t\treturn `İlk`;\n\t\tcase `Prev`:\n\t\t\treturn `Önceki`;\n\t\tcase `Next`:\n\t\t\treturn `Sonraki`;\n\t\tcase `Last`:\n\t\t\treturn `Son`;\n\t\tcase `Advanced Search`:\n\t\t\treturn `Gelişmiş Arama`;\n\t\tcase `Find posts with...`:\n\t\t\treturn `Aşağıdakilere uyan gönderileri bulur.`;\n\t\tcase `all these words:`:\n\t\t\treturn `Şu kelimelerin hepsi:`;\n\t\tcase `this exact phrase:`:\n\t\t\treturn `Tam olarak şu söz dizisi:`;\n\t\tcase `none of these words:`:\n\t\t\treturn `Şu kelimelerin hiçbiri:`;\n\t\tcase `posted in the group:`:\n\t\t\treturn `Gönderildiği grup:`;\n\t\tcase `posted by:`:\n\t\t\treturn `Gönderenin adı:`;\n\t\tcase `posted by (email):`:\n\t\t\treturn `Gönderenin e-posta'sı:`;\n\t\tcase `in threads titled:`:\n\t\t\treturn `Konu başlığı:`;\n\t\tcase `containing:`:\n\t\t\treturn `İçeriğinde geçen:`;\n\t\tcase `posted between:`:\n\t\t\treturn `Tarih aralığı:`;\n\t\tcase `yyyy-mm-dd`:\n\t\t\treturn `yyyy-aa-gg`;\n\t\tcase `posted as new thread:`:\n\t\t\treturn `Konunun ilk mesajı:`;\n\t\tcase `Advanced search`:\n\t\t\treturn `Gelişmiş ara`;\n\t\tcase `Search`:\n\t\t\treturn `Ara`;\n\t\tcase `Invalid date: %s (%s)`:\n\t\t\treturn `Geçersiz tarih: %s (%s)`;\n\t\tcase `Start date must be before end date`:\n\t\t\treturn `Başlangıç ​​tarihi bitiş tarihinden önce olmalıdır`;\n\t\tcase `Invalid page number`:\n\t\t\treturn `Geçersiz sayfa numarası`;\n\t\tcase `Your search -`:\n\t\t\treturn `Aramanız -`;\n\t\tcase `- did not match any forum posts.`:\n\t\t\treturn `- hiçbir forum gönderisiyle eşleşmedi.`;\n\t\tcase `View this post`:\n\t\t\treturn `Bu gönderiyi görüntüle`;\n\t\tcase `Invalid path`:\n\t\t\treturn `Geçersiz yol`;\n\t\tcase `Legacy redirect - unsupported feature`:\n\t\t\treturn `Eski yönlendirme - desteklenmeyen özellik`;\n\t\tcase `Legacy redirect - article not found`:\n\t\t\treturn `Eski yönlendirme - gönderi bulunamadı`;\n\t\tcase `Legacy redirect - ambiguous artnum (group parameter missing)`:\n\t\t\treturn `Eski yönlendirme - belirsiz artnum (grup parametresi eksik)`;\n\t\tcase `No group specified`:\n\t\t\treturn `Grup belirtilmedi`;\n\t\tcase `(page %d)`:\n\t\t\treturn `(sayfa %d)`;\n\t\tcase `Unknown group`:\n\t\t\treturn `Bilinmeyen grup`;\n\t\tcase `%s group index`:\n\t\t\treturn `%s grup dizini`;\n\t\tcase `New posts on`:\n\t\t\treturn `Yeni gönderiler:`;\n\t\tcase `New threads on`:\n\t\t\treturn `Yeni konular:`;\n\t\tcase `No thread specified`:\n\t\t\treturn `Konu belirtilmedi`;\n\t\tcase `Viewing thread in basic view mode – click a post's title to open it in %s view mode`:\n\t\t\treturn `Konu temel görünüm ile görüntüleniyor - %s olarak görüntülemek için bir konuya tıklayın`;\n\t\tcase `No post specified`:\n\t\t\treturn `Gönderi belirtilmedi`;\n\t\tcase `(view single post)`:\n\t\t\treturn `(tek gönderiyi görüntüle)`;\n\t\tcase `Invalid URL`:\n\t\t\treturn `Geçersiz URL`;\n\t\tcase `No such group`:\n\t\t\treturn `Böyle bir grup yok`;\n\t\tcase `Posting to %s`:\n\t\t\treturn `Alıcı: %s`;\n\t\tcase `New thread`:\n\t\t\treturn `Yeni konu`;\n\t\tcase `Replying to \"%s\"`:\n\t\t\treturn `Yanıtlanan: \"%s\"`;\n\t\tcase `Post reply`:\n\t\t\treturn `Yanıt gönder`;\n\t\tcase `Posting`:\n\t\t\treturn `Gönderi`;\n\t\tcase `No post ID specified`:\n\t\t\treturn `Gönderi kimliği belirtilmemiş`;\n\t\tcase `Composing message`:\n\t\t\treturn `Mesaj oluşturma`;\n\t\tcase `Please log in to do that`:\n\t\t\treturn `Lütfen giriş yapınız`;\n\t\tcase `Subscribe to thread`:\n\t\t\treturn `Konuya abone ol`;\n\t\tcase `No subscription specified`:\n\t\t\treturn `Abonelik belirtilmemiş`;\n\t\tcase `View subscription`:\n\t\t\treturn `Aboneliği görüntüle`;\n\t\tcase `Unsubscribe`:\n\t\t\treturn `Aboneliği iptal et`;\n\t\tcase `You are not a moderator`:\n\t\t\treturn `Yönetici değilsiniz`;\n\t\tcase `Moderating post \"%s\"`:\n\t\t\treturn `\"%s\" gönderisi denetleniyor`;\n\t\tcase `Moderate post`:\n\t\t\treturn `Gönderiyi denetle`;\n\t\tcase `You can't flag posts`:\n\t\t\treturn `Gönderileri işaretleyemezsiniz`;\n\t\tcase `Flag \"%s\" by %s`:\n\t\t\treturn `%2$s tarafından gönderilen \"%1$s\" gönderisini işaretle`;\n\t\tcase `Flag post`:\n\t\t\treturn `Gönderiyi işaretle`;\n\t\tcase `You can't approve moderated drafts`:\n\t\t\treturn `Denetlenmiş olan taslakları siz onaylayamazsınız`;\n\t\tcase `Approving moderated draft`:\n\t\t\treturn `Denetlenmiş olan taslak onaylanıyor`;\n\t\tcase `Registration`:\n\t\t\treturn `Kayıt`;\n\t\tcase `Login error`:\n\t\t\treturn `Giriş hatası`;\n\t\tcase `Registration error`:\n\t\t\treturn `Kayıt Hatası`;\n\t\tcase `Account`:\n\t\t\treturn `Hesap`;\n\t\tcase `Change Password`:\n\t\t\treturn `Şifre değiştir`;\n\t\tcase `Export Data`:\n\t\t\treturn `Dışa Aktar`;\n\t\tcase `Delete Account`:\n\t\t\treturn `Hesabı sil`;\n\t\tcase `Help`:\n\t\t\treturn `Yardım`;\n\t\tcase `Forum activity summary`:\n\t\t\treturn `Forum etkinliği özeti`;\n\t\tcase `Feed type not specified`:\n\t\t\treturn `Feed türü belirtilmedi`;\n\t\tcase `Unknown feed type`:\n\t\t\treturn `Bilinmeyen feed türü`;\n\t\tcase `hours parameter exceeds limit`:\n\t\t\treturn `hours parametresi sınırı aşıyor`;\n\t\tcase `Not Found`:\n\t\t\treturn `Bulunamadı`;\n\t\tcase `Error`:\n\t\t\treturn `Hata`;\n\t\tcase `Index`:\n\t\t\treturn `Dizin`;\n\t\tcase `Log out`:\n\t\t\treturn `Çıkış Yap`;\n\t\tcase `Draft discarded.`:\n\t\t\treturn `Taslak silindi.`;\n\t\tcase `Settings saved.`:\n\t\t\treturn `Ayarlar kaydedildi.`;\n\t\tcase `Warning: cookie size approaching RFC 2109 limit.`:\n\t\t\treturn `Uyarı: çerez boyutu RFC 2109 sınırına yaklaşıyor.`;\n\t\tcase `Please consider %screating an account%s to avoid losing your read post history.`:\n\t\t\treturn `Okuma geçmişinizi kaybetmemek için lütfen %shesap açtırın%s.`;\n\t\tcase `Forums`:\n\t\t\treturn `Forumlarda`;\n\t\tcase `%s group`:\n\t\t\treturn `%s grubunda`;\n\t\tcase `View subscription:`:\n\t\t\treturn ``;\n\t\tcase `It looks like there's nothing here! No posts matched this subscription so far.`:\n\t\t\treturn `Görünüşe göre burada hiçbir şey yok! Henüz bu abonelikle eşleşen gönderi yok.`;\n\t\tcase `This subscription has been deactivated.`:\n\t\t\treturn `Bu abonelik devre dışı bırakıldı.`;\n\t\tcase `If you did not intend to do this, you can reactivate the subscription's actions on your %ssettings page%s.`:\n\t\t\treturn `Bunu yapmak istemediyseniz, aboneliğin eylemlerini %sayarlar sayfanızda%s yeniden etkinleştirebilirsiniz.`;\n\t\tcase `Hint`:\n\t\t\treturn `İpucu`;\n\t\tcase \"Is the CAPTCHA too hard?\\nRefresh the page to get a different question,\\nor ask in the %s#d IRC channel on Libera.Chat%s.\":\n\t\t\treturn \"CAPTCHA çok mu zor?\\nFarklı bir soru almak için sayfayı yenileyin\\nveya yanıtını %sLibera.Chat üzerindeki #d IRC kanalında%s sorun.\";\n\t\tcase `Unknown or expired CAPTCHA challenge`:\n\t\t\treturn `Bilinmeyen veya süresi dolmuş CAPTCHA testi`;\n\t\tcase `The answer is incorrect`:\n\t\t\treturn `Yanıt yanlış`;\n\t\tcase `Akismet thinks your post looks like spam`:\n\t\t\treturn `Akismet, gönderinizin spam gibi göründüğünü düşünüyor`;\n\t\tcase `Akismet error:`:\n\t\t\treturn `Akismet hatası:`;\n\t\tcase `Latest announcements`:\n\t\t\treturn `Son duyurular`;\n\t\tcase `Active discussions`:\n\t\t\treturn `Aktif konu`;\n\t\tcase `ProjectHoneyPot thinks you may be a spammer (%s last seen: %d days ago, threat score: %d/255, type: %s)`:\n\t\t\treturn `ProjectHoneyPot, spam yapan birisi olduğunuzdan şüpheleniyor (%s son görülme: %d gün önce, tehdit puanı: %d/255, tür: %s)`;\n\t\tcase `From`:\n\t\t\treturn `Gönderen`;\n\t\tcase `Date`:\n\t\t\treturn `Tarih`;\n\t\tcase `In reply to`:\n\t\t\treturn `Yanıtlanan:`;\n\t\tcase `Attachments`:\n\t\t\treturn `Ekler`;\n\t\tcase `Parent post is not quoted.`:\n\t\t\treturn `Alıntı yapılmamış.`;\n\t\tcase `When replying to someone's post, you should provide some context for your replies by quoting the revelant parts of their post.`:\n\t\t\treturn `Bir gönderiyi yanıtlarken, gönderinin ilgili bölümlerini alıntılayarak yanıtınızın açıklayıcı olmasını sağlamalısınız.`;\n\t\tcase `Depending on the software (or its configuration) used to read your message, it may not be obvious which post you're replying to.`:\n\t\t\treturn `Mesajınızın alıcı tarafında okunduğu programa (veya onun ayarlarına) bağlı olarak, hangi gönderiyi yanıtladığınız belli olmayabilir.`;\n\t\tcase `Thus, when writing a reply, don't delete all quoted text: instead, leave just enough to provide context for your reply.`:\n\t\t\treturn `Bu nedenle, bir yanıt yazarken, alıntılanan tüm metni silmek yerine, yanıtınızın anlamlı olmasına yetecek kadar alıntı bırakın.`;\n\t\tcase `You can also insert your replies inline (interleaved with quoted text) to address specific parts of the parent post.`:\n\t\t\treturn `Gönderinin belirli bölümlerine yanıt vermek için yanıtlarınızı alıntılanmış metinle iç içe olarak da ekleyebilirsiniz.`;\n\t\tcase `You are quoting a post other than the parent.`:\n\t\t\treturn `Bir öncekinden farklı bir gönderiyi alıntılıyorsunuz.`;\n\t\tcase `When replying a message, the message you are replying to is referenced in the post's headers.`:\n\t\t\treturn `Yanıtladığınız mesaj yazının başlık alanlarında belirtilir.`;\n\t\tcase `Depending on the software (or its configuration) used to read your message, your message may be displayed below its parent post.`:\n\t\t\treturn `Mesajınızın alıcı tarafında okunduğu programa (veya onun ayarlarına) bağlı olarak, mesajınız yanıtlanmakta olan gönderinin altında görüntülenebilir.`;\n\t\tcase `If your message contains a reply to a different post, following the conversation may become somewhat confusing.`:\n\t\t\treturn `Mesajınız farklı bir gönderiye yanıt içerdiğinde konunun anlaşılırlığı güçleşebilir.`;\n\t\tcase `Thus, make sure to click the \"Reply\" link on the actual post you're replying to, and quote the parent post for context.`:\n\t\t\treturn `Bu nedenle, lütfen yanıtlamakta olduğunuz gönderinin \"Yanıtla\" bağlantısını tıkladığınızdan ve onu alıntıladığınızdan emin olun.`;\n\t\tcase `Parent post is not indicated.`:\n\t\t\treturn `Yanıtlanan gönderi belirtilmemiş.`;\n\t\tcase `When quoting someone's post, you should leave the \"On (date), (author) wrote:\" line.`:\n\t\t\treturn `Bir gönderiyi alıntılarken, lütfen yanıtlanan gönderinin tarih ve yazar bilgisini taşıyan satırı silmeyin.`;\n\t\tcase `Thus, this line provides important context for your replies regarding the structure of the conversation.`:\n\t\t\treturn `O bilgi, sohbetin anlaşılmasına yardımcı olur.`;\n\t\tcase `You are quoting multiple posts.`:\n\t\t\treturn `Birden fazla gönderiden alıntı yapıyorsunuz.`;\n\t\tcase `Thus, you should avoid replying to multiple posts in one reply.`:\n\t\t\treturn `Tek bir yanıtta birden fazla gönderiye yanıt vermekten kaçınmalısınız.`;\n\t\tcase `If applicable, you should split your message into several, each as a reply to its corresponding parent post.`:\n\t\t\treturn `Mümkünse, yanıtlanmakta olan her mesaj için farklı yanıt yazmalısınız.`;\n\t\tcase `You are top-posting.`:\n\t\t\treturn `Yanıtınızı asıl gönderinin üst tarafına yazıyorsunuz.`;\n\t\tcase `When replying a message, it is generally preferred to add your reply under the quoted parent text.`:\n\t\t\treturn `Genel tercih, yanıtınızı alıntılanan metnin altına yazmanızdır.`;\n\t\tcase `Depending on the software (or its configuration) used to read your message, your message may not be displayed below its parent post.`:\n\t\t\treturn `Mesajınızın alıcı tarafında okunduğu programa (veya onun ayarlarına) bağlı olarak, mesajınız yanıtlanmakta olan gönderinin altında görüntülenmiyor olabilir.`;\n\t\tcase `In such cases, readers would need to first read the quoted text below your reply for context.`:\n\t\t\treturn `Öyle ise, okuyucular önce yanıtınızın altındaki alıntıyı okumak zorunda kalırlar.`;\n\t\tcase `Thus, you should add your reply below the quoted text (or reply to individual paragraphs inline), rather than above it.`:\n\t\t\treturn `Bu nedenle, yanıtınızı alıntıladığınız metnin altına (veya birden fazla bölüm halinde iç içe) yazmalısınız.`;\n\t\tcase `You are overquoting.`:\n\t\t\treturn `Çok fazla alıntı yapmışsınız.`;\n\t\tcase `The ratio between quoted and added text is vastly disproportional.`:\n\t\t\treturn `Alıntılanan ve eklenen metin çok orantısız.`;\n\t\tcase `Quoting should be limited to the amount necessary to provide context for your replies.`:\n\t\t\treturn `Alıntı, yanıtınıza anlam katmaya yetecek kadar olmalıdır.`;\n\t\tcase `Quoting posts in their entirety is thus rarely necessary, and is a waste of vertical space.`:\n\t\t\treturn `Bu nedenle, metnin tamamını alıntılamak nadiren gereklidir ve alan israfıdır.`;\n\t\tcase `Please trim the quoted text to just the relevant parts you're addressing in your reply, or add more content to your post.`:\n\t\t\treturn `Lütfen alıntılanan metni yanıtınızda ele aldığınız kısımlarla ilgili olarak kısaltın veya gönderinize daha fazla içerik ekleyin.`;\n\t\tcase `Don't use URL shorteners.`:\n\t\t\treturn `URL kısaltıcıları kullanmayınız.`;\n\t\tcase `URL shortening services, such as TinyURL, are useful in cases where space is at a premium, e.g. in IRC or Twitter messages.`:\n\t\t\treturn `TinyURL gibi URL kısaltma hizmetleri, alanın kısıtlı olduğu IRC veya Twitter mesajları gibi ortamlarda yararlıdır.`;\n\t\tcase `In other circumstances, however, they provide little benefit, and have the significant disadvantage of being opaque:`:\n\t\t\treturn `Ancak diğer durumlarda çok az fayda sağlarlar ve bağlantının anlaşılırlığını düşürürler:`;\n\t\tcase `readers can only guess where the link will lead to before they click it.`:\n\t\t\treturn `okuyucular, tıklanan bağlantının nereye götüreceği konusunda tahminde bulunamazlar.`;\n\t\tcase `Additionally, URL shortening services come and go - your link may work today, but might not in a year or two.`:\n\t\t\treturn `Ek olarak, URL kısaltma hizmetleri kalıcı değillerdir - bugün işleyen bir bağlantı bir iki yıl içinde yitirilmiş olabilir.`;\n\t\tcase `Thus, do not use URL shorteners when posting messages online - post the full link instead, even if it seems exceedingly long.`:\n\t\t\treturn `Bu nedenle, gönderilerinizde URL kısaltmaları kullanmak yerine, aşırı uzun görünüyor olsa bile tam bağlantıyı verin.`;\n\t\tcase `If it is too long to be inserted inline, add it as a footnote instead.`:\n\t\t\treturn `Satır içi için fazla uzun olduğunu düşündüğünüzde mesajınıza dipnot olarak ekleyebilirsiniz.`;\n\t\tcase `Could not expand URL:`:\n\t\t\treturn `URL genişletilemedi:`;\n\t\tcase `Don't put links in the subject.`:\n\t\t\treturn `Konu satırına bağlantı koymayın.`;\n\t\tcase `Links in message subjects are usually not clickable.`:\n\t\t\treturn `Konu satırlarındaki bağlantılar genellikle tıklanabilir değildir.`;\n\t\tcase `Please move the link in the message body instead.`:\n\t\t\treturn `Lütfen bağlantıyı mesaj içine taşıyın.`;\n\t\tcase `Avoid replying to very old threads.`:\n\t\t\treturn `Çok eski konuları yanıtlamaktan kaçının.`;\n\t\tcase `The thread / post you are replying to is very old.`:\n\t\t\treturn `Yanıtladığınız konu veya gönderi çok eski.`;\n\t\tcase `Consider creating a new thread instead of replying to an existing one.`:\n\t\t\treturn `Mevcut konuyu yanıtlamak yerine yeni bir konu başlatmayı düşünün.`;\n\t\tcase `BlogSpam.net thinks your post looks like spam:`:\n\t\t\treturn `BlogSpam.net, gönderinizin spam gibi göründüğünü düşünüyor:`;\n\t\tcase `BlogSpam.net error:`:\n\t\t\treturn `BlogSpam.net hatası:`;\n\t\tcase `BlogSpam.net unexpected response:`:\n\t\t\treturn `BlogSpam.net beklenmeyen yanıt:`;\n\t\tcase `Perform which moderation actions on this post?`:\n\t\t\treturn `Hangi denetim işlemleri uygulansın?`;\n\t\tcase `Delete local cached copy of this post from DFeed's database`:\n\t\t\treturn `Bu gönderinin yerel kopyasını DFeed veritabanından sil`;\n\t\tcase `Ban poster (place future posts in moderation queue)`:\n\t\t\treturn `Göndericiyi yasakla (gelecekteki gönderileri de denetlensin)`;\n\t\tcase `Delete source copy from %-(%s/%)`:\n\t\t\treturn `%-(%s/%) kaynağındaki asıl kopyasını sil`;\n\t\tcase `Reason:`:\n\t\t\treturn `Nedeni:`;\n\t\tcase `It looks like you've already flagged this post.`:\n\t\t\treturn `Görünüşe göre bu gönderiyi zaten işaretlemişsiniz.`;\n\t\tcase `Would you like to %sunflag it%s?`:\n\t\t\treturn `%sİşaretini kaldırmak%s ister misiniz?`;\n\t\tcase `Are you sure you want to flag this post?`:\n\t\t\treturn `Bu gönderiyi işaretlemek istediğinizden emin misiniz?`;\n\t\tcase `You can't flag posts!`:\n\t\t\treturn `Gönderileri işaretleyemezsiniz!`;\n\t\tcase `You can't flag this post!`:\n\t\t\treturn `Bu gönderiyi işaretleyemezsiniz!`;\n\t\tcase `You've already flagged this post.`:\n\t\t\treturn `Bu gönderiyi zaten işaretlediniz.`;\n\t\tcase `Post flagged.`:\n\t\t\treturn `Gönderi işaretlendi.`;\n\t\tcase `Return to post`:\n\t\t\treturn `Gönderiye dön`;\n\t\tcase `It looks like you've already unflagged this post.`:\n\t\t\treturn `Görünüşe göre bu gönderinin işaretini zaten kaldırmışsınız.`;\n\t\tcase `Would you like to %sflag it%s?`:\n\t\t\treturn `%sİşaretlemek%s ister misiniz?`;\n\t\tcase `Are you sure you want to unflag this post?`:\n\t\t\treturn `Bu gönderinin işaretini kaldırmak istediğinizden emin misiniz?`;\n\t\tcase `Unflag`:\n\t\t\treturn `İşareti kaldır`;\n\t\tcase `You've already unflagged this post.`:\n\t\t\treturn `Bu gönderinin işaretini zaten kaldırdınız.`;\n\t\tcase `Post unflagged.`:\n\t\t\treturn `İşaret kaldırıldı.`;\n\t\tcase `You can view it here.`:\n\t\t\treturn `Buradan görüntüleyebilirsiniz.`;\n\t\tcase `This is not a post in need of moderation. Its status is currently:`:\n\t\t\treturn `Bu, denetlenmesi gereken bir gönderi değil. Şu andaki durumu:`;\n\t\tcase `Are you sure you want to approve this post?`:\n\t\t\treturn `Bu gönderiyi onaylamak istediğinizden emin misiniz?`;\n\t\tcase `Approve`:\n\t\t\treturn `Onayla`;\n\t\tcase `Post approved!`:\n\t\t\treturn `Gönderi onaylandı!`;\n\t\tcase `View posting`:\n\t\t\treturn `Gönderiyi görüntüle`;\n\t\tcase `Toggle navigation`:\n\t\t\treturn `Gezinti çubuğunu aç / kapa`;\n\t\tcase `Loading message`:\n\t\t\treturn `Mesaj yükleniyor`;\n\t\tcase `Your browser does not support HTML5 pushState.`:\n\t\t\treturn `Tarayıcınızda 'HTML5 pushState' olanağı yok.`;\n\t\tcase `Keyboard shortcuts`:\n\t\t\treturn `Klavye kısayolları`;\n\t\tcase `Ctrl`:\n\t\t\treturn `Ctrl`;\n\t\tcase `Down Arrow`:\n\t\t\treturn `Aşağı Ok`;\n\t\tcase `Select next message`:\n\t\t\treturn `Sonraki mesajı seç`;\n\t\tcase `Up Arrow`:\n\t\t\treturn `Yukarı Ok`;\n\t\tcase `Select previous message`:\n\t\t\treturn `Önceki mesajı seç`;\n\t\tcase `Enter / Return`:\n\t\t\treturn `Enter / Return`;\n\t\tcase `Open selected message`:\n\t\t\treturn `Seçili mesajı aç`;\n\t\tcase `Mark as unread`:\n\t\t\treturn `Okunmamış olarak işaretle`;\n\t\tcase `Open link`:\n\t\t\treturn `Bağlantı aç`;\n\t\tcase `Space Bar`:\n\t\t\treturn `Boşluk Tuşu`;\n\t\tcase `Scroll message / Open next unread message`:\n\t\t\treturn `Mesajı ilerlet / Bir sonraki okunmamış mesajı aç`;\n\t\tcase `(press any key or click to close)`:\n\t\t\treturn `(kapatmak için bir tuşa basın veya tıklayın)`;\n\t\tcase `Draft saved.`:\n\t\t\treturn `Taslak kaydedildi.`;\n\t\tcase `Error auto-saving draft.`:\n\t\t\treturn `Taslağı kaydederken hata oluştu.`;\n\t\tcase `The CAPTCHA solution was incorrect`:\n\t\t\treturn `CAPTCHA çözümü doğru değil`;\n\t\tcase `The solution was received after the CAPTCHA timed out`:\n\t\t\treturn `Çözüm, CAPTCHA zaman aşımına uğradıktan sonra alındı`;\n\t\tcase `s`:\n\t\t\treturn `sn`;\n\t\tcase `m`:\n\t\t\treturn `dk`;\n\t\tcase `h`:\n\t\t\treturn `sa`;\n\t\tcase `d`:\n\t\t\treturn `g`;\n\t\tcase `HTML-like text was discarded.`:\n\t\t\treturn `HTML benzeri metin atıldı.`;\n\t\tcase `Your message seems to contain content which the Markdown renderer has interpreted as raw HTML.`:\n\t\t\treturn `Mesajınız, Markdown yorumlayıcısı tarafından ham HTML olarak yorumlanan içerik içeriyor gibi görünüyor.`;\n\t\tcase `Since using raw HTML is not allowed, this content has been discarded from the rendered output.`:\n\t\t\treturn `Ham HTML kullanımına izin verilmediğinden, bu içerik oluşturulan çıktıdan atılmıştır.`;\n\t\tcase `If your intention was to use HTML for formatting, please revise your message to use the %savailable Markdown formatting syntax%s instead.`:\n\t\t\treturn `Biçimlendirme için HTML kullanmak istediyseniz, lütfen mesajınızı %sMarkdown sözdizimi%s ile yeniden yazın.`;\n\t\tcase \"If your intention was to use characters such as &gt; &lt; &amp; verbatim in your message, you can prevent them from being interpreted as special characters by escaping them with a backslash character (\\\\).\":\n\t\t\treturn \"Mesajınızda &gt; &lt; &amp; gibi karakterleri aynen kullanmak istediyseniz, bunları ters eğik çizgi karakteriyle (\\\\) kaçırarak özel karakter olarak yorumlanmalarını engelleyebilirsiniz.\";\n\t\tcase `Clicking \"Fix it for me\" will apply this escaping automatically.`:\n\t\t\treturn `\"Benim için düzelt\" üzerine tıklamak bu kaçırma işlemini otomatik olarak uygular.`;\n\t\tcase `Finally, if you do not want any special characters to be treated as formatting at all, you may uncheck the \"Enable Markdown\" checkbox to disable Markdown rendering completely.`:\n\t\t\treturn `Son olarak, hiçbir özel karakterin biçimlendirme olarak ele alınmasını istemiyorsanız, Markdown işlemeyi tamamen devre dışı bırakmak için \"Markdown'ı Etkinleştir\" onay kutusunun işaretini kaldırabilirsiniz.`;\n\t\tcase `Avoid using HTML entities.`:\n\t\t\treturn `HTML varlıklarını kullanmaktan kaçının.`;\n\t\tcase `HTML character entities, such as \"&amp;mdash;\", are rendered to the corresponding character when using Markdown, but will still appear as you typed them to users of software where Markdown rendering is unavailable or disabled.`:\n\t\t\treturn `\"&amp;mdash;\" gibi HTML karakter varlıkları, Markdown kullanılırken karşılık gelen karaktere dönüştürülür, ancak Markdown işlemenin kullanılamadığı veya devre dışı bırakıldığı yazılımların kullanıcılarına yazdığınız gibi görünmeye devam eder.`;\n\t\tcase `As such, it is preferable to use the Unicode characters directly instead of their HTML entity encoded form (e.g. \"—\" instead of \"&amp;mdash;\").`:\n\t\t\treturn `Bu nedenle, Unicode karakterlerini HTML varlık kodlu biçimleri yerine doğrudan kullanmak tercih edilir (örn. \"&amp;mdash;\" yerine \"—\").`;\n\t\tcase `If you did not mean to use an HTML entity to represent a character, escape the leading ampersand (&amp;) by prepending a backslash (e.g. \"\\&\").`:\n\t\t\treturn `Bir karakteri temsil etmek için bir HTML varlığı kullanmak istemediyseniz, başındaki ve işaretini (&amp;) ters eğik çizgi ekleyerek kaçırın (örn. \"\\&\").`;\n\t\tcase `A code block may be misformatted.`:\n\t\t\treturn `Bir kod bloğu yanlış biçimlendirilmiş olabilir.`;\n\t\tcase `It looks like your post may include a code block, but it is not formatted as such. (Click \"Save and preview\" to see how your message will look once posted.)`:\n\t\t\treturn `Gönderiniz bir kod bloğu içeriyor gibi görünüyor, ancak öyle biçimlendirilmemiş. (Gönderildikten sonra mesajınızın nasıl görüneceğini görmek için \"Kaydet ve önizle\" üzerine tıklayın.)`;\n\t\tcase \"When using %sMarkdown formatting%s, you should either wrap code blocks in fences (<code>```</code> lines), or indent all lines by four spaces.\":\n\t\t\treturn \"%sMarkdown biçimlendirmesi%s kullanırken, kod bloklarını çitlerle (<code>```</code> satırları) sarmalı veya tüm satırları dört boşlukla girintilemelisisiniz.\";\n\t\tcase `Click \"Fix it for me\" to have the forum software attempt to do this automatically.`:\n\t\t\treturn `Otomatik düzeltme için \"Benim için düzelt\" üzerine tıklayın.`;\n\t\tcase `Alternatively, you may uncheck the \"Enable Markdown\" checkbox to disable Markdown rendering completely, which will cause whitespace to be rendered verbatim.`:\n\t\t\treturn `Alternatif olarak, Markdown işlemeyi tamamen devre dışı bırakmak için \"Markdown'ı Etkinleştir\" onay kutusunun işaretini kaldırabilirsiniz; bu, boşlukların aynen işlenmesine neden olur.`;\n\t\tcase `Markdown syntax was used, but Markdown is disabled.`:\n\t\t\treturn `Markdown sözdizimi kullanıldı, ancak Markdown devre dışı bırakıldı.`;\n\t\tcase `It looks like your post may include Markdown syntax, but %sMarkdown%s is not enabled. (Click \"Save and preview\" to see how your message will look once posted.)`:\n\t\t\treturn `Gönderiniz Markdown sözdizimi içeriyor gibi görünüyor, ancak %sMarkdown%s etkin değil. (Gönderildikten sonra mesajınızın nasıl görüneceğini görmek için \"Kaydet ve önizle\" üzerine tıklayın.)`;\n\t\tcase `Click \"Fix it for me\" to enable Markdown rendering automatically.`:\n\t\t\treturn `Markdown işlemeyi otomatik olarak etkinleştirmek için \"Benim için düzelt\" üzerine tıklayın.`;\n\t\tcase `Failed to render Markdown:`:\n\t\t\treturn `Markdown işlenemedi:`;\n\t\tcase `Enable %sMarkdown%s`:\n\t\t\treturn `%sMarkdown%s'ı etkinleştir`;\n\t\tcase `No post form submitted. Please click \"Back\" in your web browser to navigate back to the posting form, and resubmit it.`:\n\t\t\treturn `Hiçbir gönderi formu gönderilmedi. Lütfen gönderi formuna geri dönmek için web tarayıcınızda \"Geri\" düğmesini tıklayın ve yeniden gönderin.`;\n\t\tcase `Unban by key`:\n\t\t\treturn `Anahtara göre yasağı kaldır`;\n\t\tcase `Try to moderate in other message sinks (e.g. Twitter)`:\n\t\t\treturn `Diğer mesaj havuzlarında denetlemeyi deneyin (örn. Twitter)`;\n\t\tcase `The specified key is not banned.`:\n\t\t\treturn `Belirtilen anahtar yasaklanmamış.`;\n\t\tcase `Key to unban:`:\n\t\t\treturn `Yasağı kaldırılacak anahtar:`;\n\t\tcase `Look up`:\n\t\t\treturn `Ara`;\n\t\tcase `Select which keys to unban:`:\n\t\t\treturn `Yasağı kaldırılacak anahtarları seçin:`;\n\t\tcase `Unban Selected`:\n\t\t\treturn `Seçilenlerin Yasağını Kaldır`;\n\t\tcase `No keys selected to unban`:\n\t\t\treturn `Yasağı kaldırılacak hiçbir anahtar seçilmedi`;\n\t\tcase `Unbanned %d key(s)!`:\n\t\t\treturn `%d anahtarın yasağı kaldırıldı!`;\n\t\tcase `Unban another key`:\n\t\t\treturn `Başka bir anahtarın yasağını kaldır`;\n\t\tcase `Render Markdown posts as HTML. If disabled, they will just be shown as-is, in plain text.`:\n\t\t\treturn `Markdown gönderilerini HTML olarak işleyin. Devre dışı bırakılırsa, düz metin olarak olduğu gibi gösterilecektir.`;\n\t\tcase `Render Markdown`:\n\t\t\treturn `Markdown İşle`;\n\t\tcase `I am not a robot`:\n\t\t\treturn `Ben robot değilim`;\n\t\tcase `Please confirm you are not a robot`:\n\t\t\treturn `Lütfen robot olmadığınızı onaylayın`;\n\t\tcase `Your subject contains a keyword that triggers moderation`:\n\t\t\treturn `Konu başlığınız denetimi tetikleyen bir anahtar kelime içeriyor`;\n\t\tcase `%s's profile`:\n\t\t\treturn `%s kullanıcısının profili`;\n\t\tcase `No user specified`:\n\t\t\treturn `Kullanıcı belirtilmedi`;\n\t\tcase `Users`:\n\t\t\treturn `Kullanıcılar`;\n\t\tcase `User not found`:\n\t\t\treturn `Kullanıcı bulunamadı`;\n\t\tcase `Subscribe to user`:\n\t\t\treturn `Kullanıcıya abone ol`;\n\t\tcase `View Gravatar profile`:\n\t\t\treturn `Gravatar profilini görüntüle`;\n\t\tcase `Gravatar profile`:\n\t\t\treturn `Gravatar profili`;\n\t\tcase `Subscribe to this user's posts`:\n\t\t\treturn `Bu kullanıcının gönderilerine abone ol`;\n\t\tcase `Posts:`:\n\t\t\treturn `Gönderi:`;\n\t\tcase `Threads started:`:\n\t\t\treturn `Başlattığı konular:`;\n\t\tcase `Replies:`:\n\t\t\treturn `Yanıtlar:`;\n\t\tcase `First post:`:\n\t\t\treturn `İlk gönderi:`;\n\t\tcase `Last seen:`:\n\t\t\treturn `Son görülme:`;\n\t\tcase `Most active in:`:\n\t\t\treturn `En aktif olduğu grup:`;\n\t\tcase `Recent posts`:\n\t\t\treturn `Son gönderiler`;\n\t\tcase `Subject`:\n\t\t\treturn `Konu`;\n\t\tcase `View all %d posts`:\n\t\t\treturn `Tüm %d gönderiyi görüntüle`;\n\t\tcase `See also`:\n\t\t\treturn `Ayrıca bakınız`;\n\t\tcase `User`:\n\t\t\treturn `Kullanıcı`;\n\t\tcase `Last seen`:\n\t\t\treturn `Son görülme`;\n\t\tcase `%d posts`:\n\t\t\treturn `%d gönderi`;\n\t\tcase `Announcements widget not configured`:\n\t\t\treturn `Duyurular bileşeni yapılandırılmamış`;\n\t\tdefault:\n\t\t\treturn null;\n\t}\n}\n\nprivate string pluralOf(string unit)\n{\n\tswitch (unit)\n\t{\n\t\tcase \"second\":\n      return \"saniye\";\n\n\t\tcase \"minute\":\n      return \"dakika\";\n\n\t\tcase \"hour\":\n      return \"saat\";\n\n\t\tcase \"day\":\n      return \"gün\";\n\n\t\tcase \"week\":\n      return \"hafta\";\n\n\t\tcase \"month\":\n      return \"ay\";\n\n\t\tcase \"year\":\n      return \"yıl\";\n\n\t\tcase \"thread\":\n      return \"konu\";\n\n\t\tcase \"post\":\n      return \"gönderi\";\n\n\t\tcase \"forum post\":\n      return \"forum gönderisi\";\n\n\t\tcase \"subscription\":\n      // This seems to be used only in the `No new posts matching your %s%s%s.` string where it happens to be the first\n      // word in Turkish, so we use it capitalized for now.\n      return \"Abonelikleriniz\";\n\n\t\tcase \"unread post\":\n      return \"okunmamış gönderi\";\n\n\t\tcase \"registered user\":\n      return \"kayıtlı kullanıcı\";\n\n\t\tcase \"visit\":\n      // This seems to be used only in the `You have read a total of %s %s during your %s.` string where it happens to\n      // be the first word in Turkish, so we use it capitalized for now.\n      return \"Ziyaretiniz\";\n\n\t\tcase \"reply\":\n      return \"yanıt\";\n\n\t\tcase \"new reply\":\n      return \"yeni yanıt\";\n\n\t\tcase \"user has created\":\n      return \"kullanıcı tarafından\";\n\n\t\tdefault:\n\t\t\tassert(false, \"Unknown unit: \" ~ unit);\n\t}\n}\n\nstring plural(string unit)(long amount)\n{\n\t// There are no plural forms of nouns in Turkish.\n\treturn pluralOf(unit);\n}\n\nconst WeekdayShortNames = [\"Paz\", \"Pzt\", \"Sal\", \"Çar\", \"Per\", \"Cum\", \"Cmt\"];\nconst WeekdayLongNames = [\"Pazar\", \"Pazartesi\", \"Salı\", \"Çarşamba\", \"Perşembe\", \"Cuma\", \"Cumartesi\"];\nconst MonthShortNames = [\"Oca\", \"Şub\", \"Mar\", \"Nis\", \"May\", \"Haz\", \"Tem\", \"Ağu\", \"Eyl\", \"Eki\", \"Kas\", \"Ara\"];\nconst MonthLongNames = [\"Ocak\", \"Şubat\", \"Mart\", \"Nisan\", \"Mayıs\", \"Haziran\", \"Temmuz\", \"Ağustos\", \"Eylül\", \"Ekim\", \"Kasım\", \"Aralık\"];\n"
  },
  {
    "path": "src/dfeed/mail.d",
    "content": "/*  Copyright (C) 2015, 2017, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.mail;\n\nimport std.exception;\nimport std.format;\nimport std.process;\n\nimport dfeed.site;\n\n/// Send a fully-formatted (incl. headers) message by email.\nvoid sendMail(string message)\n{\n\tauto pipes = pipeProcess([\"sendmail\",\n\t\t\t\"-t\",\n\t\t\t\"-r\", \"%s <no-reply@%s>\".format(site.name.length ? site.name : site.host, site.host),\n\t\t], Redirect.stdin);\n\tpipes.stdin.rawWrite(message);\n\tpipes.stdin.close();\n\tenforce(wait(pipes.pid) == 0, \"mail program failed\");\n}\n"
  },
  {
    "path": "src/dfeed/message.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2018, 2020, 2021, 2024  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.message;\n\nimport std.algorithm;\nimport std.array;\nimport std.conv;\nimport std.exception;\nimport std.range;\nimport std.string;\n\npublic import ae.net.ietf.message;\nimport ae.net.ietf.headers : decodeTokenHeader;\nimport ae.net.ietf.url;\nimport ae.net.ietf.wrap;\nimport ae.utils.array;\nimport ae.utils.meta;\n\nimport dfeed.bitly;\nimport dfeed.common;\nimport dfeed.groups;\nimport dfeed.site;\n\nalias std.string.indexOf indexOf;\n\nclass Rfc850Post : Post\n{\n\t/// Internet message.\n\tRfc850Message msg;\n\talias msg this;\n\n\t/// Internal database index\n\tint rowid;\n\n\t/// Thread ID obtained by examining parent posts\n\tstring cachedThreadID;\n\n\t/// URLs for IRC.\n\tstring url, shortURL;\n\n\t/// For IRC.\n\tstring verb;\n\n\t/// Result of threadify()\n\tRfc850Post[] children;\n\n\t/// If no, don't announce this message and don't trigger subscriptions\n\tFresh fresh = Fresh.yes;\n\n\tthis(string _message, string _id=null, int rowid=0, string threadID=null)\n\t{\n\t\tmsg = new Rfc850Message(_message);\n\t\tif (!msg.id && _id)\n\t\t\tmsg.id = _id;\n\t\tthis.rowid = rowid;\n\t\tthis.cachedThreadID = threadID;\n\n\t\tthis.verb = reply ? \"replied to\" : \"posted\";\n\n\t\tint bugzillaCommentNumber;\n\t\tif (\"X-Bugzilla-Who\" in headers)\n\t\t{\n\t\t\t// Special case for Bugzilla emails\n\t\t\tauthor = authorEmail = headers[\"X-Bugzilla-Who\"];\n\n\t\t\tforeach (line; content.split(\"\\n\"))\n\t\t\t\tif (line.endsWith(\"> changed:\"))\n\t\t\t\t\tauthor = line[0..line.indexOf(\" <\")];\n\t\t\t\telse\n\t\t\t\tif (line.startsWith(\"--- Comment #\") && line.indexOf(\" from \")>0 && line.indexOf(\" <\")>0 && line.endsWith(\" ---\"))\n\t\t\t\t{\n\t\t\t\t\tauthor = line[line.indexOf(\" from \")+6 .. line.indexOf(\" <\")];\n\t\t\t\t\tbugzillaCommentNumber = to!int(line[\"--- Comment #\".length .. line.indexOf(\" from \")]);\n\t\t\t\t}\n\t\t}\n\t\telse\n\t\tif (\"List-Id\" in headers)\n\t\t{\n\t\t\tauto list = headers[\"List-Id\"];\n\t\t\tauto listId = list.findSplit(\" <\")[2].findSplit(\".puremagic.com>\")[0];\n\t\t\tauto suffix = \" via \" ~ listId.toLower();\n\t\t\tif (listId.length && author.toLower().endsWith(suffix))\n\t\t\t\tauthor = author[0 .. $ - suffix.length];\n\t\t}\n\n\t\tif (\"X-DFeed-List\" in headers && !xref.length)\n\t\t\txref = [Xref(headers[\"X-DFeed-List\"])];\n\n\t\t// Fallback for local posts that have Newsgroups header but no Xref\n\t\tif (\"Newsgroups\" in headers && !xref.length)\n\t\t\tforeach (group; headers[\"Newsgroups\"].split(\",\"))\n\t\t\t\txref ~= Xref(group.strip());\n\n\t\tif (\"List-ID\" in headers && subject.startsWith(\"[\") && xref.length == 1)\n\t\t{\n\t\t\tauto p = subject.indexOf(\"] \");\n\t\t\tif (p >= 0 && !icmp(subject[1..p], xref[0].group))\n\t\t\t\tsubject = subject[p+2..$];\n\t\t}\n\n\t\tif (subject.startsWith(\"[Issue \"))\n\t\t{\n\t\t\tauto urlBase = headers.get(\"X-Bugzilla-URL\", \"http://d.puremagic.com/issues/\");\n\t\t\turl = urlBase ~ \"show_bug.cgi?id=\" ~ subject.split(\" \")[1][0..$-1];\n\t\t\tverb = bugzillaCommentNumber ? \"commented on\" : reply ? \"updated\" : \"created\";\n\t\t\tif (bugzillaCommentNumber > 0)\n\t\t\t\turl ~= \"#c\" ~ .text(bugzillaCommentNumber);\n\t\t}\n\t\telse\n\t\tif (id.length)\n\t\t{\n\t\t\turl = format(\"%s://%s%s\", site.proto, site.host, idToUrl(id));\n\t\t\tif (!doArchive)\n\t\t\t\turl = null;\n\t\t}\n\n\t\tsuper.time = msg.time;\n\n\t\tif (\"X-Original-Date\" in headers)\n\t\t\tfresh = Fresh.no;\n\t}\n\n\tprivate this(Rfc850Message msg) { this.msg = msg; }\n\n\tstatic Rfc850Post newPostTemplate(string groups) { return new Rfc850Post(Rfc850Message.newPostTemplate(groups)); }\n\tRfc850Post replyTemplate() { return new Rfc850Post(msg.replyTemplate()); }\n\n\t/// Set headers and message.\n\tvoid compile()\n\t{\n\t\tmsg.compile();\n\t\theaders[\"User-Agent\"] = \"DFeed\";\n\t}\n\n\toverride void formatForIRC(void delegate(string) handler)\n\t{\n\t\tif (getImportance() >= Importance.normal && url && !shortURL)\n\t\t\treturn shortenURL(url, (string shortenedURL) {\n\t\t\t\tshortURL = shortenedURL;\n\t\t\t\tformatForIRC(handler);\n\t\t\t});\n\n\t\thandler(format(\"%s%s %s %s%s\",\n\t\t\txref.length ? \"[\" ~ publicGroupNames.join(\",\") ~ \"] \" : null,\n\t\t\tauthor == \"\" ? \"<no name>\" : filterIRCName(author),\n\t\t\tverb,\n\t\t\tsubject == \"\" ? \"<no subject>\" : `\"` ~ subject ~ `\"`,\n\t\t\tshortURL ? \": \" ~ shortURL : url ? \": \" ~ url : \"\",\n\t\t));\n\t}\n\n\toverride Importance getImportance()\n\t{\n\t\tif (!fresh)\n\t\t\treturn Importance.none;\n\n\t\tif (msg.headers.get(\"X-List-Administrivia\", \"\").icmp(\"yes\") == 0)\n\t\t\treturn Importance.none;\n\n\t\tauto group = getGroup(this);\n\t\tif (!reply && group && group.announce)\n\t\t\treturn Importance.high;\n\n\t\t// GitHub notifications are already grabbed from RSS\n\t\tif (author == \"GitHub\")\n\t\t\treturn Importance.low;\n\n\t\tif (where == \"\")\n\t\t\treturn Importance.low;\n\n\t\tif (where.isIn(ANNOUNCE_REPLIES))\n\t\t\treturn Importance.normal;\n\n\t\treturn !reply || author.isIn(VIPs) ? Importance.normal : Importance.low;\n\t}\n\n\t@property final bool doArchive()\n\t{\n\t\treturn msg.headers.get(\"X-No-Archive\", \"\").icmp(\"yes\") != 0;\n\t}\n\n\t@property string[] publicGroupNames()\n\t{\n\t\treturn xref.map!(x => x.group.getGroupInfo.I!(gi => gi ? gi.publicName : x.group)).array();\n\t}\n\n\t@property string where()\n\t{\n\t\tstring[] groups;\n\t\tforeach (x; xref)\n\t\t\tgroups ~= x.group;\n\t\treturn groups.join(\",\");\n\t}\n\n\t/// Arrange a bunch of posts in a thread hierarchy. Returns the root posts.\n\tstatic Rfc850Post[] threadify(Rfc850Post[] posts)\n\t{\n\t\tRfc850Post[string] postLookup;\n\t\tforeach (post; posts)\n\t\t{\n\t\t\tpost.children = null;\n\t\t\tpostLookup[post.id] = post;\n\t\t}\n\n\t\tRfc850Post[] roots;\n\t\tpostLoop:\n\t\tforeach (post; posts)\n\t\t{\n\t\t\tforeach_reverse(reference; post.references)\n\t\t\t{\n\t\t\t\tauto pparent = reference in postLookup;\n\t\t\t\tif (pparent)\n\t\t\t\t{\n\t\t\t\t\t(*pparent).children ~= post;\n\t\t\t\t\tcontinue postLoop;\n\t\t\t\t}\n\t\t\t}\n\t\t\troots ~= post;\n\t\t}\n\t\treturn roots;\n\t}\n\n\t/// Get content excluding quoted text.\n\t@property string newContent()\n\t{\n\t\tauto paragraphs = content.unwrapText(wrapFormat);\n\t\tauto index = paragraphs.length.iota.filter!(i =>\n\t\t\t!paragraphs[i].quotePrefix.length && (i+1 >= paragraphs.length || !paragraphs[i+1].quotePrefix.length)\n\t\t).array;\n\t\treturn paragraphs.indexed(index).map!(p => p.text).join(\"\\n\");\n\t}\n\n\t/// Return configured CAPTCHA method.\n\t@property string captcha()\n\t{\n\t\tauto groups = xref.map!(x => x.group.getGroupInfo());\n\t\tenforce(groups.length, \"No groups\");\n\t\tauto group = groups.front;\n\t\tauto captchas = groups.map!(group => group.captcha);\n\t\tenforce(captchas.uniq.walkLength == 1, \"Conflicting CAPTCHA methods\");\n\t\treturn captchas.front;\n\t}\n\nprivate:\n\tstring[] ANNOUNCE_REPLIES = [];\n\tstring[] VIPs = [\"Walter Bright\", \"Andrei Alexandrescu\", \"Sean Kelly\", \"Don\", \"dsimcha\"];\n}\n\nunittest\n{\n\tauto post = new Rfc850Post(\"From: msonke at example.org (=?ISO-8859-1?Q?S=F6nke_Martin?=)\\n\\nText\");\n\tassert(post.author == \"Sönke Martin\");\n\tassert(post.authorEmail == \"msonke@example.org\");\n\n\tpost = new Rfc850Post(\"Date: Tue, 06 Sep 2011 14:52 -0700\\n\\nText\");\n\tassert(post.time.year == 2011);\n}\n\n// ***************************************************************************\n\ntemplate urlEncode(string forbidden, char escape = '%')\n{\n\talias encoder = encodeUrlPart!(c => c >= 0x20 && c < 0x7F && forbidden.indexOf(c) < 0 && c != escape, escape);\n\tstring urlEncode(string s)\n\t{\n\t\t//  !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~\n\t\t// \" !\\\"#$%&'()*+,-./:;<=>?@[\\\\]^_`{|}~\"\n\t\treturn encoder(s);\n\t}\n}\n\nstring urlDecode(string encoded)\n{\n\treturn decodeUrlParameter!false(encoded);\n}\n\n/// Encode a string to one suitable for an HTML anchor\nstring encodeAnchor(string s)\n{\n\t//return encodeUrlParameter(s).replace(\"%\", \".\");\n\t// RFC 3986: \" \\\"#%<>[\\\\]^`{|}\"\n\treturn urlEncode!(\" !\\\"#$%&'()*+,/;<=>?@[\\\\]^`{|}~\", ':')(s);\n}\n\nalias urlEncodeMessageUrl = urlEncode!(\" \\\"#%/<>?[\\\\]^`{|}\", '%');\n\n/// Get relative URL to a post ID.\nstring idToUrl(string id, string action = \"post\", int page = 1)\n{\n\tenforce(id.startsWith('<') && id.endsWith('>'), \"Invalid message ID: \" ~ id);\n\n\t// RFC 3986:\n\t// pchar         = unreserved / pct-encoded / sub-delims / \":\" / \"@\"\n\t// sub-delims    = \"!\" / \"$\" / \"&\" / \"'\" / \"(\" / \")\" / \"*\" / \"+\" / \",\" / \";\" / \"=\"\n\t// unreserved    = ALPHA / DIGIT / \"-\" / \".\" / \"_\" / \"~\"\n\tstring path = \"/\" ~ action ~ \"/\" ~ urlEncodeMessageUrl(id[1..$-1]);\n\n\tassert(page >= 1);\n\tif (page > 1)\n\t\tpath ~= \"?page=\" ~ text(page);\n\n\treturn path;\n}\n\n/// Get URL fragment / anchor name for a post on the same page.\nstring idToFragment(string id)\n{\n\tenforce(id.startsWith('<') && id.endsWith('>'), \"Invalid message ID: \" ~ id);\n\treturn \"post-\" ~ encodeAnchor(id[1..$-1]);\n}\n\nGroupInfo getGroup(Rfc850Post post)\n{\n\tenforce(post.xref.length, \"No groups found in post\");\n\treturn getGroupInfo(post.xref[0].group);\n}\n\nbool isMarkdown(Rfc850Message post)\n{\n\tauto contentType = decodeTokenHeader(post.headers.get(\"Content-Type\", null));\n\treturn contentType.value == \"text/plain\" &&\n\t\tcontentType.properties.get(\"markup\", null) == \"markdown\";\n}\n\n"
  },
  {
    "path": "src/dfeed/paths.d",
    "content": "/*  Copyright (C) 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Path resolution with site overlay support.\n///\n/// Files are searched in order:\n///   1. site/     (site-specific, highest priority)\n///   2. defaults/ (generic fallback)\n///\n/// This allows site-specific configuration and branding to override\n/// generic defaults without modifying the core DFeed repository.\nmodule dfeed.paths;\n\nimport std.file : exists;\nimport std.path : buildPath, dirSeparator;\nimport std.typecons : tuple;\n\nimmutable string[] siteSearchPaths = [\"site\", \"site-defaults\"];\n\n/// Resolve the location of a site file through the overlay.\n/// Path is relative to site root, e.g. \"config/site.ini\" or \"web/skel.htt\".\n/// Returns the first overlay where the file is found,\n/// or null if the file is not found in any overlay.\nstring resolveSiteFileBase(string relativePath)\n{\n    foreach (base; siteSearchPaths)\n    {\n        auto fullPath = buildPath(base, relativePath);\n        if (exists(fullPath))\n            return base ~ dirSeparator;\n    }\n\n    return null;\n}\n\n/// Resolve a site file through the overlay.\n/// Path is relative to site root, e.g. \"config/site.ini\" or \"web/skel.htt\"\nstring resolveSiteFile(string relativePath)\n{\n    auto base = resolveSiteFileBase(relativePath);\n    if (!base)\n    {\n        // Return first path for error messages (file doesn't exist anywhere)\n        base = siteSearchPaths[0];\n    }\n    return buildPath(base, relativePath);\n}\n\n/// Resolve the location of a static file through the overlay.\n/// Returns the base directory for serving `relativePath`,\n/// or null if the file doesn't exist anywhere.\n/// `relativePath` is expected to start with `/` (root-relative web path).\nauto resolveStaticFileBase(string webPath)\n{\n    import std.algorithm.searching : skipOver;\n    import std.exception : enforce;\n\n    // Convert web path to relative path\n    auto relativePath = webPath;\n    relativePath.skipOver(\"/\")\n        .enforce(\"Web path must start with /\");\n    relativePath = buildPath(\"web\", \"static\", relativePath);\n\n    auto base = resolveSiteFileBase(relativePath);\n    if (!base)\n        return null;\n    return base.buildPath(\"web\", \"static\") ~ dirSeparator;\n}\n"
  },
  {
    "path": "src/dfeed/progs/dfeed.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2021, 2023  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.progs.dfeed;\n\nimport std.getopt;\nimport std.stdio : stderr;\n\nimport ae.net.asockets;\nimport ae.net.ssl.openssl;\nimport ae.utils.meta;\nimport ae.utils.sini;\n\nimport dfeed.backup;\nimport dfeed.common;\nimport dfeed.debugging;\nimport dfeed.web.web.server;\n\n// Sources\nimport dfeed.sources.github;\nimport dfeed.sources.mailman;\nimport dfeed.sources.mailrelay;\nimport dfeed.sources.newsgroups;\nimport dfeed.sources.socket;\nimport dfeed.sources.web.feed;\nimport dfeed.sources.web.reddit;\nimport dfeed.sources.web.stackoverflow;\n\n// Sinks\nimport dfeed.sinks.irc;\nimport dfeed.sinks.messagedb;\nimport dfeed.sinks.subscriptions;\nimport dfeed.sinks.twitter;\nimport dfeed.web.posting;\n\nbool noDownload;\n\nvoid main(string[] args)\n{\n\tbool refresh;\n\tbool noSources;\n\tgetopt(args,\n\t\t\"q|quiet\", {}, // handled by ae.sys.log\n\t\t\"refresh\", &refresh,\n\t\t\"no-sources\", &noSources,\n\t\t\"no-download\", &noDownload,\n\t);\n\n\t// Create sources\n\tif (!noSources)\n\t{\n\t\tcreateServices!NntpSource   (\"sources/nntp\");\n\t\tcreateServices!MailRelay    (\"sources/mailrelay\");\n\t\tcreateServices!Feed         (\"sources/feeds\");\n\t\tcreateServices!StackOverflow(\"sources/stackoverflow\");\n\t\tcreateServices!Reddit       (\"sources/reddit\");\n\t\tcreateServices!SocketSource (\"sources/socket\");\n\t\tcreateServices!GitHub       (\"sources/github\");\n\t\tif (!noDownload)\n\t\t\tcreateServices!Mailman      (\"sources/mailman\");\n\t}\n\tif (refresh)\n\t\tnew MessageDBSource();\n\n\t// Create sinks\n\tcreateServices!IrcSink(\"sinks/irc\");\n\tnew MessageDBSink(refresh ? Yes.update : No.update);\n\tnew PostingNotifySink();\n\tnew SubscriptionSink();\n\tcreateServices!TwitterSink(\"sinks/twitter\");\n\n\t// Start web server\n\tstartWebUI();\n\n\tstartNewsSources();\n\n\tsocketManager.loop();\n\n\tif (!dfeed.common.quiet)\n\t\tstderr.writeln(\"Exiting.\");\n}\n\n/// Avoid any problems (bugs or missed messages) caused by downloader/listener running\n/// simultaneously or sequentially by doing the following:\n/// 1. Note NNTP server time before starting downloader (sync)\n/// 2. Download new messages\n/// 3. Start listener with querying for new messages since the download START.\nclass NntpSource\n{\n\talias Config = NntpConfig;\n\n\tthis(Config config)\n\t{\n\t\tauto listener = new NntpListenerSource(config.host);\n\t\tif (noDownload)\n\t\t\tlistener.startListening;\n\t\telse\n\t\t{\n\t\t\tauto downloader = new NntpDownloader(config.host, isDebug ? NntpDownloader.Mode.newOnly : NntpDownloader.Mode.fullPurge);\n\t\t\tdownloader.handleFinished = &listener.startListening;\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/progs/nntpdownload.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.progs.nntpdownload;\n\nimport std.exception;\nimport std.getopt;\n\nimport ae.net.asockets;\nimport ae.net.shutdown;\n\nimport dfeed.common;\nimport dfeed.database;\nimport dfeed.sources.newsgroups;\nimport dfeed.sinks.messagedb;\n\nvoid main(string[] args)\n{\n\tbool full, purge;\n\tgetopt(args,\n\t\t\"f|full\", &full,\n\t\t\"purge\", &purge,\n\t);\n\n\tenforce(!(full && purge), \"Specify either --full or --purge, not both\");\n\tauto mode = purge ? NntpDownloader.Mode.fullPurge : full ? NntpDownloader.Mode.full : NntpDownloader.Mode.newOnly;\n\n\tstatic class Downloader : NntpDownloader\n\t{\n\t\talias Config = NntpConfig;\n\t\tthis(Config config, NntpDownloader.Mode mode) { super(config.host, mode); }\n\t}\n\n\tcreateServices!Downloader(\"sources/nntp\", mode);\n\tnew MessageDBSink();\n\n\tmixin(DB_TRANSACTION);\n\n\tstartNewsSources();\n\tsocketManager.loop();\n}\n"
  },
  {
    "path": "src/dfeed/progs/sendspamfeedback.d",
    "content": "/*  Copyright (C) 2014, 2015, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.progs.sendspamfeedback;\n\nimport std.file;\nimport std.stdio;\nimport std.string;\n\nimport ae.net.asockets;\n\nimport dfeed.web.posting;\nimport dfeed.web.spam;\n\nvoid main(string[] args)\n{\n\tfiles:\n\tforeach (fn; args[1..$])\n\t{\n\t\tif (fn.length == 20)\n\t\t\tfn = dirEntries(\"logs\", \"* - PostProcess-\" ~ fn ~ \".log\", SpanMode.shallow).front.name;\n\n\t\twriteln(\"--------------------------------------------------------------------\");\n\t\tauto pp = new PostProcess(fn);\n\t\twrite(pp.post.message);\n\t\twriteln();\n\t\twriteln(\"--------------------------------------------------------------------\");\n\n\t\tSpamFeedback feedback = SpamFeedback.unknown;\n\t\twhile (feedback == SpamFeedback.unknown)\n\t\t{\n\t\t\twrite(\"Is this message spam or ham? \");\n\t\t\tswitch (readln().chomp())\n\t\t\t{\n\t\t\t\tcase \"spam\": feedback = SpamFeedback.spam; break;\n\t\t\t\tcase \"ham\":  feedback = SpamFeedback.ham;  break;\n\t\t\t\tcase \"skip\": continue files;\n\t\t\t\tdefault: break;\n\t\t\t}\n\t\t}\n\t\tvoid handler(Spamicity spamicity, string message) { writeln(spamicity < spamThreshold ? \"OK!\" : \"Error: \" ~ message); }\n\t\tsendSpamFeedback(pp, &handler, feedback);\n\t\tsocketManager.loop();\n\t}\n}\n\n/// Work around link error\nvoid foo()\n{\n\timport std.array;\n\tauto a = appender!string();\n\ta.put(\"test\"d);\n\tdchar c = 't';\n\ta.put(c);\n}\n"
  },
  {
    "path": "src/dfeed/progs/unban.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.progs.unban;\n\nimport ae.sys.log;\n\nimport std.algorithm.searching;\nimport std.format;\nimport std.stdio;\nimport std.string;\n\nimport dfeed.web.moderation : loadBanList, saveBanList, banned, parseParents;\n\nvoid main(string[] args)\n{\n\tstring[] logLines;\n\tvoid log(string s)\n\t{\n\t\tlogLines ~= s;\n\t\twriteln(s);\n\t}\n\n\tloadBanList();\n\n\tstring[][string] parents;\n\tstring[][string] children;\n\tforeach (key, reason; banned)\n\t{\n\t\tauto p = parseParents(reason);\n\t\tparents[key] = p;\n\t\tforeach (parent; p)\n\t\t\tchildren[parent] ~= key;\n\t}\n\n\tstring[] queue;\n\tsize_t total;\n\tvoid unban(string key, string reason)\n\t{\n\t\tif (key in banned)\n\t\t{\n\t\t\tlog(format(\"Unbanning %s (%s)\", key, reason));\n\t\t\tbanned.remove(key);\n\t\t\ttotal++;\n\t\t\tqueue ~= key;\n\t\t}\n\t}\n\n\tforeach (arg; args[1..$])\n\t\tunban(arg, \"command line\");\n\n\twhile (queue.length)\n\t{\n\t\tauto key = queue[0];\n\t\tqueue = queue[1..$];\n\t\t\n\t\tforeach (p; parents.get(key, null))\n\t\t\tunban(p, \"Parent of \" ~ key);\n\t\tforeach (c; children.get(key, null))\n\t\t\tunban(c, \"Child of \" ~ key);\n\t}\n\n\twritefln(\"Unbanning a total of %d keys.\", total);\n\twriteln(\"Type 'yes' to continue\");\n\tif (readln().strip() != \"yes\")\n\t{\n\t\twriteln(\"Aborting\");\n\t\treturn;\n\t}\n\n\tauto logFile = fileLogger(\"Unban\");\n\tforeach (line; logLines)\n\t\tlogFile(line);\n\tlogFile.close();\n\n\tsaveBanList();\n\twriteln(\"Restart DFeed to apply ban list.\");\n}\n"
  },
  {
    "path": "src/dfeed/sinks/cache.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sinks.cache;\n\nimport dfeed.common;\nimport dfeed.database;\n\nversion(Posix) import ae.sys.signals;\n\nint dbVersion = 1;\n\n/// Fake sink used only to invalidate the cache on new data.\nfinal class CacheSink : NewsSink\n{\n\toverride void handlePost(Post post, Fresh fresh)\n\t{\n\t\tdbVersion++;\n\t}\n}\n\nstruct Cached(T)\n{\n\tint cacheVersion;\n\tT cachedData;\n\n\tT opCall(lazy T dataSource)\n\t{\n\t\tif (cacheVersion != dbVersion)\n\t\t{\n\t\t\tcachedData = dataSource;\n\t\t\tcacheVersion = dbVersion;\n\t\t\tdebug(NOCACHE) cacheVersion = -1;\n\t\t}\n\t\treturn cachedData;\n\t}\n}\n\n/// Clears the whole set when the cache is invalidated, to save memory\nstruct CachedSet(K, T)\n{\n\tint cacheVersion;\n\tT[K] cachedData;\n\n\tT opCall(K key, lazy T dataSource)\n\t{\n\t\tif (cacheVersion != dbVersion)\n\t\t{\n\t\t\tcachedData = null;\n\t\t\tcacheVersion = dbVersion;\n\t\t}\n\n\t\tauto pdata = key in cachedData;\n\t\tif (pdata)\n\t\t\treturn *pdata;\n\t\telse\n\t\t\treturn cachedData[key] = dataSource;\n\t}\n}\n\nstatic this()\n{\n\tnew CacheSink();\n\n\tversion(Posix) addSignalHandler(SIGHUP, { dbVersion++; });\n}\n"
  },
  {
    "path": "src/dfeed/sinks/irc.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2016, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sinks.irc;\n\nimport std.algorithm.comparison;\nimport std.datetime;\nimport std.file;\nimport std.string;\n\nimport ae.net.asockets;\nimport ae.net.irc.client;\nimport ae.net.shutdown;\nimport ae.sys.log;\nimport ae.sys.timing;\nimport ae.utils.text;\n\nimport dfeed.common;\n\nalias core.time.TickDuration TickDuration;\n\n/// IRC color code for sent lines\nenum ircColor = 14; // Dark gray\n\n/// Format string for IRC announcements (as raw IRC protocol line)\nconst ircFormat = \"PRIVMSG %s :\\x01ACTION \\x03\" ~ format(\"%02d\", ircColor) ~ \"%s\\x01\";\n\nfinal class IrcSink : NewsSink\n{\n\tstatic struct Config\n\t{\n\t\tstring network;\n\t\tstring server;\n\t\tushort port = 6667;\n\t\tstring nick;\n\t\tstring channel;\n\t\tstring channel2;\n\t}\n\n\tthis(Config config)\n\t{\n\t\tif (config.channel.length && !config.channel.startsWith(\"#\"))\n\t\t\tconfig.channel = '#' ~ config.channel;\n\t\tif (config.channel2.length && !config.channel2.startsWith(\"#\"))\n\t\t\tconfig.channel2 = '#' ~ config.channel2;\n\t\tif (!config.network)\n\t\t\tconfig.network = config.server.split(\".\")[max(2, $)-2].capitalize();\n\t\tthis.config = config;\n\n\t\ttcp = new TcpConnection();\n\t\tirc = new IrcClient(tcp);\n\t\tirc.encoder = irc.decoder = &nullStringTransform;\n\t\tirc.exactNickname = true;\n\t\tirc.log = createLogger(\"IRC-\"~config.network);\n\t\tirc.handleConnect = &onConnect;\n\t\tirc.handleDisconnect = &onDisconnect;\n\t\tirc.handleInvite = &onInvite;\n\t\tconnect();\n\n\t\taddShutdownHandler((scope const(char)[] reason) { stopping = true; if (connecting || connected) irc.disconnect(\"DFeed shutting down: \" ~ cast(string)reason); });\n\t}\n\n\t@property string network() { return config.network; }\n\n\tvoid sendMessage(string recipient, string message)\n\t{\n\t\tif (connected)\n\t\t\tirc.message(recipient, message);\n\t}\n\nprotected:\n\toverride void handlePost(Post post, Fresh fresh)\n\t{\n\t\tif (!fresh)\n\t\t\treturn;\n\n\t\tif (post.time < Clock.currTime() - dur!\"days\"(1))\n\t\t\treturn; // ignore posts older than a day old (e.g. StackOverflow question activity bumps the questions)\n\n\t\tauto importance = post.getImportance();\n\t\tif (!importance)\n\t\t\treturn;\n\n\t\tbool important = importance >= Post.Importance.normal;\n\t\tif (important || haveUnimportantListeners())\n\t\t{\n\t\t\tpost.formatForIRC((string summary) {\n\t\t\t\tif (connected)\n\t\t\t\t{\n\t\t\t\t\tsummary = summary.newlinesToSpaces();\n\t\t\t\t\tif (config.channel.length && important)\n\t\t\t\t\t\tirc.sendRaw(format(ircFormat, config.channel , summary));\n\t\t\t\t\tif (config.channel2.length)\n\t\t\t\t\t\tirc.sendRaw(format(ircFormat, config.channel2, summary));\n\t\t\t\t}\n\t\t\t});\n\t\t}\n\t}\n\nprivate:\n\tTcpConnection tcp;\n\tIrcClient irc;\n\timmutable Config config;\n\tbool connecting, connected, stopping;\n\n\tvoid connect()\n\t{\n\t\tirc.nickname = config.nick;\n\t\tirc.realname = \"https://github.com/CyberShadow/DFeed\";\n\t\ttcp.connect(config.server, config.port);\n\t\tconnecting = true;\n\t}\n\n\tvoid onConnect()\n\t{\n\t\tconnecting = false;\n\t\tif (config.channel.length)\n\t\t\tirc.join(config.channel);\n\t\tif (config.channel2.length)\n\t\t\tirc.join(config.channel2);\n\t\tconnected = true;\n\t}\n\n\tvoid onDisconnect(string reason, DisconnectType type)\n\t{\n\t\tconnecting = connected = false;\n\t\tif (type != DisconnectType.requested && !stopping)\n\t\t\tsetTimeout(&connect, 10.seconds);\n\t}\n\n\t/// This function exists for the sole reason of avoiding creation of\n\t/// shortened URLs (thus, needlessly polluting bit.ly) when no one\n\t/// will be there to see them.\n\tbool haveUnimportantListeners()\n\t{\n\t\treturn config.channel2.length\n\t\t\t&& config.channel2 in irc.channels\n\t\t\t&& irc.channels[config.channel2].users.length > 1;\n\t}\n\n\tvoid onInvite(string invitee, string channel)\n\t{\n\t\tif (channel == config.channel || channel == config.channel2)\n\t\t\tirc.join(channel);\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/sinks/messagedb.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2018, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sinks.messagedb;\n\nimport std.algorithm;\nimport std.ascii;\nimport std.conv;\nimport std.string;\n\nimport ae.sys.log;\nimport ae.sys.timing;\nimport ae.utils.digest;\n\nimport dfeed.common;\nimport dfeed.database;\nimport dfeed.message;\n\nfinal class MessageDBSink : NewsSink\n{\n\talias Update = Flag!\"update\";\n\n\tthis(Update update=Update.no)\n\t{\n\t\tlog = createLogger(\"MessageDBSink\").asyncLogger();\n\t\tthis.update = update;\n\t}\n\nprivate:\n    Logger log;\n    Update update;\n\nprotected:\n\toverride void handlePost(Post post, Fresh fresh)\n\t{\n\t\tauto message = cast(Rfc850Post)post;\n\t\tif (!message)\n\t\t\treturn;\n\n\t\tlog(format(\"Saving message %s (%s)\", message.id, message.where));\n\n\t\tif (!message.doArchive)\n\t\t{\n\t\t\tlog(\"Archiving disabled, not saving.\");\n\t\t\treturn;\n\t\t}\n\n\t\tscope(success)\n\t\t{\n\t\t\tif (transactionDepth == 1) // This is a batch operation\n\t\t\t\tif (flushTransactionEvery(50))\n\t\t\t\t\tlog(\"Transaction flushed\");\n\t\t}\n\t\tmixin(DB_TRANSACTION);\n\n\t\tif (!message.rowid)\n\t\t\tforeach (int postRowid; query!\"SELECT `ROWID` FROM `Posts` WHERE `ID` = ?\".iterate(message.id))\n\t\t\t\tmessage.rowid = postRowid;\n\n\t\tif (message.rowid)\n\t\t{\n\t\t\tlog(format(\"Message %s already present with ROWID=%d\", message.id, message.rowid));\n\t\t\tif (update)\n\t\t\t{\n\t\t\t\tquery!\"UPDATE [Posts] SET [ID]=?, [Message]=?, [Author]=?, [AuthorEmail]=?, [Subject]=?, [Time]=?, [ParentID]=?, [ThreadID]=? WHERE [ROWID] = ?\"\n\t\t\t\t\t.exec(message.id, message.message, message.author, message.authorEmail, message.rawSubject, message.time.stdTime, message.parentID, message.threadID, message.rowid);\n\t\t\t\tlog(\"Updated.\");\n\t\t\t}\n\t\t}\n\t\telse\n\t\t{\n\t\t\tquery!\"INSERT INTO `Posts` (`ID`, `Message`, `Author`, `AuthorEmail`, `Subject`, `Time`, `ParentID`, `ThreadID`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)\"\n\t\t\t\t.exec(message.id, message.message, message.author, message.authorEmail, message.rawSubject, message.time.stdTime, message.parentID, message.threadID);\n\t\t\tmessage.rowid = db.lastInsertRowID.to!int;\n\t\t\tlog(format(\"Message %s saved with ROWID=%d\", message.id, message.rowid));\n\t\t}\n\n\t\tforeach (xref; message.xref)\n\t\t{\n\t\t\tquery!\"INSERT OR IGNORE INTO `Groups` (`Group`, `ArtNum`, `ID`, `Time`) VALUES (?, ?, ?, ?)\"\n\t\t\t\t.exec(xref.group, xref.num, message.id, message.time.stdTime);\n\n\t\t\tlong threadIndex = 0, created, updated;\n\t\t\tforeach (long rowid, long threadCreated, long threadUpdated; query!\"SELECT `ROWID`, `Created`, `LastUpdated` FROM `Threads` WHERE `ID` = ? AND `Group` = ?\".iterate(message.threadID, xref.group))\n\t\t\t\tthreadIndex = rowid, created = threadCreated, updated = threadUpdated;\n\n\t\t\tif (!threadIndex) // new thread\n\t\t\t\tquery!\"INSERT INTO `Threads` (`Group`, `ID`, `LastPost`, `Created`, `LastUpdated`) VALUES (?, ?, ?, ?, ?)\".exec(xref.group, message.threadID, message.id, message.time.stdTime, message.time.stdTime);\n\t\t\telse\n\t\t\t{\n\t\t\t\tif ((created > message.time.stdTime || !created) && !message.references.length)\n\t\t\t\t\tquery!\"UPDATE `Threads` SET `Created` = ? WHERE `ROWID` = ?\".exec(message.time.stdTime, threadIndex);\n\t\t\t\tif (updated < message.time.stdTime)\n\t\t\t\t\tquery!\"UPDATE `Threads` SET `LastPost` = ?, `LastUpdated` = ? WHERE `ROWID` = ?\".exec(message.id, message.time.stdTime, threadIndex);\n\t\t\t}\n\t\t}\n\n\t\tquery!\"INSERT OR REPLACE INTO [PostSearch] ([ROWID], [Time], [ThreadMD5], [Group], [Author], [AuthorEmail], [Subject], [Content], [NewThread]) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)\"\n\t\t\t.exec(\n\t\t\t\tmessage.rowid,\n\t\t\t\tmessage.time.stdTime,\n\t\t\t\tmessage.threadID.getDigestString!MD5().toLower(),\n\t\t\t\tmessage.xref.map!(xref => xref.group.searchTerm).join(\",\"),\n\t\t\t\tmessage.author,\n\t\t\t\tmessage.authorEmail,\n\t\t\t\tmessage.subject,\n\t\t\t\tmessage.newContent,\n\t\t\t\tmessage.references.length ? \"n\" : \"y\",\n\t\t\t);\n\t}\n}\n\n/// Source used for refreshing the message database.\nfinal class MessageDBSource : NewsSource\n{\n\tthis()\n\t{\n\t\tsuper(\"MessageDBSource\");\n\t}\n\n\tint batchSize = 500;\n\tDuration idleInterval = 100.msecs;\n\n\toverride void start()\n\t{\n\t\tstopping = false;\n\t\tdoBatch(0);\n\t}\n\n\toverride void stop()\n\t{\n\t\tlog(\"Stop requested...\");\n\t\tstopping = true;\n\t}\n\nprivate:\n\tbool stopping;\n\n\tvoid doBatch(int offset)\n\t{\n\t\tif (stopping)\n\t\t{\n\t\t\tlog(\"Stopping.\");\n\t\t\treturn;\n\t\t}\n\n\t\tbool foundPosts;\n\n\t\tassert(batchSize > 0);\n\t\tlog(\"Processing posts %d..%d\".format(offset, offset + batchSize));\n\n\t\t{\n\t\t\tmixin(DB_TRANSACTION);\n\n\t\t\tforeach (int rowID, string message, string id; query!\"SELECT [ROWID], [Message], [ID] FROM [Posts] LIMIT ? OFFSET ?\".iterate(batchSize, offset))\n\t\t\t{\n\t\t\t\tannouncePost(new Rfc850Post(message, id, rowID), Fresh.no);\n\t\t\t\tfoundPosts = true;\n\t\t\t}\n\n\t\t\tlog(\"Committing...\");\n\t\t}\n\t\tlog(\"Batch committed.\");\n\n\t\tif (foundPosts)\n\t\t\tsetTimeout({doBatch(offset + batchSize);}, idleInterval);\n\t\telse\n\t\t\tlog(\"All done!\");\n\t}\n}\n\n/// Look up the real thread ID of a post, by travelling\n/// up the chain of the first known ancestor IDs.\nstring getThreadID(string id)\n{\n\tstatic string[string] cache;\n\tauto pcached = id in cache;\n\tif (pcached)\n\t\treturn *pcached;\n\n\tstring result = id;\n\tforeach (string threadID; query!\"SELECT [ThreadID] FROM [Posts] WHERE [ID] = ?\".iterate(id))\n\t\tresult = threadID;\n\n\tif (result != id)\n\t\tresult = getThreadID(result);\n\treturn cache[id] = result;\n}\n\n@property string threadID(Rfc850Post post)\n{\n\treturn getThreadID(post.firstAncestorID);\n}\n\nstring searchTerm(string s)\n{\n\tstring result;\n\tforeach (c; s)\n\t\tif (isAlphaNum(c))\n\t\t\tresult ~= c;\n\treturn result;\n}\n"
  },
  {
    "path": "src/dfeed/sinks/subscriptions.d",
    "content": "﻿/*  Copyright (C) 2015, 2016, 2017, 2018, 2020, 2022, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sinks.subscriptions;\n\nimport std.algorithm;\nimport std.ascii;\nimport std.conv;\nimport std.exception;\nimport std.format;\nimport std.process;\nimport std.regex;\nimport std.string;\nimport std.typecons;\nimport std.typetuple;\n\nimport ae.net.ietf.url : UrlParameters;\nimport ae.sys.log;\nimport ae.sys.timing;\nimport ae.utils.array;\nimport ae.utils.json;\nimport ae.utils.meta;\nimport ae.utils.regex;\nimport ae.utils.text;\nimport ae.utils.textout;\nimport ae.utils.time;\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.common;\nimport dfeed.database;\nimport dfeed.groups;\nimport dfeed.loc;\nimport dfeed.mail;\nimport dfeed.message;\nimport dfeed.site;\nimport dfeed.sinks.irc;\nimport dfeed.sinks.messagedb : threadID;\nimport dfeed.web.user;\nimport dfeed.web.web.page : NotFoundException;\nimport dfeed.web.web.postinfo : getPost;\n\nvoid log(string s)\n{\n\tstatic Logger log;\n\t(log ? log : (log=createLogger(\"Subscription\")))(s);\n}\n\nstruct Subscription\n{\n\tstring userName, id;\n\tTrigger trigger;\n\tAction[] actions;\n\n\tthis(string userName, UrlParameters data)\n\t{\n\t\tthis.userName = userName;\n\t\tthis.id = data.get(\"id\", null);\n\t\tthis.trigger = getTrigger(userName, data);\n\t\tthis.actions = getActions(userName, data);\n\t}\n\n\t@property FormSection[] sections() { return cast(FormSection[])[trigger] ~ cast(FormSection[])actions; }\n\n\tvoid save()\n\t{\n\t\tassert(id, \"No subscription ID\");\n\t\tassert(userName, \"No subscription username\");\n\n\t\tforeach (section; sections)\n\t\t\tsection.validate();\n\n\t\tUrlParameters data;\n\t\tdata[\"id\"] = id;\n\t\tdata[\"trigger-type\"] = trigger.type;\n\n\t\tforeach (section; sections)\n\t\t\tsection.serialize(data);\n\n\t\t{\n\t\t\tmixin(DB_TRANSACTION);\n\t\t\tquery!\"INSERT OR REPLACE INTO [Subscriptions] ([ID], [Username], [Data]) VALUES (?, ?, ?)\"\n\t\t\t\t.exec(id, userName, SubscriptionData(data).toJson());\n\t\t\tforeach (section; sections)\n\t\t\t\tsection.save();\n\t\t}\n\t}\n\n\tvoid remove()\n\t{\n\t\tmixin(DB_TRANSACTION);\n\t\tforeach (section; sections)\n\t\t\tsection.cleanup();\n\t\tquery!`DELETE FROM [Subscriptions] WHERE [ID] = ?`.exec(id);\n\t}\n\n\tvoid unsubscribe()\n\t{\n\t\tforeach (action; actions)\n\t\t\taction.unsubscribe();\n\t\tsave();\n\t}\n\n\tvoid runActions(Rfc850Post post)\n\t{\n\t\tlog(\"Running subscription %s (%s trigger) actions for post %s\".format(id, trigger.type, post.id));\n\t\tstring name = getUserSetting(userName, \"name\");\n\t\tstring email = getUserSetting(userName, \"email\");\n\t\tif ((name  && !icmp(name, post.author))\n\t\t || (email && !icmp(email, post.authorEmail)))\n\t\t{\n\t\t\tlog(\"Post created by author, ignoring\");\n\t\t\treturn;\n\t\t}\n\n\t\tforeach (action; actions)\n\t\t\taction.run(this, post);\n\t}\n\n\tbool haveUnread()\n\t{\n\t\tauto user = registeredUser(userName);\n\t\tforeach (int rowid; query!\"SELECT [MessageRowID] FROM [SubscriptionPosts] WHERE [SubscriptionID] = ?\".iterate(id))\n\t\t\tif (!user.isRead(rowid))\n\t\t\t\treturn true;\n\t\treturn false;\n\t}\n\n\tint getUnreadCount()\n\t{\n\t\tauto user = registeredUser(userName);\n\t\tint count = 0;\n\t\tforeach (int rowid; query!\"SELECT [MessageRowID] FROM [SubscriptionPosts] WHERE [SubscriptionID] = ?\".iterate(id))\n\t\t\tif (!user.isRead(rowid))\n\t\t\t\tcount++;\n\t\treturn count;\n\t}\n}\n\n/// POD serialization type to avoid depending on UrlParameters internals\nstruct SubscriptionData\n{\n\tstring[][string] items;\n\tthis(UrlParameters parameters) { items = parameters.toAA; }\n\t@property UrlParameters data() { return UrlParameters(items); }\n}\n\nbool subscriptionExists(string subscriptionID)\n{\n\treturn query!`SELECT COUNT(*) FROM [Subscriptions] WHERE [ID]=?`.iterate(subscriptionID).selectValue!int > 0;\n}\n\nSubscription getSubscription(string subscriptionID)\nout(result) { assert(result.id == subscriptionID); }\nbody\n{\n\tforeach (string userName, string data; query!`SELECT [Username], [Data] FROM [Subscriptions] WHERE [ID] = ?`.iterate(subscriptionID))\n\t\treturn Subscription(userName, data.jsonParse!SubscriptionData.data);\n\tthrow new NotFoundException(_!\"No such subscription\");\n}\n\nSubscription getUserSubscription(string userName, string subscriptionID)\nout(result) { assert(result.id == subscriptionID && result.userName == userName); }\nbody\n{\n\tenforce(userName.length, _!\"Not logged in\");\n\tforeach (string data; query!`SELECT [Data] FROM [Subscriptions] WHERE [Username] = ? AND [ID] = ?`.iterate(userName, subscriptionID))\n\t\treturn Subscription(userName, data.jsonParse!SubscriptionData.data);\n\tthrow new NotFoundException(_!\"No such user subscription\");\n}\n\nSubscription[] getUserSubscriptions(string userName)\n{\n\tassert(userName);\n\n\tSubscription[] results;\n\tforeach (string data; query!`SELECT [Data] FROM [Subscriptions] WHERE [Username] = ?`.iterate(userName))\n\t\tresults ~= Subscription(userName, data.jsonParse!SubscriptionData.data);\n\treturn results;\n}\n\nvoid createReplySubscription(string userName)\n{\n\tauto replySubscriptions = getUserSubscriptions(userName)\n\t\t.filter!(result => result.trigger.type == \"reply\");\n\n\tauto subscription = replySubscriptions.empty\n\t\t? createSubscription(userName, \"reply\")\n\t\t: replySubscriptions.front\n\t;\n\tsubscription.save();\n}\n\nSubscription createSubscription(string userName, string triggerType, string[string] extraData = null)\n{\n\tUrlParameters data = extraData;\n\tdata[\"trigger-type\"] = triggerType;\n\n\tSubscription subscription;\n\tsubscription.userName = userName;\n\tsubscription.id = data[\"id\"] = randomString();\n\tsubscription.trigger = getTrigger(userName, data);\n\tsubscription.actions = getActions(userName, data);\n\treturn subscription;\n}\n\nabstract class FormSection\n{\n\tstring userName, subscriptionID;\n\n\tthis(string userName, UrlParameters data) { list(this.userName, this.subscriptionID) = tuple(userName, data.get(\"id\", null)); }\n\n\t/// Output the form HTML to edit this trigger.\n\tabstract void putEditHTML(ref StringBuffer html);\n\n\t/// Serialize state to a key-value AA,\n\t/// with the same keys as form input names.\n\tabstract void serialize(ref UrlParameters data);\n\n\t/// Verify that the settings are valid.\n\t/// Throw an exception otherwise.\n\tabstract void validate();\n\n\t/// Create or update any persistent state\n\t/// (outside the [Subscriptions] table).\n\tabstract void save();\n\n\t/// Clean up any persistent state after deletion\n\t/// (outside the [Subscriptions] table).\n\tabstract void cleanup();\n}\n\n// ***********************************************************************\n\nclass Trigger : FormSection\n{\n\tmixin GenerateConstructorProxies;\n\n\t/// TriggerType\n\tabstract @property string type() const;\n\tabstract @property string typeName() const; /// Localized name\n\n\t/// HTML description shown in the subscription list.\n\tabstract void putDescription(ref StringBuffer html);\n\n\tfinal string getDescription()\n\t{\n\t\tStringBuffer description;\n\t\tputDescription(description);\n\t\treturn description.get().assumeUnique();\n\t}\n\n\t/// Text description shown in emails and feed titles.\n\tabstract string getTextDescription();\n\n\t/// Short description for IRC and email subjects.\n\tabstract string getShortPostDescription(Rfc850Post post);\n\n\t/// Longer description emails.\n\tabstract string getLongPostDescription(Rfc850Post post);\n}\n\nfinal class ReplyTrigger : Trigger\n{\n\tmixin GenerateConstructorProxies;\n\n\toverride @property string type() const { return \"reply\"; }\n\toverride @property string typeName() const { return _!\"reply\"; }\n\n\toverride void putDescription(ref StringBuffer html) { html.put(getTextDescription()); }\n\n\toverride string getTextDescription() { return _!\"Replies to your posts\"; }\n\n\toverride string getShortPostDescription(Rfc850Post post)\n\t{\n\t\treturn _!\"%s replied to your post in the thread \\\"%s\\\"\".format(post.author, post.subject);\n\t}\n\n\toverride string getLongPostDescription(Rfc850Post post)\n\t{\n\t\treturn _!\"%s has just replied to your %s post in the thread titled \\\"%s\\\" in the %s group of %s.\".format(\n\t\t\tpost.author,\n\t\t\tpost.time.formatTimeLoc!`F j`,\n\t\t\tpost.subject,\n\t\t\tpost.xref[0].group,\n\t\t\tsite.host,\n\t\t);\n\t}\n\n\toverride void putEditHTML(ref StringBuffer html)\n\t{\n\t\thtml.put(_!\"When someone replies to your posts:\");\n\t}\n\n\toverride void serialize(ref UrlParameters data) {}\n\n\toverride void validate() {}\n\n\toverride void save()\n\t{\n\t\tstring email = getUserSetting(userName, \"email\");\n\t\tif (email)\n\t\t\tquery!`INSERT OR REPLACE INTO [ReplyTriggers] ([SubscriptionID], [Email]) VALUES (?, ?)`.exec(subscriptionID, email);\n\t}\n\n\toverride void cleanup()\n\t{\n\t\tquery!`DELETE FROM [ReplyTriggers] WHERE [SubscriptionID] = ?`.exec(subscriptionID);\n\t}\n}\n\nfinal class ThreadTrigger : Trigger\n{\n\tstring threadID;\n\n\tthis(string userName, UrlParameters data)\n\t{\n\t\tsuper(userName, data);\n\t\tthis.threadID = data.get(\"trigger-thread-id\", null);\n\t}\n\n\toverride @property string type() const { return \"thread\"; }\n\toverride @property string typeName() const { return _!\"thread\"; }\n\n\tfinal void putThreadName(ref StringBuffer html)\n\t{\n\t\tauto post = getPost(threadID);\n\t\thtml.put(`<a href=\"`), html.putEncodedEntities(idToUrl(threadID)), html.put(`\"><b>`),\n\t\thtml.putEncodedEntities(post ? post.subject : threadID),\n\t\thtml.put(`</b></a>`);\n\t}\n\n\toverride void putDescription(ref StringBuffer html)\n\t{\n\t\thtml.put(_!`Replies to the thread`, ` `), putThreadName(html);\n\t}\n\n\toverride string getTextDescription()\n\t{\n\t\tauto post = getPost(threadID);\n\t\treturn _!\"Replies to the thread\" ~ \" \" ~ (post ? `\"` ~ post.subject ~ `\"` : threadID);\n\t}\n\n\toverride string getShortPostDescription(Rfc850Post post)\n\t{\n\t\treturn _!\"%s replied to the thread \\\"%s\\\"\".format(post.author, post.subject);\n\t}\n\n\toverride string getLongPostDescription(Rfc850Post post)\n\t{\n\t\treturn _!\"%s has just replied to a thread you have subscribed to titled \\\"%s\\\" in the %s group of %s.\".format(\n\t\t\tpost.author,\n\t\t\tpost.subject,\n\t\t\tpost.xref[0].group,\n\t\t\tsite.host,\n\t\t);\n\t}\n\n\toverride void putEditHTML(ref StringBuffer html)\n\t{\n\t\tauto post = getPost(threadID);\n\t\thtml.put(\n\t\t\t`<input type=\"hidden\" name=\"trigger-thread-id\" value=\"`), html.putEncodedEntities(threadID), html.put(`\">`,\n\t\t\t_!`When someone posts a reply to the thread`, ` `), putThreadName(html), html.put(`:`\n\t\t);\n\t}\n\n\toverride void serialize(ref UrlParameters data)\n\t{\n\t\tdata[\"trigger-thread-id\"] = threadID;\n\t}\n\n\toverride void validate()\n\t{\n\t\tenforce(getPost(threadID), _!\"No such post\");\n\t}\n\n\toverride void save()\n\t{\n\t\tquery!`INSERT OR REPLACE INTO [ThreadTriggers] ([SubscriptionID], [ThreadID]) VALUES (?, ?)`.exec(subscriptionID, threadID);\n\t}\n\n\toverride void cleanup()\n\t{\n\t\tquery!`DELETE FROM [ThreadTriggers] WHERE [SubscriptionID] = ?`.exec(subscriptionID);\n\t}\n}\n\nfinal class ContentTrigger : Trigger\n{\n\tstruct StringFilter\n\t{\n\t\tbool enabled;\n\t\tbool isRegex;\n\t\tbool caseSensitive;\n\t\tstring str;\n\t}\n\n\tbool onlyNewThreads;\n\tbool onlyInGroups; string[] groups;\n\tStringFilter authorNameFilter, authorEmailFilter, subjectFilter, messageFilter;\n\n\tthis(string userName, UrlParameters data)\n\t{\n\t\tsuper(userName, data);\n\t\tthis.onlyNewThreads = data.get(\"trigger-content-message-type\", null) == \"threads\";\n\t\tthis.onlyInGroups = !!(\"trigger-content-only-in-groups\" in data);\n\t\tthis.groups = data.valuesOf(\"trigger-content-groups\");\n\n\t\tvoid readStringFilter(string id, out StringFilter filter)\n\t\t{\n\t\t\tauto prefix = \"trigger-content-\" ~ id ~ \"-\";\n\t\t\tfilter.enabled = !!((prefix ~ \"enabled\") in data);\n\t\t\tfilter.isRegex = data.get(prefix ~ \"match-type\", null) == \"regex\";\n\t\t\tfilter.caseSensitive = !!((prefix ~ \"case-sensitive\") in data);\n\t\t\tfilter.str = data.get(prefix ~ \"str\", null);\n\t\t}\n\n\t\treadStringFilter(\"author-name\", authorNameFilter);\n\t\treadStringFilter(\"author-email\", authorEmailFilter);\n\t\treadStringFilter(\"subject\", subjectFilter);\n\t\treadStringFilter(\"message\", messageFilter);\n\t}\n\n\toverride @property string type() const { return \"content\"; }\n\toverride @property string typeName() const { return _!\"content\"; }\n\n\toverride void putDescription(ref StringBuffer html)\n\t{\n\t\thtml.put(onlyNewThreads ? _!`New threads` : _!`New posts`);\n\t\tif (onlyInGroups)\n\t\t{\n\t\t\thtml.put(` `, _!`in`, ` `);\n\t\t\tvoid putGroup(string group)\n\t\t\t{\n\t\t\t\tauto gi = getGroupInfo(group);\n\t\t\t\thtml.put(`<b>`), html.putEncodedEntities(gi ? gi.publicName : group), html.put(`</b>`);\n\t\t\t}\n\n\t\t\tputGroup(groups[0]);\n\t\t\tif (groups.length==1)\n\t\t\t\t{}\n\t\t\telse\n\t\t\tif (groups.length==2)\n\t\t\t\thtml.put(` ` ~ _!`and` ~ ` `), putGroup(groups[1]);\n\t\t\telse\n\t\t\tif (groups.length==3)\n\t\t\t\thtml.put(`, `), putGroup(groups[1]), html.put(` `, _!`and`, ` `), putGroup(groups[2]);\n\t\t\telse\n\t\t\t\thtml.put(`, `), putGroup(groups[1]), html.put(`, (<b>%d</b> `.format(groups.length-2), _!`more`, `)`);\n\t\t}\n\n\t\tvoid putStringFilter(string preface, ref StringFilter filter)\n\t\t{\n\t\t\tif (filter.enabled)\n\t\t\t\thtml.put(\n\t\t\t\t\t` `, preface, ` `,\n\t\t\t\t\tfilter.isRegex ? `/` : ``,\n\t\t\t\t\t`<b>`), html.putEncodedEntities(filter.str), html.put(`</b>`,\n\t\t\t\t\tfilter.isRegex ? `/` : ``,\n\t\t\t\t\tfilter.isRegex && !filter.caseSensitive ? `i` : ``,\n\t\t\t\t);\n\t\t}\n\n\t\tputStringFilter(_!\"from\", authorNameFilter);\n\t\tputStringFilter(_!\"from email\", authorEmailFilter);\n\t\tputStringFilter(_!\"titled\", subjectFilter);\n\t\tputStringFilter(_!\"containing\", messageFilter);\n\t}\n\n\toverride string getTextDescription() { return getDescription().replace(`<b>`, \"\\&ldquo;\").replace(`</b>`, \"\\&rdquo;\"); }\n\n\toverride string getShortPostDescription(Rfc850Post post)\n\t{\n\t\tauto s = _!\"%s %s the thread \\\"%s\\\" in %s\".format(\n\t\t\tpost.author,\n\t\t\tpost.references.length ? _!\"replied to\" : _!\"created\",\n\t\t\tpost.subject,\n\t\t\tpost.xref[0].group,\n\t\t);\n\t\tstring matchStr =\n\t\t\tauthorNameFilter .enabled && authorNameFilter .str ? authorNameFilter .str :\n\t\t\tauthorEmailFilter.enabled && authorEmailFilter.str ? authorEmailFilter.str :\n\t\t\tsubjectFilter    .enabled && subjectFilter    .str ? subjectFilter    .str :\n\t\t\tmessageFilter    .enabled && messageFilter    .str ? messageFilter    .str :\n\t\t\tnull;\n\t\tif (matchStr)\n\t\t\ts = _!\"%s matching %s\".format(s, matchStr);\n\t\treturn s;\n\t}\n\n\toverride string getLongPostDescription(Rfc850Post post)\n\t{\n\t\treturn _!\"%s has just %s a thread titled \\\"%s\\\" in the %s group of %s.\\n\\n%s matches a content alert subscription you have created (%s).\".format(\n\t\t\tpost.author,\n\t\t\tpost.references.length ? _!\"replied to\" : _!\"created\",\n\t\t\tpost.subject,\n\t\t\tpost.xref[0].group,\n\t\t\tsite.host,\n\t\t\tpost.references.length ? _!\"This post\" : _!\"This thread\",\n\t\t\tgetTextDescription(),\n\t\t);\n\t}\n\n\toverride void putEditHTML(ref StringBuffer html)\n\t{\n\t\thtml.put(\n\t\t\t`<div id=\"trigger-content\">`,\n\t\t\t_!`When someone`, ` ` ~\n\t\t\t`<select name=\"trigger-content-message-type\">` ~\n\t\t\t\t`<option value=\"posts\"`  , onlyNewThreads ? `` : ` selected`, `>`, _!`posts or replies to a thread`, `</option>` ~\n\t\t\t\t`<option value=\"threads\"`, onlyNewThreads ? ` selected` : ``, `>`, _!`posts a new thread`, `</option>` ~\n\t\t\t`</select>` ~\n\t\t\t`<table>` ~\n\t\t\t`<tr><td>` ~\n\t\t\t\t`<input type=\"checkbox\" name=\"trigger-content-only-in-groups\"`, onlyInGroups ? ` checked` : ``, `> `, _!`only in the groups:` ~\n\t\t\t`</td><td>` ~\n\t\t\t\t`<select name=\"trigger-content-groups\" multiple size=\"10\">`\n\t\t);\n\t\tforeach (set; groupHierarchy)\n\t\t{\n\t\t\tif (!set.visible)\n\t\t\t\tcontinue;\n\n\t\t\thtml.put(\n\t\t\t\t`<option disabled>`), html.putEncodedEntities(set.shortName), html.put(`</option>`\n\t\t\t);\n\t\t\tforeach (group; set.groups)\n\t\t\t\thtml.put(\n\t\t\t\t\t`<option value=\"`), html.putEncodedEntities(group.internalName), html.put(`\"`, groups.canFind(group.internalName) ? ` selected` : ``, `>` ~\n\t\t\t\t\t\t`&nbsp;&nbsp;&nbsp;`), html.putEncodedEntities(group.publicName), html.put(`</option>`\n\t\t\t\t);\n\t\t}\n\t\thtml.put(\n\t\t\t\t`</select>` ~\n\t\t\t`</td></tr>`\n\t\t);\n\n\t\tvoid putStringFilter(string name, string id, ref StringFilter filter)\n\t\t{\n\t\t\thtml.put(\n\t\t\t\t`<tr><td>` ~\n\t\t\t\t\t`<input type=\"checkbox\" name=\"trigger-content-`, id, `-enabled\"`, filter.enabled ? ` checked` : ``, `> ` ~\n\t\t\t\t\t_!`and when the`, ` `, name, ` ` ~\n\t\t\t\t`</td><td>` ~\n\t\t\t\t\t`<select name=\"trigger-content-`, id, `-match-type\">` ~\n\t\t\t\t\t\t`<option value=\"substring\"`, filter.isRegex ? `` : ` selected`, `>`, _!`contains the string`, `</option>` ~\n\t\t\t\t\t\t`<option value=\"regex\"`    , filter.isRegex ? ` selected` : ``, `>`, _!`matches the regular expression`, `</option>` ~\n\t\t\t\t\t`</select> ` ~\n\t\t\t\t\t`<input name=\"trigger-content-`, id, `-str\" value=\"`), html.putEncodedEntities(filter.str), html.put(`\"> ` ~\n\t\t\t\t\t`(` ~\n\t\t\t\t\t`<input type=\"checkbox\" name=\"trigger-content-`, id, `-case-sensitive\"`, filter.caseSensitive ? ` checked` : ``, `>` ~\n\t\t\t\t\t` `, _!`case sensitive`, ` )` ~\n\t\t\t\t`</td></tr>`\n\t\t\t);\n\t\t}\n\n\t\tputStringFilter(_!\"author name\", \"author-name\", authorNameFilter);\n\t\tputStringFilter(_!\"author email\", \"author-email\", authorEmailFilter);\n\t\tputStringFilter(_!\"subject\", \"subject\", subjectFilter);\n\t\tputStringFilter(_!\"message\", \"message\", messageFilter);\n\t\thtml.put(`</table></div>`);\n\t}\n\n\toverride void serialize(ref UrlParameters data) const\n\t{\n\t\tdata[\"trigger-content-message-type\"] = onlyNewThreads ? \"threads\" : \"posts\";\n\t\tif (onlyInGroups) data[\"trigger-content-only-in-groups\"] = \"on\";\n\t\tforeach (group; groups)\n\t\t\tdata.add(\"trigger-content-groups\", group);\n\n\t\tvoid serializeStringFilter(string id, ref in StringFilter filter)\n\t\t{\n\t\t\tauto prefix = \"trigger-content-\" ~ id ~ \"-\";\n\t\t\tif (filter.enabled) data[prefix ~ \"enabled\"] = \"on\";\n\t\t\tdata[prefix ~ \"match-type\"] = filter.isRegex ? \"regex\" : \"substring\";\n\t\t\tif (filter.caseSensitive) data[prefix ~ \"case-sensitive\"] = \"on\";\n\t\t\tdata[prefix ~ \"str\"] = filter.str;\n\t\t}\n\n\t\tserializeStringFilter(\"author-name\", authorNameFilter);\n\t\tserializeStringFilter(\"author-email\", authorEmailFilter);\n\t\tserializeStringFilter(\"subject\", subjectFilter);\n\t\tserializeStringFilter(\"message\", messageFilter);\n\t}\n\n\toverride void validate()\n\t{\n\t\tvoid validateFilter(string name, ref StringFilter filter)\n\t\t{\n\t\t\tif (filter.enabled)\n\t\t\t{\n\t\t\t\tenforce(filter.str.length, _!\"No %s search term specified\".format(name));\n\t\t\t\ttry\n\t\t\t\t\tauto re = regex(filter.str);\n\t\t\t\tcatch (Exception e)\n\t\t\t\t\tthrow new Exception(_!\"Invalid %s regex `%s`: %s\".format(name, filter.str, e.msg));\n\t\t\t}\n\t\t}\n\n\t\tvalidateFilter(_!\"author name\", authorNameFilter);\n\t\tvalidateFilter(_!\"author email\", authorEmailFilter);\n\t\tvalidateFilter(_!\"subject\", subjectFilter);\n\t\tvalidateFilter(_!\"message\", messageFilter);\n\n\t\tif (onlyInGroups)\n\t\t\tenforce(groups.length, _!\"No groups selected\");\n\t}\n\n\toverride void save()\n\t{\n\t\tquery!`INSERT OR REPLACE INTO [ContentTriggers] ([SubscriptionID]) VALUES (?)`.exec(subscriptionID);\n\t}\n\n\toverride void cleanup()\n\t{\n\t\tquery!`DELETE FROM [ContentTriggers] WHERE [SubscriptionID] = ?`.exec(subscriptionID);\n\t}\n\n\tbool checkPost(Rfc850Post post)\n\t{\n\t\tif (onlyNewThreads && post.references.length)\n\t\t\treturn false;\n\t\tif (onlyInGroups && post.xref.all!(xref => !groups.canFind(xref.group)))\n\t\t\treturn false;\n\n\t\tbool checkFilter(ref StringFilter filter, string field)\n\t\t{\n\t\t\tif (!filter.enabled)\n\t\t\t\treturn true;\n\t\t\tif (filter.isRegex)\n\t\t\t\treturn !!field.match(regex(filter.str, filter.caseSensitive ? \"\" : \"i\"));\n\t\t\telse\n\t\t\t\treturn field.indexOf(filter.str, filter.caseSensitive ? CaseSensitive.yes : CaseSensitive.no) >= 0;\n\t\t}\n\n\t\tif (!checkFilter(authorNameFilter , post.author     )) return false;\n\t\tif (!checkFilter(authorEmailFilter, post.authorEmail)) return false;\n\t\tif (!checkFilter(subjectFilter    , post.subject    )) return false;\n\t\tif (!checkFilter(messageFilter    , post.newContent )) return false;\n\n\t\treturn true;\n\t}\n}\n\nTrigger getTrigger(string userName, UrlParameters data)\nout(result) { assert(result.type == data.get(\"trigger-type\", null)); }\nbody\n{\n\tauto triggerType = data.get(\"trigger-type\", null);\n\tswitch (triggerType)\n\t{\n\t\tcase \"reply\":\n\t\t\treturn new ReplyTrigger(userName, data);\n\t\tcase \"thread\":\n\t\t\treturn new ThreadTrigger(userName, data);\n\t\tcase \"content\":\n\t\t\treturn new ContentTrigger(userName, data);\n\t\tdefault:\n\t\t\tthrow new Exception(_!\"Unknown subscription trigger type:\" ~ \" \" ~ triggerType);\n\t}\n}\n\n// ***********************************************************************\n\nvoid checkPost(Rfc850Post post)\n{\n\t// ReplyTrigger\n\tif (auto parentID = post.parentID())\n\t\tif (auto parent = getPost(parentID))\n\t\t\tforeach (string subscriptionID; query!\"SELECT [SubscriptionID] FROM [ReplyTriggers] WHERE [Email] = ?\".iterate(parent.authorEmail))\n\t\t\t\tgetSubscription(subscriptionID).runActions(post);\n\n\t// ThreadTrigger\n\tforeach (string subscriptionID; query!\"SELECT [SubscriptionID] FROM [ThreadTriggers] WHERE [ThreadID] = ?\".iterate(post.threadID))\n\t\tgetSubscription(subscriptionID).runActions(post);\n\n\t// ContentTrigger\n\tforeach (string subscriptionID; query!\"SELECT [SubscriptionID] FROM [ContentTriggers]\".iterate())\n\t{\n\t\tauto subscription = getSubscription(subscriptionID);\n\t\tif (auto trigger = cast(ContentTrigger)subscription.trigger)\n\t\t\tif (trigger.checkPost(post))\n\t\t\t\tsubscription.runActions(post);\n\t}\n}\n\nfinal class SubscriptionSink : NewsSink\n{\nprotected:\n\toverride void handlePost(Post post, Fresh fresh)\n\t{\n\t\tif (!fresh)\n\t\t\treturn;\n\n\t\tif (!post.getImportance())\n\t\t\treturn;\n\n\t\tauto message = cast(Rfc850Post)post;\n\t\tif (!message)\n\t\t\treturn;\n\n\t\tlog(\"Checking post \" ~ message.id);\n\t\ttry\n\t\t\tcheckPost(message);\n\t\tcatch (Exception e)\n\t\t\tforeach (line; e.toString().splitLines())\n\t\t\t\tlog(\"* \" ~ line);\n\t}\n}\n\n// ***********************************************************************\n\nclass Action : FormSection\n{\n\tmixin GenerateConstructorProxies;\n\n\t/// Execute this action, if it is enabled.\n\tabstract void run(ref Subscription subscription, Rfc850Post post);\n\n\t/// Disable this action (used for one-click-unsubscribe in emails)\n\tabstract void unsubscribe();\n}\n\nfinal class IrcAction : Action\n{\n\tbool enabled;\n\tstring nick;\n\tstring network;\n\n\tthis(string userName, UrlParameters data)\n\t{\n\t\tsuper(userName, data);\n\t\tenabled = !!(\"saction-irc-enabled\" in data);\n\t\tnick = data.get(\"saction-irc-nick\", null);\n\t\tnetwork = data.get(\"saction-irc-network\", null);\n\t}\n\n\toverride void putEditHTML(ref StringBuffer html)\n\t{\n\t\thtml.put(\n\t\t\t`<p>` ~\n\t\t\t\t`<input type=\"checkbox\" name=\"saction-irc-enabled\"`, enabled ? ` checked` : ``, `> `,\n\t\t\t\t_!`Send a private message to`, ` <input name=\"saction-irc-nick\" value=\"`), html.putEncodedEntities(nick), html.put(`\"> `, _!`on the`, ` ` ~\n\t\t\t\t`<select name=\"saction-irc-network\">`);\n\t\tforeach (irc; services!IrcSink)\n\t\t{\n\t\t\thtml.put(\n\t\t\t\t\t`<option value=\"`), html.putEncodedEntities(irc.network), html.put(`\"`, network == irc.network ? ` selected` : ``, `>`),\n\t\t\t\t\t\thtml.putEncodedEntities(irc.network),\n\t\t\t\t\thtml.put(`</option>`);\n\t\t}\n\t\thtml.put(\n\t\t\t\t`</select> `, _!`IRC network`,\n\t\t\t`</p>`\n\t\t);\n\t}\n\n\toverride void serialize(ref UrlParameters data)\n\t{\n\t\tif (enabled) data[\"saction-irc-enabled\"] = \"on\";\n\t\tdata[\"saction-irc-nick\"] = nick;\n\t\tdata[\"saction-irc-network\"] = network;\n\t}\n\n\toverride void run(ref Subscription subscription, Rfc850Post post)\n\t{\n\t\tif (!enabled)\n\t\t\treturn;\n\n\t\t// Queue messages to avoid sending more than 1 PM per message.\n\n\t\tstatic string[string][string] queue;\n\t\tstatic TimerTask queueTask;\n\n\t\tqueue[network][nick] = subscription.trigger.getShortPostDescription(post) ~ \": \" ~ post.url;\n\t\tif (!queueTask)\n\t\t\tqueueTask = setTimeout({\n\t\t\t\tqueueTask = null;\n\t\t\t\tscope(exit) queue = null;\n\t\t\t\tforeach (irc; services!IrcSink)\n\t\t\t\t\tforeach (nick, message; queue.get(irc.network, null))\n\t\t\t\t\t\tirc.sendMessage(nick, message);\n\t\t\t}, 1.msecs);\n\t}\n\n\toverride void validate()\n\t{\n\t\tif (!enabled)\n\t\t\treturn;\n\t\tenforce(nick.length, _!\"No nickname indicated\");\n\t\tforeach (c; nick)\n\t\t\tif (!(isAlphaNum(c) || c.isOneOf(r\"-_|\\[]{}`\")))\n\t\t\t\tthrow new Exception(_!\"Invalid character in nickname.\");\n\t}\n\n\toverride void save() {}\n\n\toverride void cleanup() {}\n\n\toverride void unsubscribe() { enabled = false; }\n}\n\nfinal class EmailAction : Action\n{\n\tbool enabled;\n\tstring address;\n\n\tthis(string userName, UrlParameters data)\n\t{\n\t\tsuper(userName, data);\n\t\tenabled = !!(\"saction-email-enabled\" in data);\n\t\taddress = data.get(\"saction-email-address\", getUserSetting(userName, \"email\"));\n\t}\n\n\toverride void putEditHTML(ref StringBuffer html)\n\t{\n\t\thtml.put(\n\t\t\t`<p>` ~\n\t\t\t\t`<input type=\"checkbox\" name=\"saction-email-enabled\"`, enabled ? ` checked` : ``, `> `,\n\t\t\t\t_!`Send an email to`, ` <input type=\"email\" size=\"30\" name=\"saction-email-address\" value=\"`), html.putEncodedEntities(address), html.put(`\">` ~\n\t\t\t`</p>`\n\t\t);\n\t}\n\n\toverride void serialize(ref UrlParameters data)\n\t{\n\t\tif (enabled) data[\"saction-email-enabled\"] = \"on\";\n\t\tdata[\"saction-email-address\"] = address;\n\t}\n\n\tstring getUserRealName(string userName)\n\t{\n\t\tauto name = getUserSetting(userName, \"name\");\n\t\tif (!name)\n\t\t//\tname = address.split(\"@\")[0].capitalize();\n\t\t\tname = userName;\n\t\treturn name;\n\t}\n\n\tLanguage getUserLanguage(string userName)\n\t{\n\t\ttry\n\t\t\treturn getUserSetting(userName, \"language\").to!Language;\n\t\tcatch (Exception e)\n\t\t\treturn Language.init;\n\t}\n\n\toverride void run(ref Subscription subscription, Rfc850Post post)\n\t{\n\t\tif (!enabled)\n\t\t\treturn;\n\n\t\tif (subscription.haveUnread())\n\t\t{\n\t\t\tlog(\"User %s has unread messages in subscription %s - not emailing\"\n\t\t\t\t.format(subscription.userName, subscription.id));\n\t\t\treturn;\n\t\t}\n\n\t\t// Queue messages to avoid sending more than 1 email per message.\n\t\tstatic string[string] queue;\n\t\tstatic TimerTask queueTask;\n\n\t\tif (address in queue)\n\t\t{\n\t\t\t// TODO: Maybe add something to the content, to indicate that\n\t\t\t// a second subscription was triggered by the same message.\n\t\t\treturn;\n\t\t}\n\n\t\tqueue[address] = formatMessage(subscription, post);\n\n\t\tif (!queueTask)\n\t\t\tqueueTask = setTimeout({\n\t\t\t\tqueueTask = null;\n\t\t\t\tscope(exit) queue = null;\n\t\t\t\tforeach (address, message; queue)\n\t\t\t\t{\n\t\t\t\t\ttry\n\t\t\t\t\t\tsendMail(message);\n\t\t\t\t\tcatch (Exception e)\n\t\t\t\t\t\tlog(_!\"Error:\" ~ \" \" ~ e.msg);\n\t\t\t\t}\n\t\t\t}, 1.msecs);\n\t}\n\n\tstring formatMessage(ref Subscription subscription, Rfc850Post post)\n\t{\n\t\tauto realName = getUserRealName(userName);\n\t\tenforce(!(address~realName).canFind(\"\\n\"), \"Shenanigans detected\");\n\t\tauto oldLanguage = withLanguage(getUserLanguage(userName));\n\n\t\treturn [\n\t\t\t`From: %10$s <no-reply@%7$s>`,\n\t\t\t`To: %13$s <%11$s>`,\n\t\t\t`Subject: %12$s`,\n\t\t\t`Precedence: bulk`,\n\t\t\t`Content-Type: text/plain; charset=utf-8`,\n\t\t\t`List-Unsubscribe-Post: List-Unsubscribe=One-Click`,\n\t\t\t`List-Unsubscribe: <%6$s://%7$s/subscription-unsubscribe/%9$s>`,\n\t\t\t``,\n\t\t\t_!`Howdy %1$s,`,\n\t\t\t``,\n\t\t\t`%2$s`,\n\t\t\t``,\n\t\t\t_!`This %3$s is located at:`,\n\t\t\t`%4$s`,\n\t\t\t``,\n\t\t\t_!`Here is the message that has just been posted:`,\n\t\t\t`----------------------------------------------`,\n\t\t\t`%5$-(%s`,\n\t\t\t`%)`,\n\t\t\t`----------------------------------------------`,\n\t\t\t``,\n\t\t\t_!`To reply to this message, please visit this page:`,\n\t\t\t`%6$s://%7$s%8$s`,\n\t\t\t``,\n\t\t\t_!`There may also be other messages matching your subscription, but you will not receive any more notifications for this subscription until you've read all messages matching this subscription:`,\n\t\t\t`%6$s://%7$s/subscription-posts/%9$s`,\n\t\t\t``,\n\t\t\t_!`All the best,`,\n\t\t\t`%10$s`,\n\t\t\t``,\n\t\t\t`~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~`,\n\t\t\t_!`Unsubscription information:`,\n\t\t\t``,\n\t\t\t_!`To stop receiving emails for this subscription, please visit this page:`,\n\t\t\t`%6$s://%7$s/subscription-unsubscribe/%9$s`,\n\t\t\t``,\n\t\t\t_!`Or, visit your settings page to edit your subscriptions:`,\n\t\t\t`%6$s://%7$s/settings`,\n\t\t\t`.`,\n\t\t]\n\t\t.join(\"\\n\")\n\t\t.format(\n\t\t\t/* 1*/ realName.split(\" \")[0],\n\t\t\t/* 2*/ subscription.trigger.getLongPostDescription(post),\n\t\t\t/* 3*/ post.references.length ? _!\"post\" : _!\"thread\",\n\t\t\t/* 4*/ post.url,\n\t\t\t/* 5*/ post.content.strip.splitAsciiLines.map!(line => line.length ? \"> \" ~ line : \">\"),\n\t\t\t/* 6*/ site.proto,\n\t\t\t/* 7*/ site.host,\n\t\t\t/* 8*/ idToUrl(post.id, \"reply\"),\n\t\t\t/* 9*/ subscription.id,\n\t\t\t/*10*/ site.name.length ? site.name : site.host,\n\t\t\t/*11*/ address,\n\t\t\t/*12*/ subscription.trigger.getShortPostDescription(post),\n\t\t\t/*13*/ realName,\n\t\t);\n\t}\n\n\toverride void validate()\n\t{\n\t\tif (!enabled)\n\t\t\treturn;\n\t\tenforce(address.match(re!(`^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]+$`, \"i\")), _!\"Invalid email address\");\n\t}\n\n\toverride void save() {}\n\n\toverride void cleanup() {}\n\n\toverride void unsubscribe() { enabled = false; }\n}\n\nfinal class DatabaseAction : Action\n{\n\tmixin GenerateConstructorProxies;\n\n\toverride void putEditHTML(ref StringBuffer html)\n\t{\n\t\thtml.put(\n\t\t\t`<p>`,\n\t\t\t_!`Additionally, you can %ssubscribe to an ATOM feed of matched posts%s, or %sread them online%s.`.format(\n\t\t\t\t`<a href=\"/subscription-feed/` ~ subscriptionID ~ `\">`,\n\t\t\t\t`</a>`,\n\t\t\t\t`<a href=\"/subscription-posts/` ~ subscriptionID ~ `\">`,\n\t\t\t\t`</a>`,\n\t\t\t),\n\t\t\t`</p>`\n\t\t);\n\t}\n\n\toverride void serialize(ref UrlParameters data) {}\n\n\toverride void run(ref Subscription subscription, Rfc850Post post)\n\t{\n\t\tassert(post.rowid, \"No row ID for message \" ~ post.id);\n\t\tquery!\"INSERT INTO [SubscriptionPosts] ([SubscriptionID], [MessageID], [MessageRowID], [Time]) VALUES (?, ?, ?, ?)\"\n\t\t\t.exec(subscriptionID, post.id, post.rowid, post.time.stdTime);\n\t\t// TODO: trim old posts?\n\t}\n\n\toverride void validate() {}\n\n\toverride void save() {}\n\n\toverride void cleanup() {} // Just leave the SubscriptionPosts alone, e.g. in case the user clicks undo\n\n\toverride void unsubscribe() {}\n}\n\nAction[] getActions(string userName, UrlParameters data)\n{\n\tAction[] result;\n\tforeach (ActionType; TypeTuple!(EmailAction, IrcAction, DatabaseAction))\n\t\tresult ~= new ActionType(userName, data);\n\treturn result;\n}\n\n// ***********************************************************************\n\nprivate string getUserSetting(string userName, string setting)\n{\n\tforeach (string value; query!`SELECT [Value] FROM [UserSettings] WHERE [User] = ? AND [Name] = ?`.iterate(userName, setting))\n\t\treturn value;\n\treturn null;\n}\n"
  },
  {
    "path": "src/dfeed/sinks/twitter.d",
    "content": "/*  Copyright (C) 2018  Sebastian Wilzbach\n *  Copyright (C) 2018, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sinks.twitter;\n\nimport std.algorithm.iteration;\nimport std.datetime;\nimport std.string;\n\nimport dfeed.common;\nimport dfeed.message;\n\nimport ae.net.http.client;\nimport ae.net.http.common;\nimport ae.net.oauth.common;\nimport ae.sys.data;\nimport ae.utils.json;\n\nfinal class TwitterSink : NewsSink, ModerationSink\n{\n\tstatic struct Config\n\t{\n\t\tOAuthConfig oauth;\n\t\tstring postStatusURL = \"https://api.twitter.com/1.1/statuses/update.json\";\n\t\tstring deleteStatusURL = \"https://api.twitter.com/1.1/statuses/destroy/%s.json\";\n\t\tstring oauthAccessToken;\n\t\tstring oauthAccessTokenSecret;\n\t\tstring formatString = \"%s by %s: %s\";\n\t}\n\n\tthis(Config config)\n\t{\n\t\tthis.config = config;\n\t\tthis.session.config = config.oauth;\n\t\tthis.session.token = config.oauthAccessToken;\n\t\tthis.session.tokenSecret = config.oauthAccessTokenSecret;\n\t}\n\nprotected:\n\toverride void handlePost(Post post, Fresh fresh)\n\t{\n\t\tif (!fresh)\n\t\t\treturn;\n\n\t\tif (post.time < Clock.currTime() - dur!\"days\"(1))\n\t\t\treturn; // ignore posts older than a day old (e.g. StackOverflow question activity bumps the questions)\n\n\t\tif (post.getImportance() < Post.Importance.high)\n\t\t\treturn;\n\n\t\tauto rfcPost = cast(Rfc850Post)post;\n\t\tif (!rfcPost)\n\t\t\treturn;\n\n\t\ttweet(config.formatString.format(\n\t\t\trfcPost.subject,\n\t\t\trfcPost.author,\n\t\t\trfcPost.url,\n\t\t), (tweetId) {\n\t\t\tpostTweetCache.put(rfcPost.id, tweetId);\n\t\t});\n\t}\n\n\tvoid tweet(string message, void delegate(long tweetId) callback)\n\t{\n\t\tUrlParameters parameters;\n\t\tparameters[\"status\"] = message;\n\t\tauto request = new HttpRequest;\n\t\t//auto queryString = encodeUrlParameters(parameters);\n\t\tauto queryString = parameters.byPair.map!(p => session.encode(p.key) ~ \"=\" ~ session.encode(p.value)).join(\"&\");\n\t\tauto baseURL = config.postStatusURL;\n\t\tauto fullURL = baseURL ~ \"?\" ~ queryString;\n\t\trequest.resource = fullURL;\n\t\trequest.method = \"POST\";\n\t\trequest.headers[\"Authorization\"] = session.prepareRequest(baseURL, \"POST\", parameters).oauthHeader;\n\t\trequest.data = DataVec(Data([]));\n\t\thttpRequest(request, delegate(HttpResponse response, string disconnectReason) {\n\t\t\tif (response)\n\t\t\t{\n\t\t\t\t@JSONPartial\n\t\t\t\tstruct ResponseT\n\t\t\t\t{\n\t\t\t\t\tlong id;\n\t\t\t\t}\n\n\t\t\t\tauto res = (cast(const(char)[]) response.getContent().contents)\n\t\t\t\t\t.jsonParse!ResponseT;\n\t\t\t\tif (res.id != long.init)\n\t\t\t\t\tcallback(res.id);\n\t\t\t}\n\t\t});\n\t}\n\t\n\tvoid handleModeration(Post p, Flag!\"ban\" ban)\n\t{\n\t\tauto rfcPost = cast(Rfc850Post)p;\n\t\tif (!rfcPost)\n\t\t\treturn;\n\n\t\tif (auto tweetId = postTweetCache.get(rfcPost.id))\n\t\t{\n\t\t\tauto request = new HttpRequest;\n\t\t\tauto deleteURL = config.deleteStatusURL.format(tweetId);\n\t\t\trequest.resource = deleteURL;\n\t\t\trequest.method = \"POST\";\n\t\t\trequest.headers[\"Authorization\"] = session.prepareRequest(deleteURL, \"POST\").oauthHeader;\n\t\t\trequest.data = DataVec(Data([]));\n\t\t\thttpRequest(request, null);\n\t\t}\n\t}\n\nprivate:\n\timmutable Config config;\n\tOAuthSession session;\n\tPostTweetCache postTweetCache;\n}\n\nprivate struct PostTweetCache\n{\n\tstruct Entry\n\t{\n\t\tstring postId;\n\t\tlong tweetId;\n\t}\n\n\tEntry[128] lastTweets;\n\tsize_t i;\n\n\tvoid put(string postId, long tweetId)\n\t{\n\t\tlastTweets[i] = Entry(postId, tweetId);\n\t\ti++;\n\t\tif (i >= lastTweets.length)\n\t\t\ti = 0;\n\t}\n\n\tlong get(string postId)\n\t{\n\t\tforeach (ref tweet; lastTweets)\n\t\t\tif (tweet.postId == postId)\n\t\t\t\treturn tweet.tweetId;\n\t\treturn 0;\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/site.d",
    "content": "/*  Copyright (C) 2014, 2015, 2017, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.site;\n\nstruct SiteConfig\n{\n\tstring name;\n\tstring host = \"localhost\";\n\tstring proto = \"http\";\n\tstring about;\n\tstring ogImage;  // OpenGraph image URL for social media previews\n\timmutable(string)[] moderators;\n}\nimmutable SiteConfig site;\n\nimport ae.utils.sini;\nimport dfeed.paths : resolveSiteFile;\nshared static this() { site = loadIni!SiteConfig(resolveSiteFile(\"config/site.ini\")); }\n"
  },
  {
    "path": "src/dfeed/sources/github.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2017, 2018, 2021, 2023  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sources.github;\n\nimport std.algorithm.searching;\nimport std.exception;\nimport std.json;\nimport std.string;\nimport std.uni;\n\nimport ae.net.http.common;\nimport ae.sys.dataset;\nimport ae.utils.array;\nimport ae.utils.digest;\nimport ae.utils.sini;\nimport ae.utils.text;\n\nimport dfeed.bitly;\nimport dfeed.common;\n\nclass GitHub : NewsSource\n{\n\tthis(Config config)\n\t{\n\t\tsuper(\"GitHub\");\n\t\tthis.config = config;\n\t\tenforce(config.secret.length, \"No secret set\");\n\t}\n\n\tstruct Config\n\t{\n\t\tstring secret;\n\t}\n\n\timmutable Config config;\n\n\toverride void start() {}\n\toverride void stop () {}\n\n\tvoid handleWebHook(HttpRequest request)\n\t{\n\t\tauto data = cast(string)request.data.joinToHeap();\n\t\tauto digest = request.headers.get(\"X-Hub-Signature\", null);\n\t\tenforce(digest.length, \"No signature\");\n\t\tenforce(digest.skipOver(\"sha1=\"), \"Unexpected digest algorithm\");\n\t\tenforce(icmp(HMAC_SHA1(config.secret.representation, data.representation).toHex(), digest) == 0, \"Wrong digest\");\n\n\t\tauto event = request.headers.get(\"X-Github-Event\", null);\n\t\tlog(\"Got event: \" ~ event);\n\t\tif (!event.isOneOf(\"status\"))\n\t\t\tannouncePost(new GitHubPost(event, data), Fresh.yes);\n\t}\n}\n\nclass GitHubPost : Post\n{\n\tthis(string event, string data)\n\t{\n\t\tthis.event = event;\n\t\tthis.data = parseJSON(data);\n\t}\n\n\toverride void formatForIRC(void delegate(string) handler)\n\t{\n\t\tstring str, url;\n\t\tswitch (event)\n\t\t{\n\t\t\tcase \"ping\":\n\t\t\t\tstr = \"%s sent a ping (\\\"%s\\\")\".format(\n\t\t\t\t\tdata[\"sender\"][\"login\"].str.filterIRCName,\n\t\t\t\t\tdata[\"zen\"].str\n\t\t\t\t);\n\t\t\t\tbreak;\n\t\t\tcase \"push\":\n\t\t\t\tstr = \"%s: %s pushed %d commit%s to %s\".format(\n\t\t\t\t\tdata[\"repository\"][\"name\"].str,\n\t\t\t\t\tdata[\"sender\"][\"login\"].str.filterIRCName,\n\t\t\t\t\tdata[\"commits\"].array.length,\n\t\t\t\t\tdata[\"commits\"].array.length == 1 ? \"\" : \"s\",\n\t\t\t\t\tdata[\"ref\"].str.replace(\"refs/heads/\", \"branch \"),\n\t\t\t\t);\n\t\t\t\turl = data[\"compare\"].str;\n\t\t\t\tbreak;\n\t\t\tcase \"pull_request\":\n\t\t\t\tstr = \"%s: %s %s pull request #%s (\\\"%s\\\")\".format(\n\t\t\t\t\tdata[\"repository\"][\"name\"].str,\n\t\t\t\t\tdata[\"sender\"][\"login\"].str.filterIRCName,\n\t\t\t\t\t   (data[\"action\"].str == \"closed\" && data[\"pull_request\"][\"merged\"].type == JSON_TYPE.TRUE) ? \"merged\" :\n\t\t\t\t\t\tdata[\"action\"].str == \"synchronize\" ? \"updated\" :\n\t\t\t\t\t\tdata[\"action\"].str,\n\t\t\t\t\tdata[\"pull_request\"][\"number\"].integer,\n\t\t\t\t\tdata[\"pull_request\"][\"title\"].str,\n\t\t\t\t);\n\t\t\t\turl = data[\"pull_request\"][\"html_url\"].str;\n\t\t\t\tbreak;\n\t\t\tcase \"issue_comment\":\n\t\t\t\tstr = \"%s: %s %s a comment on issue #%s (\\\"%s\\\")\".format(\n\t\t\t\t\tdata[\"repository\"][\"name\"].str,\n\t\t\t\t\tdata[\"sender\"][\"login\"].str.filterIRCName,\n\t\t\t\t\tdata[\"action\"].str,\n\t\t\t\t\tdata[\"issue\"][\"number\"].integer,\n\t\t\t\t\tdata[\"issue\"][\"title\"].str,\n\t\t\t\t);\n\t\t\t\turl = data[\"comment\"][\"html_url\"].str;\n\t\t\t\tbreak;\n\t\t\tcase \"pull_request_review_comment\":\n\t\t\t\tstr = \"%s: %s %s a review comment on pull request #%s (\\\"%s\\\")\".format(\n\t\t\t\t\tdata[\"repository\"][\"name\"].str,\n\t\t\t\t\tdata[\"sender\"][\"login\"].str.filterIRCName,\n\t\t\t\t\tdata[\"action\"].str,\n\t\t\t\t\tdata[\"pull_request\"][\"number\"].integer,\n\t\t\t\t\tdata[\"pull_request\"][\"title\"].str,\n\t\t\t\t);\n\t\t\t\turl = data[\"comment\"][\"html_url\"].str;\n\t\t\t\tbreak;\n\t\t\tcase \"commit_comment\":\n\t\t\t\tstr = \"%s: %s a comment on %s commit %s\".format(\n\t\t\t\t\tdata[\"repository\"][\"name\"].str,\n\t\t\t\t\tdata[\"sender\"][\"login\"].str.filterIRCName,\n\t\t\t\t\tdata[\"action\"].str,\n\t\t\t\t\tdata[\"comment\"][\"commit_id\"].str[0..8],\n\t\t\t\t);\n\t\t\t\turl = data[\"comment\"][\"html_url\"].str;\n\t\t\t\tbreak;\n\t\t\tcase \"create\":\n\t\t\tcase \"delete\":\n\t\t\t\tstr = \"%s: %s %sd %s on %s\".format(\n\t\t\t\t\tdata[\"repository\"][\"name\"].str,\n\t\t\t\t\tdata[\"sender\"][\"login\"].str.filterIRCName,\n\t\t\t\t\tevent,\n\t\t\t\t\tdata[\"ref_type\"].str,\n\t\t\t\t\tdata[\"ref\"].str,\n\t\t\t\t);\n\t\t\t\tif (event == \"create\")\n\t\t\t\t\turl = data[\"repository\"][\"html_url\"].str ~ \"/compare/master...\" ~ data[\"ref\"].str;\n\t\t\t\tbreak;\n\t\t\tcase \"fork\":\n\t\t\t\tstr = \"%s: %s forked to %s\".format(\n\t\t\t\t\tdata[\"repository\"][\"name\"].str,\n\t\t\t\t\tdata[\"sender\"][\"login\"].str.filterIRCName,\n\t\t\t\t\tdata[\"forkee\"][\"full_name\"].str,\n\t\t\t\t);\n\t\t\t\turl = data[\"forkee\"][\"html_url\"].str;\n\t\t\t\tbreak;\n\t\t\tcase \"watch\":\n\t\t\t\tstr = \"%s %s %s watching\".format(\n\t\t\t\t\tdata[\"repository\"][\"name\"].str,\n\t\t\t\t\tdata[\"sender\"][\"login\"].str.filterIRCName,\n\t\t\t\t\tdata[\"action\"].str,\n\t\t\t\t);\n\t\t\t\turl = data[\"sender\"][\"html_url\"].str;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\t//throw new Exception(\"Unknown event type: \" ~ event);\n\t\t\t\tstr = \"(Unknown event: %s)\".format(event);\n\t\t\t\tbreak;\n\t\t}\n\n\t\tstr = \"[GitHub] \" ~ str;\n\n\t\tif (url && getImportance() >= Importance.normal)\n\t\t\tshortenURL(url, (string shortenedURL) {\n\t\t\t\thandler(str ~ \": \" ~ shortenedURL);\n\t\t\t});\n\t\telse\n\t\t{\n\t\t\tif (url)\n\t\t\t\tstr ~= \": \" ~ url;\n\t\t\thandler(str);\n\t\t}\n\t}\n\n\toverride Importance getImportance()\n\t{\n\t\tdebug\n\t\t\treturn Importance.low;\n\t\telse\n\t\t\tswitch (event)\n\t\t\t{\n\t\t\t\tcase \"pull_request\":\n\t\t\t\t\treturn data[\"action\"].str.isOneOf(\"opened\", \"closed\", \"reopened\") ? Importance.normal : Importance.low;\n\t\t\t\tcase \"check_run\":\n\t\t\t\tcase \"check_suite\":\n\t\t\t\tcase \"workflow_job\":\n\t\t\t\tcase \"workflow_run\":\n\t\t\t\t\treturn Importance.none;\t\t\t\t\n\t\t\t\tdefault:\n\t\t\t\t\treturn Importance.low;\n\t\t\t}\n\t}\n\nprivate:\n\tstring event;\n\tJSONValue data;\n}\n"
  },
  {
    "path": "src/dfeed/sources/mailman.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sources.mailman;\n\nimport std.datetime;\nimport std.file;\nimport std.getopt;\nimport std.random;\nimport std.string;\nimport std.regex;\n\nimport ae.net.asockets;\nimport ae.net.http.client;\nimport ae.sys.dataio;\nimport ae.sys.file;\nimport ae.utils.digest;\nimport ae.utils.gzip;\nimport ae.sys.data;\nimport ae.sys.log;\nimport ae.sys.timing;\nimport ae.utils.time;\n\nimport dfeed.common;\nimport dfeed.database;\nimport dfeed.message;\n\nclass Mailman : NewsSource\n{\n\tint maxConnections = 5;\n\n\tstruct ShadowList\n\t{\n\t\tstring list, group;\n\t}\n\n\tstruct Config\n\t{\n\t\tstring baseURL;\n\t\tstring lists;\n\t\tShadowList[string] shadowLists;\n\t}\n\tConfig config;\n\n\tthis(Config config)\n\t{\n\t\tsuper(\"Mailman\");\n\t\tthis.config = config;\n\t}\n\n\toverride void start()\n\t{\n\t\tforeach (list; config.lists.split(\",\"))\n\t\t\tdownloadList(list);\n\t}\n\n\toverride void stop()\n\t{\n\t\tstopping = true;\n\t}\n\nprivate:\n\tbool stopping;\n\tint queued;\n\n\tvoid getURL(string url, void delegate(string fn, bool fresh) callback)\n\t{\n\t\tif (stopping) return;\n\t\tif (queued >= maxConnections)\n\t\t{\n\t\t\tsetTimeout({getURL(url, callback);}, uniform(1, 1000).msecs);\n\t\t\treturn;\n\t\t}\n\n\t\tauto cachePath = \"data/mailman-cache/\" ~ getDigestString!MD5(url);\n\t\tlog(\"%s URL %s to %s...\".format(cachePath.exists ? \"Updating\" : \"Downloading\", url, cachePath));\n\n\t\tqueued++;\n\t\tauto request = new HttpRequest;\n\t\trequest.resource = url;\n\t\tif (cachePath.exists)\n\t\t\trequest.headers[\"If-Modified-Since\"] = cachePath.timeLastModified.formatTime!(TimeFormats.RFC2822);\n\t\thttpRequest(request,\n\t\t\t(HttpResponse response, string disconnectReason)\n\t\t\t{\n\t\t\t\tqueued--;\n\t\t\t\tauto okPath = cachePath ~ \".ok\";\n\t\t\t\tif (response && response.status == HttpStatusCode.OK)\n\t\t\t\t{\n\t\t\t\t\tensurePathExists(cachePath);\n\t\t\t\t\tatomicWrite(cachePath, response.getContent().contents);\n\t\t\t\t\tcallback(cachePath, !okPath.exists);\n\t\t\t\t\tokPath.touch();\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\tif (response && response.status == HttpStatusCode.NotModified)\n\t\t\t\t{\n\t\t\t\t\tcallback(cachePath, !okPath.exists);\n\t\t\t\t\tokPath.touch();\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tlog(\"Error getting URL %s: error=%s status=%s\".format(url, disconnectReason, response ? response.status : 0));\n\t\t\t\t\tsetTimeout({\n\t\t\t\t\t\tlog(\"Retrying...\");\n\t\t\t\t\t\tgetURL(url, callback);\n\t\t\t\t\t}, 10.seconds);\n\t\t\t\t}\n\t\t\t}\n\t\t);\n\t}\n\n\tvoid downloadList(string list)\n\t{\n\t\tif (stopping) return;\n\t\tgetURL(config.baseURL ~ list.toLower() ~ \"/\",\n\t\t\t(string fn, bool fresh)\n\t\t\t{\n\t\t\t\tlog(\"Got list index: \" ~ list);\n\t\t\t\tif (!fresh)\n\t\t\t\t{\n\t\t\t\t\tlog(\"Stale index, not parsing\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tauto html = readText(fn);\n\t\t\t\tauto re = regex(`<A href=\"(\\d+(-\\w+)?\\.txt(\\.gz)?)\">`);\n\t\t\t\tforeach (line; splitLines(html))\n\t\t\t\t{\n\t\t\t\t\tauto m = match(line, re);\n\t\t\t\t\tif (!m.empty)\n\t\t\t\t\t\tdownloadFile(list, m.captures[1]);\n\t\t\t\t}\n\t\t\t});\n\t}\n\n\tvoid downloadFile(string list, string fn)\n\t{\n\t\tif (stopping) return;\n\t\tauto url = config.baseURL ~ list.toLower() ~ \"/\" ~ fn;\n\t\tgetURL(url,\n\t\t\t(string datafn, bool fresh)\n\t\t\t{\n\t\t\t\tlog(\"Got %s/%s\".format(list, fn));\n\t\t\t\tif (!fresh)\n\t\t\t\t{\n\t\t\t\t\tlog(\"Stale file, not parsing\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tauto data = readData(datafn);\n\t\t\t\tscope(failure) std.file.write(\"errorfile\", data.contents);\n\t\t\t\tstring text;\n\t\t\t\tif (fn.endsWith(\".txt.gz\"))\n\t\t\t\t\ttext = cast(string)(data.uncompress.toHeap);\n\t\t\t\telse\n\t\t\t\tif (fn.endsWith(\".txt\"))\n\t\t\t\t\ttext = cast(string)(data.toHeap);\n\t\t\t\telse\n\t\t\t\t\tassert(false);\n\t\t\t\ttext = text[text.indexOf('\\n')+1..$]; // skip first From line\n\t\t\t\tauto fromline = regex(\"\\n\\nFrom .* at .*  \\\\w\\\\w\\\\w \\\\w\\\\w\\\\w [\\\\d ]\\\\d \\\\d\\\\d:\\\\d\\\\d:\\\\d\\\\d \\\\d\\\\d\\\\d\\\\d\\n\");\n\t\t\t\tmixin(DB_TRANSACTION);\n\t\t\t\tforeach (msg; splitter(text, fromline))\n\t\t\t\t{\n\t\t\t\t\tmsg = \"X-DFeed-List: \" ~ list ~ \"\\n\" ~ msg;\n\t\t\t\t\tscope(failure) std.file.write(\"errormsg\", msg);\n\t\t\t\t\tRfc850Post post;\n\t\t\t\t\tversion (mailman_strict)\n\t\t\t\t\t\tpost = new Rfc850Post(msg);\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\ttry\n\t\t\t\t\t\t\tpost = new Rfc850Post(msg);\n\t\t\t\t\t\tcatch (Exception e)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlog(\"Invalid message: \" ~ e.msg);\n\t\t\t\t\t\t\tcontinue;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tforeach (int n; query!\"SELECT COUNT(*) FROM `Posts` WHERE `ID` = ?\".iterate(post.id))\n\t\t\t\t\t\tif (n == 0)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tlog(\"Found new post: \" ~ post.id);\n\t\t\t\t\t\t\tannouncePost(post, Fresh.no);\n\t\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t});\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/sources/mailrelay.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sources.mailrelay;\n\nimport ae.net.asockets;\nimport ae.utils.text;\n\nimport dfeed.common;\nimport dfeed.message;\n\n/// Listen for email messages piped by a helper script to a socket.\nclass MailRelay : NewsSource\n{\n\tstatic struct Config\n\t{\n\t\tstring addr;\n\t\tushort port;\n\t}\n\n\tthis(Config config)\n\t{\n\t\tsuper(\"MailRelay\");\n\n\t\tthis.config = config;\n\n\t\tserver = new TcpServer();\n\t\tserver.handleAccept = &onAccept;\n\t}\n\n\toverride void start()\n\t{\n\t\tserver.listen(config.port, config.addr);\n\t}\n\n\toverride void stop()\n\t{\n\t\tserver.close();\n\t}\n\nprivate:\n\tTcpServer server;\n\timmutable Config config;\n\n\tvoid onAccept(TcpConnection incoming)\n\t{\n\t\tlog(\"* New connection\");\n\t\tData received;\n\n\t\tincoming.handleReadData = (Data data)\n\t\t{\n\t\t\treceived ~= data;\n\t\t};\n\n\t\tincoming.handleDisconnect = (string reason, DisconnectType type)\n\t\t{\n\t\t\tauto text = cast(string)received.toHeap();\n\t\t\tforeach (line; splitAsciiLines(text))\n\t\t\t\tlog(\"> \" ~ line);\n\t\t\tlog(\"* Disconnected\");\n\n\t\t\tauto post = new Rfc850Post(text);\n\t\t\tannouncePost(post, post.fresh);\n\t\t};\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/sources/newsgroups.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sources.newsgroups;\n\nimport std.algorithm;\nimport std.string;\nimport std.conv;\n\nimport ae.utils.array : queuePop;\nimport ae.utils.aa : HashSet;\nimport ae.utils.json;\nimport ae.net.nntp.client;\nimport ae.net.nntp.listener;\nimport ae.sys.timing;\n\nimport dfeed.common;\nimport dfeed.database;\nimport dfeed.message;\n\nstruct NntpConfig\n{\n\tstring host;\n\tbool postingAllowed = true;\n\tstring deleteCommand;\n}\n\n/// Poll the server periodically for new messages\nclass NntpListenerSource : NewsSource\n{\n\tthis(string server)\n\t{\n\t\tsuper(\"NNTP-Listener\");\n\t\tthis.server = server;\n\t\tclient = new NntpListener(log);\n\t\tclient.handleMessage = &onMessage;\n\t}\n\n\toverride void start()\n\t{\n\t}\n\n\toverride void stop()\n\t{\n\t\tif (connected)\n\t\t\tclient.disconnect();\n\t\tstopped = true;\n\t}\n\n\t/// Call this to start polling the server.\n\t/// startTime is the timestamp (as returned by the\n\t/// server DATE command) for the first poll cutoff time.\n\tvoid startListening(string startTime=null)\n\t{\n\t\tif (!stopped)\n\t\t{\n\t\t\tclient.connect(server);\n\t\t\tconnected = true;\n\t\t\tclient.startPolling(startTime);\n\t\t}\n\t}\n\nprivate:\n\tstring server;\n\tbool connected, stopped;\n\tNntpListener client;\n\n\tvoid onMessage(string[] lines, string num, string id)\n\t{\n\t\tauto post = new Rfc850Post(lines.join(\"\\n\"), id);\n\t\tannouncePost(post, post.fresh);\n\t}\n}\n\n/// Download articles not present in the database.\nclass NntpDownloader : NewsSource\n{\n\tenum Mode { newOnly, full, fullPurge }\n\n\tNntpClient client;\n\n\tthis(string server, Mode mode)\n\t{\n\t\tsuper(\"NNTP-Downloader\");\n\t\tthis.server = server;\n\t\tthis.mode = mode;\n\n\t\tinitialize();\n\t}\n\n\toverride void start()\n\t{\n\t\trunning = true;\n\t\tlog(\"Starting, mode is \" ~ text(mode));\n\t\tclient.connect(server, &onConnect);\n\t}\n\n\toverride void stop()\n\t{\n\t\tif (running)\n\t\t{\n\t\t\trunning = false;\n\t\t\tstopping = true;\n\t\t\tlog(\"Shutting down\");\n\t\t\tclient.disconnect();\n\t\t}\n\t}\n\n\tvoid delegate(string startTime) handleFinished;\n\nprivate:\n\tstring server;\n\tMode mode;\n\tbool running, stopping;\n\tstring startTime;\n\n\tvoid onConnect()\n\t{\n\t\tif (stopping) return;\n\t\tlog(\"Listing groups...\");\n\t\tclient.getDate((string date) { startTime = date; });\n\t\tclient.listGroups(&onGroups);\n\t}\n\n\tvoid onGroups(GroupInfo[] groups)\n\t{\n\t\tlog(format(\"Got %d groups.\", groups.length));\n\n\t\tforeach (group; groups)\n\t\t\tgetGroup(group); // Own function for closure\n\n\t\tclient.handleIdle = &onIdle;\n\t}\n\n\tvoid getGroup(GroupInfo group)\n\t{\n\t\t// Get maximum article numbers before fetching messages -\n\t\t// a cross-posted message might change a queued group's\n\t\t// \"maximum article number in database\".\n\n\t\t// The listGroup commands will be queued all together\n\t\t// before any getMessage commands.\n\n\t\tlog(format(\"Fetching group info for: %s\", group.name));\n\n\t\tint maxNum = 0;\n\t\tforeach (int num; query!\"SELECT MAX(`ArtNum`) FROM `Groups` WHERE `Group` = ?\".iterate(group.name))\n\t\t\tmaxNum = num;\n\n\t\tvoid onListGroup(string[] messages)\n\t\t{\n\t\t\tif (stopping) return;\n\t\t\tlog(format(\"%d messages in group %s.\", messages.length, group.name));\n\n\t\t\tHashSet!int serverMessages;\n\t\t\tforeach (i, m; messages)\n\t\t\t\tserverMessages.add(to!int(m));\n\n\t\t\tHashSet!int localMessages;\n\t\t\tforeach (int num; query!\"SELECT `ArtNum` FROM `Groups` WHERE `Group` = ?\".iterate(group.name))\n\t\t\t\tlocalMessages.add(num);\n\n\t\t\t// Construct set of posts to download\n\t\t\tHashSet!int messagesToDownload = serverMessages.dup;\n\t\t\tforeach (num; localMessages)\n\t\t\t\tif (num in messagesToDownload)\n\t\t\t\t\tmessagesToDownload.remove(num);\n\n\t\t\t// Remove posts present in the database\n\t\t\tif (messagesToDownload.length)\n\t\t\t{\n\t\t\t\tclient.selectGroup(group.name);\n\t\t\t\tforeach (num; messagesToDownload.keys.sort().release())\n\t\t\t\t\tclient.getMessage(to!string(num), &onMessage);\n\t\t\t}\n\n\t\t\tif (mode == Mode.fullPurge)\n\t\t\t{\n\t\t\t\tHashSet!int messagesToDelete = localMessages.dup;\n\t\t\t\tforeach (num; serverMessages)\n\t\t\t\t\tif (num in messagesToDelete)\n\t\t\t\t\t\tmessagesToDelete.remove(num);\n\n\t\t\t\tenum PRETEND = false;\n\n\t\t\t\tvoid logAndDelete(string TABLE, string WHERE, T...)(T args)\n\t\t\t\t{\n\t\t\t\t\tenum selectSql = \"SELECT * FROM `\" ~ TABLE ~ \"` \" ~ WHERE;\n\t\t\t\t\tenum deleteSql = \"DELETE   FROM `\" ~ TABLE ~ \"` \" ~ WHERE;\n\n\t\t\t\t\tlog(\"  \" ~ deleteSql);\n\n\t\t\t\t\tauto select = query!selectSql;\n\t\t\t\t\tselect.bindAll!T(args);\n\t\t\t\t\twhile (select.step())\n\t\t\t\t\t\tlog(\"    \" ~ toJson(select.getAssoc()));\n\n\t\t\t\t\tstatic if (!PRETEND)\n\t\t\t\t\t\tquery!deleteSql.exec(args);\n\t\t\t\t}\n\n\t\t\t\tforeach (num; messagesToDelete)\n\t\t\t\t{\n\t\t\t\t\tlog((PRETEND ? \"Would delete\" : \"Deleting\") ~ \" message: \" ~ text(num));\n\t\t\t\t\tmixin(DB_TRANSACTION);\n\n\t\t\t\t\tstring id;\n\t\t\t\t\tforeach (string msgId; query!\"SELECT `ID` FROM `Groups` WHERE `Group` = ? AND `ArtNum` = ?\".iterate(group.name, num))\n\t\t\t\t\t\tid = msgId;\n\n\t\t\t\t\tlogAndDelete!(`Groups`, \"WHERE `Group` = ? AND `ArtNum` = ?\")(group.name, num);\n\n\t\t\t\t\tif (id)\n\t\t\t\t\t{\n\t\t\t\t\t\tlogAndDelete!(`Posts`  , \"WHERE `ID` = ?\")(id);\n\t\t\t\t\t\tlogAndDelete!(`Threads`, \"WHERE `ID` = ?\")(id);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tlog(format(\"Listing group: %s\", group.name));\n\t\tif (mode == Mode.newOnly)\n\t\t{\n\t\t\tlog(format(\"Highest article number in database: %d\", maxNum));\n\t\t\tif (group.high > maxNum)\n\t\t\t{\n\t\t\t\t// news.digitalmars.com doesn't support LISTGROUP ranges, use XOVER\n\t\t\t\tclient.listGroupXover(group.name, maxNum+1, &onListGroup);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t\tclient.listGroup(group.name, &onListGroup);\n\t}\n\n\tvoid onIdle()\n\t{\n\t\tlog(\"All done!\");\n\t\trunning = false;\n\t\tclient.handleIdle = null;\n\t\tclient.disconnect();\n\t\tassert(startTime);\n\t\tif (handleFinished)\n\t\t\thandleFinished(startTime);\n\t}\n\n\tvoid onMessage(string[] lines, string num, string id)\n\t{\n\t\tlog(format(\"Got message %s (%s)\", num, id));\n\t\tannouncePost(new Rfc850Post(lines.join(\"\\n\"), id), Fresh.no);\n\t}\n\n\tvoid onDisconnect(string reason, DisconnectType type)\n\t{\n\t\tif (running)\n\t\t\tonError(\"Unexpected NntpDownloader disconnect: \" ~ reason);\n\t}\n\n\tvoid onError(string msg)\n\t{\n\t\tlog(msg);\n\t\tsetTimeout({ log(\"Retrying...\"); restart(); }, 10.seconds);\n\t}\n\n\tvoid initialize()\n\t{\n\t\tstartTime = null;\n\n\t\tclient = new NntpClient(log);\n\t\tclient.handleDisconnect = &onDisconnect;\n\t}\n\n\tvoid restart()\n\t{\n\t\tif (stopping)\n\t\t\treturn;\n\t\tinitialize();\n\t\tstart();\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/sources/socket.d",
    "content": "/*  Copyright (C) 2015, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sources.socket;\n\nimport std.exception;\nimport std.string;\nimport std.file;\n\nimport ae.net.asockets;\nimport ae.utils.array;\nimport ae.utils.json;\nimport ae.utils.text;\n\nimport dfeed.bitly;\nimport dfeed.common;\nimport dfeed.message;\n\n/// Listen for email messages piped by a helper script to a socket.\nclass SocketSource : NewsSource\n{\n\tstatic struct Config\n\t{\n\t\tushort port;\n\t\tstring password;\n\t}\n\n\tthis(Config config)\n\t{\n\t\tsuper(\"SocketSource\");\n\n\t\tthis.config = config;\n\n\t\tserver = new TcpServer();\n\t\tserver.handleAccept = &onAccept;\n\t}\n\n\toverride void start()\n\t{\n\t\tserver.listen(config.port);\n\t}\n\n\toverride void stop()\n\t{\n\t\tserver.close();\n\t}\n\nprivate:\n\tTcpServer server;\n\tConfig config;\n\n\tvoid onAccept(TcpConnection incoming)\n\t{\n\t\tlog(\"* New connection\");\n\t\tData[] received;\n\n\t\tincoming.handleReadData = (Data data)\n\t\t{\n\t\t\treceived ~= data;\n\t\t\tif (received.length > 1*1024*1024)\n\t\t\t{\n\t\t\t\treceived = null;\n\t\t\t\tincoming.disconnect(\"Too much data\");\n\t\t\t}\n\t\t};\n\n\t\tincoming.handleDisconnect = (string reason, DisconnectType type)\n\t\t{\n\t\t\tlog(\"* Disconnected\");\n\t\t\ttry\n\t\t\t{\n\t\t\t\tif (!received)\n\t\t\t\t\treturn;\n\n\t\t\t\tauto text = cast(string)received.joinToHeap();\n\n\t\t\t\tauto receivedPassword = text.eatLine();\n\t\t\t\tenforce(receivedPassword == config.password, \"Wrong password\");\n\n\t\t\t\tauto component = text.eatLine();\n\n\t\t\t\tswitch (component)\n\t\t\t\t{\n\t\t\t\t\tcase \"dwiki\":\n\t\t\t\t\t\thandleDWiki(text);\n\t\t\t\t\t\tbreak;\n\t\t\t\t\tdefault:\n\t\t\t\t\t\tthrow new Exception(\"Unknown component: \" ~ component);\n\t\t\t\t}\n\t\t\t}\n\t\t\tcatch (Exception e)\n\t\t\t\tlog(\"* Error: \" ~ e.msg);\n\t\t};\n\t}\n\n\tvoid handleDWiki(string text)\n\t{\n\t\tstatic struct Info\n\t\t{\n\t\t\tstring article, user, text, summary, section, url;\n\t\t\tbool isMinor, isWatch;\n\t\t}\n\n\t\tauto info = jsonParse!Info(text);\n\n\t\tannouncePost(new class Post\n\t\t{\n\t\t\toverride Importance getImportance() { return info.isMinor ? Importance.low : Importance.normal; }\n\n\t\t\toverride void formatForIRC(void delegate(string) handler)\n\t\t\t{\n\t\t\t\tshortenURL(info.url, (string shortenedURL) {\n\t\t\t\t\thandler(format(\"[DWiki] %s edited \\\"%s\\\"%s%s%s%s: %s\",\n\t\t\t\t\t\tfilterIRCName(info.user),\n\t\t\t\t\t\tinfo.article,\n\t\t\t\t\t\tinfo.summary.length ? \" (\" : null,\n\t\t\t\t\t\tinfo.summary,\n\t\t\t\t\t\tinfo.summary.length ? \")\" : null,\n\t\t\t\t\t\tinfo.isMinor ? \" [m]\" : null,\n\t\t\t\t\t\tshortenedURL,\n\t\t\t\t\t));\n\t\t\t\t});\n\t\t\t}\n\t\t}, Fresh.yes);\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/sources/web/feed.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2016, 2018, 2022  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sources.web.feed;\n\nimport std.exception;\nimport std.string;\nimport std.datetime;\n\nimport ae.utils.xml;\nimport ae.net.http.client;\n\nimport dfeed.common;\nimport dfeed.bitly;\nimport dfeed.sources.web.webpoller;\n\nclass Feed : WebPoller\n{\n\tstatic struct Config\n\t{\n\t\tstring name;\n\t\tstring url;\n\t\tstring action = \"posted\";\n\t\tint pollPeriod = 60;\n\t}\n\n\tthis(Config config)\n\t{\n\t\tthis.config = config;\n\t\tsuper(config.name, config.pollPeriod);\n\t}\n\nprivate:\n\tConfig config;\n\n\tclass FeedPost : Post\n\t{\n\t\tstring title;\n\t\tstring author;\n\t\tstring url;\n\n\t\tthis(string title, string author, string url, SysTime time)\n\t\t{\n\t\t\tthis.title = title;\n\t\t\tthis.author = author;\n\t\t\tthis.url = url;\n\t\t\tthis.time = time;\n\t\t}\n\n\t\toverride void formatForIRC(void delegate(string) handler)\n\t\t{\n\t\t\tshortenURL(url, (string shortenedURL) {\n\t\t\t\tif (config.action.length)\n\t\t\t\t\thandler(format(\"[%s] %s %s \\\"%s\\\": %s\", this.outer.name, filterIRCName(author), config.action, title, shortenedURL));\n\t\t\t\telse // author is already indicated in title\n\t\t\t\t\thandler(format(\"[%s] %s: %s\", this.outer.name, filterIRCName(title), shortenedURL));\n\t\t\t});\n\t\t}\n\t}\n\nprotected:\n\toverride void getPosts()\n\t{\n\t\thttpGet(config.url, (HttpResponse response, string disconnectReason) {\n\t\t\ttry\n\t\t\t{\n\t\t\t\tenforce(response, disconnectReason);\n\t\t\t\tenforce(response.status / 100 == 2, format(\"HTTP %d (%s)\", response.status, response.statusMessage));\n\n\t\t\t\tauto result = (cast(char[])response.getContent().contents).idup;\n\t\t\t\tstatic import std.utf;\n\t\t\t\tstd.utf.validate(result);\n\n\t\t\t\tstatic import std.file;\n\t\t\t\tscope(failure) std.file.write(\"feed-error.xml\", result);\n\t\t\t\tauto data = new XmlDocument(result);\n\t\t\t\tPost[string] r;\n\t\t\t\tauto feed = data[\"feed\"];\n\n\t\t\t\tforeach (e; feed)\n\t\t\t\t\tif (e.tag == \"entry\")\n\t\t\t\t\t{\n\t\t\t\t\t\tauto key = e[\"id\"].text ~ \" / \" ~ e[\"updated\"].text;\n\n\t\t\t\t\t\tauto published = e.findChild(\"published\");\n\t\t\t\t\t\tSysTime time;\n\t\t\t\t\t\tif (published)\n\t\t\t\t\t\t\ttime = SysTime.fromISOExtString(published.text);\n\t\t\t\t\t\telse\n\t\t\t\t\t\t\ttime = Clock.currTime();\n\n\t\t\t\t\t\tauto post = new FeedPost(e[\"title\"].text, e[\"author\"][\"name\"].text, e[\"link\"].attributes[\"href\"], time);\n\t\t\t\t\t\tr[key] = post;\n\t\t\t\t\t}\n\n\t\t\t\thandlePosts(r);\n\t\t\t}\n\t\t\tcatch (Exception e)\n\t\t\t\thandleError(e.msg);\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/sources/web/reddit.d",
    "content": "/*  Copyright (C) 2011, 2014, 2015, 2018, 2023  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sources.web.reddit;\n\nimport std.exception;\nimport std.string;\nimport std.regex;\nimport std.datetime;\n\nimport ae.utils.xml;\nimport ae.net.http.client;\nimport ae.utils.time;\n\nimport dfeed.bitly;\nimport dfeed.common;\nimport dfeed.sources.web.webpoller;\n\nclass Reddit : WebPoller\n{\n\tstatic struct Config\n\t{\n\t\tstring subreddit;\n\t\tstring filter;\n\t\tint pollPeriod = 60;\n\t}\n\n\tthis(Config config)\n\t{\n\t\tthis.config = config;\n\t\tthis.filter = regex(config.filter);\n\t\tsuper(\"Reddit-\" ~ config.subreddit, config.pollPeriod);\n\t}\n\nprivate:\n\timmutable Config config;\n\tRegex!char filter;\n\n\tstatic string getAuthor(string description)\n\t{\n\t\tauto doc = new XmlDocument(description);\n\t\treturn strip(doc[1].text);\n\t}\n\n\tclass RedditPost : Post\n\t{\n\t\tstring title;\n\t\tstring author;\n\t\tstring url;\n\n\t\tthis(string title, string author, string url, SysTime time)\n\t\t{\n\t\t\tthis.title = title;\n\t\t\tthis.author = author;\n\t\t\tthis.url = url;\n\t\t\tthis.time = time;\n\t\t}\n\n\t\toverride void formatForIRC(void delegate(string) handler)\n\t\t{\n\t\t\t// TODO: use redd.it\n\t\t\tshortenURL(url, (string shortenedURL) {\n\t\t\t\thandler(format(\"[Reddit] %s posted \\\"%s\\\": %s\", author, title, shortenedURL));\n\t\t\t});\n\t\t}\n\t}\n\nprotected:\n\toverride void getPosts()\n\t{\n\t\tauto url = \"http://www.reddit.com/r/\"~config.subreddit~\"/.rss\";\n\t\thttpGet(url, (HttpResponse response, string disconnectReason) {\n\t\t\ttry\n\t\t\t{\n\t\t\t\tenforce(response, disconnectReason);\n\t\t\t\tenforce(response.status / 100 == 2, format(\"HTTP %d (%s)\", response.status, response.statusMessage));\n\n\t\t\t\tauto result = (cast(char[])response.getContent().contents).idup;\n\t\t\t\tstatic import std.utf;\n\t\t\t\tstd.utf.validate(result);\n\n\t\t\t\tstatic import std.file;\n\t\t\t\tscope(failure) std.file.write(\"reddit-error.xml\", result);\n\n\t\t\t\tauto data = new XmlDocument(result);\n\t\t\t\tPost[string] r;\n\n\t\t\t\tauto feed = data[\"rss\"][\"channel\"];\n\t\t\t\tforeach (e; feed)\n\t\t\t\t\tif (e.tag == \"item\")\n\t\t\t\t\t\tif (!match(e[\"title\"].text, filter).empty)\n\t\t\t\t\t\t\tr[e[\"guid\"].text ~ \" / \" ~ e[\"pubDate\"].text] = new RedditPost(\n\t\t\t\t\t\t\t\te[\"title\"].text,\n\t\t\t\t\t\t\t\tgetAuthor(e[\"description\"].text),\n\t\t\t\t\t\t\t\te[\"link\"].text,\n\t\t\t\t\t\t\t\te[\"pubDate\"].text.parseTime!(TimeFormats.RSS)()\n\t\t\t\t\t\t\t);\n\n\t\t\t\thandlePosts(r);\n\t\t\t}\n\t\t\tcatch (Exception e)\n\t\t\t\thandleError(e.msg);\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/sources/web/stackoverflow.d",
    "content": "/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sources.web.stackoverflow;\n\nimport std.string;\nimport std.file;\nimport std.conv;\nimport std.datetime;\n\nimport ae.net.http.client;\nimport ae.utils.json;\nimport ae.utils.text;\n\nimport dfeed.bitly;\nimport dfeed.common;\nimport dfeed.sources.web.webpoller;\n\nclass StackOverflow : WebPoller\n{\n\tstatic struct Config\n\t{\n\t\tstring tags;\n\t\tstring key;\n\t\tint pollPeriod = 60;\n\t}\n\n\tthis(Config config)\n\t{\n\t\tthis.config = config;\n\t\tsuper(\"StackOverflow-\" ~ config.tags, config.pollPeriod);\n\t}\n\nprivate:\n\tConfig config;\n\n\tclass Question : Post\n\t{\n\t\tstring title;\n\t\tstring author;\n\t\tstring url;\n\n\t\tthis(string title, string author, string url, SysTime time)\n\t\t{\n\t\t\tthis.title = title;\n\t\t\tthis.author = author;\n\t\t\tthis.url = url;\n\t\t\tthis.time = time;\n\t\t}\n\n\t\toverride void formatForIRC(void delegate(string) handler)\n\t\t{\n\t\t\tshortenURL(url, (string shortenedURL) {\n\t\t\t\thandler(format(\"[StackOverflow] %s asked \\\"%s\\\": %s\", filterIRCName(author), title, shortenedURL));\n\t\t\t});\n\t\t}\n\t}\n\nprotected:\n\toverride void getPosts()\n\t{\n\t\tauto url = \"http://api.stackexchange.com/2.2/questions?pagesize=10&order=desc&sort=creation&site=stackoverflow&tagged=\" ~ config.tags ~\n\t\t\t(config.key ? \"&key=\" ~ config.key : \"\");\n\t\thttpGet(url, (string json) {\n\t\t\tif (json == \"<html><body><h1>408 Request Time-out</h1>\\nYour browser didn't send a complete request in time.\\n</body></html>\\n\")\n\t\t\t{\n\t\t\t\tlog(\"Server reports request timeout\");\n\t\t\t\treturn; // Temporary problem\n\t\t\t}\n\n\t\t\tif (json.contains(\"<title>We are Offline</title>\"))\n\t\t\t{\n\t\t\t\tlog(\"Server reports SO is offline\");\n\t\t\t\treturn; // Temporary problem\n\t\t\t}\n\n\t\t\tstruct JsonQuestionOwner\n\t\t\t{\n\t\t\t\tint reputation;\n\t\t\t\tint user_id;\n\t\t\t\tstring user_type;\n\t\t\t\tint accept_rate;\n\t\t\t\tstring profile_image;\n\t\t\t\tstring display_name;\n\t\t\t\tstring link;\n\t\t\t}\n\n\t\t\tstruct JsonQuestion\n\t\t\t{\n\t\t\t\tstring[] tags;\n\t\t\t\tbool is_answered;\n\t\t\t\tint answer_count, accepted_answer_id, favorite_count;\n\t\t\t\tint closed_date;\n\t\t\t\tstring closed_reason;\n\t\t\t\tint bounty_closes_date, bounty_amount;\n\t\t\t\tstring question_timeline_url, question_comments_url, question_answers_url;\n\t\t\t\tint question_id;\n\t\t\t\tint locked_date;\n\t\t\t\tJsonQuestionOwner owner;\n\t\t\t\tint creation_date, last_edit_date, last_activity_date;\n\t\t\t\tint up_vote_count, down_vote_count, view_count, score;\n\t\t\t\tbool community_owned;\n\t\t\t\tstring title, link;\n\t\t\t}\n\n\t\t\tstruct JsonQuestions\n\t\t\t{\n\t\t\t\tJsonQuestion[] items;\n\t\t\t\tbool has_more;\n\t\t\t\tint quota_max, quota_remaining, backoff;\n\t\t\t}\n\n\t\t\tscope(failure) std.file.write(\"so-error.txt\", json);\n\t\t\tauto data = jsonParse!(JsonQuestions)(json);\n\t\t\tPost[string] r;\n\n\t\t\tforeach (q; data.items)\n\t\t\t\tr[text(q.question_id)] = new Question(q.title, q.owner.display_name, format(\"http://stackoverflow.com/q/%d\", q.question_id), SysTime(unixTimeToStdTime(q.creation_date)));\n\n\t\t\thandlePosts(r);\n\t\t}, (string error) {\n\t\t\thandleError(error);\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/sources/web/webpoller.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.sources.web.webpoller;\n\nimport ae.sys.timing;\nimport ae.utils.aa;\n\nimport std.algorithm;\nimport std.array;\nimport std.random;\nimport std.string;\n\nimport dfeed.common;\n\n/// Periodically polls a resource (e.g. on the web), and announces new posts.\nclass WebPoller : NewsSource\n{\n\t/// If there are more than LIMIT new posts,\n\t/// assume a glitch happened and don't announce them.\n\tenum LIMIT = 5;\n\n\tthis(string name, int pollPeriod)\n\t{\n\t\tsuper(name);\n\t\tthis.pollPeriod = pollPeriod;\n\t}\n\n\toverride void start()\n\t{\n\t\tgetPosts();\n\t}\n\n\toverride void stop()\n\t{\n\t\tif (timerTask)\n\t\t\tclearTimeout(timerTask);\n\t\telse\n\t\t\tstopping = true;\n\t}\n\nprivate:\n\tint pollPeriod;\n\tbool[string] oldPosts;\n\tbool first = true;\n\tbool stopping;\n\tTimerTask timerTask;\n\n\tvoid scheduleNextRequest()\n\t{\n\t\tif (stopping) return;\n\n\t\t// Use a jitter to avoid making multiple simultaneous requests\n\t\tauto delay = pollPeriod + uniform(-5, 5);\n\t\tlog(format(\"Next poll in %d seconds\", delay));\n\t\ttimerTask = setTimeout(&startNextRequest, delay.seconds);\n\t}\n\n\tvoid startNextRequest()\n\t{\n\t\ttimerTask = null;\n\t\tlog(\"Running...\");\n\t\tgetPosts();\n\t}\n\nprotected:\n\tvoid handlePosts(Post[string] posts)\n\t{\n\t\tPost[string] newPosts;\n\t\tlog(format(\"Got %d posts\", posts.length));\n\t\tforeach (id, q; posts)\n\t\t{\n\t\t\tif (!first && !(id in oldPosts))\n\t\t\t\tnewPosts[id] = q;\n\t\t\toldPosts[id] = true;\n\t\t}\n\t\tfirst = false;\n\n\t\tif (newPosts.length > LIMIT)\n\t\t\treturn handleError(\"Too many posts, aborting!\");\n\n\t\tauto newPostList = newPosts.byPair.array();\n\t\tnewPostList.sort!`a.value.time < b.value.time`();\n\t\tforeach (pair; newPostList)\n\t\t{\n\t\t\tlog(format(\"Announcing %s\", pair.key));\n\t\t\tannouncePost(pair.value, Fresh.yes);\n\t\t}\n\n\t\tscheduleNextRequest();\n\t}\n\n\tvoid handleError(string message)\n\t{\n\t\tlog(format(\"WebPoller error: %s\", message));\n\n\t\tscheduleNextRequest();\n\t}\n\n\t/// Asynchronously fetch new posts, and call handlePosts or handleError when done.\n\tabstract void getPosts();\n}\n"
  },
  {
    "path": "src/dfeed/web/captcha/common.d",
    "content": "/*  Copyright (C) 2012, 2014, 2015, 2016, 2018, 2021, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.captcha.common;\n\nimport std.exception;\n\npublic import ae.net.ietf.url : UrlParameters;\n\nclass Captcha\n{\n\t/// Get a HTML fragment to insert into the HTML form to present a challenge to the user.\n\t/// If showing the form again in response to a wrong CAPTCHA solution,\n\t/// the error data passed to the verify handler should be supplied.\n\tabstract string getChallengeHtml(CaptchaErrorData error = null);\n\n\t/// Get a description of the challenge for logging purposes.\n\t/// Returns null if not available.\n\t/// Must be called before verify() as verify may invalidate the challenge.\n\tstring getChallengeDescription(UrlParameters fields)\n\t{\n\t\treturn null;\n\t}\n\n\t/// Get a description of the user's response for logging purposes.\n\t/// Returns null if not available.\n\tstring getResponseDescription(UrlParameters fields)\n\t{\n\t\treturn null;\n\t}\n\n\t/// Check whether a CAPTCHA attempt is included in the form\n\t/// (check for the presence of fields added by getChallengeHtml).\n\tabstract bool isPresent(UrlParameters fields);\n\n\t/// Verify the correctness of the user's CAPTCHA solution.\n\t/// handler can be called asynchronously.\n\tabstract void verify(UrlParameters fields, string ip, void delegate(bool success, string errorMessage, CaptchaErrorData errorData) handler);\n}\n\n/// Opaque class for preserving error data.\nclass CaptchaErrorData\n{\n}\n\npackage Captcha[string] captchas;\n\n/// Try all registered captchas to get a response description from a single form field.\n/// Returns null if no captcha recognizes the field as its response field.\nstring getCaptchaResponseFromField(string fieldName, string fieldValue)\n{\n\t// Create a minimal UrlParameters with just this field\n\tUrlParameters fields;\n\tfields[fieldName] = fieldValue;\n\n\tforeach (captcha; captchas.byValue)\n\t{\n\t\tif (captcha is null)\n\t\t\tcontinue;\n\t\tauto desc = captcha.getResponseDescription(fields);\n\t\tif (desc !is null)\n\t\t\treturn desc;\n\t}\n\treturn null;\n}\n\n/// Get the CAPTCHA response description from all registered captchas given form fields.\n/// Returns null if no captcha recognizes the fields.\nstring getCaptchaResponseDescription(UrlParameters fields)\n{\n\tforeach (captcha; captchas.byValue)\n\t{\n\t\tif (captcha is null)\n\t\t\tcontinue;\n\t\tauto desc = captcha.getResponseDescription(fields);\n\t\tif (desc !is null)\n\t\t\treturn desc;\n\t}\n\treturn null;\n}\n\nstatic this()\n{\n\tcaptchas[\"none\"] = null;\n}\n"
  },
  {
    "path": "src/dfeed/web/captcha/dcaptcha.d",
    "content": "/*  Copyright (C) 2012, 2014, 2015, 2017, 2018, 2020, 2021, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.captcha.dcaptcha;\n\nimport std.algorithm : any;\nimport std.string : strip, icmp, replace, format;\n\nimport ae.utils.text;\nimport ae.utils.xmllite : encodeEntities;\n\nimport dcaptcha.dcaptcha;\n\nimport dfeed.loc;\nimport dfeed.web.captcha.common;\n\nfinal class Dcaptcha : Captcha\n{\n\tstatic Challenge[string] challenges;\n\n\tstring createChallenge()\n\t{\n\t\tauto challenge = getCaptcha();\n\t\tauto key = randomString();\n\t\tchallenges[key] = challenge;\n\t\treturn key;\n\t}\n\n\toverride string getChallengeHtml(CaptchaErrorData errorData)\n\t{\n\t\tauto key = createChallenge();\n\t\tauto challenge = challenges[key];\n\n\t\treturn\n\t\t\tchallenge.question.encodeEntities() ~ \"\\n\" ~\n\t\t\t`<pre>` ~ challenge.code.encodeEntities() ~ `</pre>` ~\n\t\t\t`<input type=\"hidden\" name=\"dcaptcha_challenge_field\" value=\"` ~ key ~ `\">` ~\n\t\t\t`<input type=\"hidden\" \"dcaptcha_response_field\"></input>` ~\n\t\t\t`<input name=\"dcaptcha_response_field\"></input>` ~\n\t\t\t`<p><b>` ~ _!`Hint` ~ `</b>: ` ~ challenge.hint ~ `</p>` ~\n\t\t\t`<p>` ~ _!\"Is the CAPTCHA too hard?\\nRefresh the page to get a different question,\\nor ask in the %s#d IRC channel on Libera.Chat%s.\"\n\t\t\t\t.replace(\"\\n\", `<br>`)\n\t\t\t\t.format(`<a href=\"https://web.libera.chat/#d\">`, `</a>`) ~\n\t\t\t`</p>`\n\t\t;\n\t}\n\n\toverride string getChallengeDescription(UrlParameters fields)\n\t{\n\t\tif (!isPresent(fields))\n\t\t\treturn null;\n\t\tauto key = fields[\"dcaptcha_challenge_field\"];\n\t\tauto pchallenge = key in challenges;\n\t\tif (!pchallenge)\n\t\t\treturn null;\n\t\treturn pchallenge.question ~ \"\\n\" ~ pchallenge.code;\n\t}\n\n\toverride string getResponseDescription(UrlParameters fields)\n\t{\n\t\tif (\"dcaptcha_response_field\" !in fields)\n\t\t\treturn null;\n\t\treturn fields[\"dcaptcha_response_field\"];\n\t}\n\n\toverride bool isPresent(UrlParameters fields)\n\t{\n\t\treturn \"dcaptcha_challenge_field\" in fields && \"dcaptcha_response_field\" in fields;\n\t}\n\n\toverride void verify(UrlParameters fields, string ip, void delegate(bool success, string errorMessage, CaptchaErrorData errorData) handler)\n\t{\n\t\tassert(isPresent(fields));\n\n\t\tauto key = fields[\"dcaptcha_challenge_field\"];\n\n\t\tauto pchallenge = key in challenges;\n\t\tif (!pchallenge)\n\t\t\treturn handler(false, _!\"Unknown or expired CAPTCHA challenge\", null);\n\t\tauto challenge = *pchallenge;\n\t\tchallenges.remove(key);\n\n\t\tauto response = fields[\"dcaptcha_response_field\"].strip();\n\n\t\tbool correct = challenge.answers.any!(answer => icmp(answer, response) == 0);\n\n\t\treturn handler(correct, correct ? null : _!\"The answer is incorrect\", null);\n\t}\n}\n\nstatic this()\n{\n\tcaptchas[\"dcaptcha\"] = new Dcaptcha();\n}\n"
  },
  {
    "path": "src/dfeed/web/captcha/dummy.d",
    "content": "/*  Copyright (C) 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.captcha.dummy;\n\nimport dfeed.loc;\nimport dfeed.web.captcha.common;\n\n/// A dummy CAPTCHA for testing purposes.\n/// Simply presents an \"I am not a robot\" checkbox.\n/// NOT suitable for production use.\nfinal class DummyCaptcha : Captcha\n{\n\toverride string getChallengeHtml(CaptchaErrorData errorData)\n\t{\n\t\treturn\n\t\t\t`<label style=\"display: block; margin: 1em 0;\">` ~\n\t\t\t`<input type=\"checkbox\" name=\"dummy_captcha_checkbox\" value=\"1\"> ` ~\n\t\t\t_!`I am not a robot` ~\n\t\t\t`</label>`;\n\t}\n\n\toverride string getChallengeDescription(UrlParameters fields)\n\t{\n\t\treturn \"Dummy CAPTCHA: I am not a robot checkbox\";\n\t}\n\n\toverride string getResponseDescription(UrlParameters fields)\n\t{\n\t\tif (\"dummy_captcha_checkbox\" !in fields)\n\t\t\treturn null;\n\t\treturn fields.get(\"dummy_captcha_checkbox\", \"\") == \"1\" ? \"checked\" : \"unchecked\";\n\t}\n\n\toverride bool isPresent(UrlParameters fields)\n\t{\n\t\treturn (\"dummy_captcha_checkbox\" in fields) !is null;\n\t}\n\n\toverride void verify(UrlParameters fields, string ip, void delegate(bool success, string errorMessage, CaptchaErrorData errorData) handler)\n\t{\n\t\tbool checked = fields.get(\"dummy_captcha_checkbox\", \"\") == \"1\";\n\t\thandler(checked, checked ? null : _!\"Please confirm you are not a robot\", null);\n\t}\n}\n\nstatic this()\n{\n\tcaptchas[\"dummy\"] = new DummyCaptcha();\n}\n"
  },
  {
    "path": "src/dfeed/web/captcha/package.d",
    "content": "/*  Copyright (C) 2012, 2014, 2015, 2016, 2018, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.captcha;\n\nimport std.exception;\n\npublic import dfeed.web.captcha.common;\n\nstatic import dfeed.web.captcha.dcaptcha;\nstatic import dfeed.web.captcha.dummy;\nstatic import dfeed.web.captcha.recaptcha;\n\nCaptcha getCaptcha(string name)\n{\n\tauto pcaptcha = name in captchas;\n\tenforce(name, \"CAPTCHA mechanism unknown or not configured: \" ~ name);\n\treturn *pcaptcha;\n}\n"
  },
  {
    "path": "src/dfeed/web/captcha/recaptcha.d",
    "content": "/*  Copyright (C) 2012, 2014, 2015, 2018, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.captcha.recaptcha;\n\nimport std.string;\n\nimport ae.net.http.client;\nimport ae.utils.sini;\n\nimport dfeed.loc;\nimport dfeed.web.captcha.common;\n\nclass Recaptcha : Captcha\n{\n\tstatic struct Config { string publicKey, privateKey; }\n\timmutable Config config;\n\tthis(Config config) { this.config = config; }\n\n\toverride string getChallengeHtml(CaptchaErrorData errorData)\n\t{\n\t\tstring error = errorData ? (cast(RecaptchaErrorData)errorData).code : null;\n\n\t\tauto publicKey = config.publicKey;\n\t\treturn\n\t\t\t`<script type=\"text/javascript\" src=\"http://www.google.com/recaptcha/api/challenge?k=` ~ publicKey ~ (error ? `&error=` ~ error : ``) ~ `\">` ~\n\t\t\t`</script>` ~\n\t\t\t`<noscript>` ~\n\t\t\t\t`<iframe src=\"http://www.google.com/recaptcha/api/noscript?k=` ~ publicKey ~ (error ? `&error=` ~ error : ``) ~ `\"` ~\n\t\t\t\t\t` height=\"300\" width=\"500\" frameborder=\"0\"></iframe><br>` ~\n\t\t\t\t`<textarea name=\"recaptcha_challenge_field\" rows=\"3\" cols=\"40\">` ~\n\t\t\t\t`</textarea>` ~\n\t\t\t\t`<input type=\"hidden\" name=\"recaptcha_response_field\" value=\"manual_challenge\">` ~\n\t\t\t`</noscript>`;\n\t}\n\n\toverride bool isPresent(UrlParameters fields)\n\t{\n\t\treturn \"recaptcha_challenge_field\" in fields && \"recaptcha_response_field\" in fields;\n\t}\n\n\toverride void verify(UrlParameters fields, string ip, void delegate(bool success, string errorMessage, CaptchaErrorData errorData) handler)\n\t{\n\t\tassert(isPresent(fields));\n\n\t\thttpPost(\"http://www.google.com/recaptcha/api/verify\", UrlParameters([\n\t\t\t\"privatekey\" : config.privateKey,\n\t\t\t\"remoteip\" : ip,\n\t\t\t\"challenge\" : fields[\"recaptcha_challenge_field\"],\n\t\t\t\"response\" : fields[\"recaptcha_response_field\"],\n\t\t]), (string result) {\n\t\t\tauto lines = result.splitLines();\n\t\t\tif (lines[0] == \"true\")\n\t\t\t\thandler(true, null, null);\n\t\t\telse\n\t\t\tif (lines.length >= 2)\n\t\t\t\thandler(false, \"reCAPTCHA error: \" ~ errorText(lines[1]), new RecaptchaErrorData(lines[1]));\n\t\t\telse\n\t\t\t\thandler(false, \"Unexpected reCAPTCHA reply: \" ~ result, null);\n\t\t}, (string error) {\n\t\t\thandler(false, error, null);\n\t\t});\n\t}\n\n\tprivate static string errorText(string code)\n\t{\n\t\tswitch (code)\n\t\t{\n\t\t\tcase \"incorrect-captcha-sol\":\n\t\t\t\treturn _!\"The CAPTCHA solution was incorrect\";\n\t\t\tcase \"captcha-timeout\":\n\t\t\t\treturn _!\"The solution was received after the CAPTCHA timed out\";\n\t\t\tdefault:\n\t\t\t\treturn code;\n\t\t}\n\t}\n}\n\nclass RecaptchaErrorData : CaptchaErrorData\n{\n\tstring code;\n\tthis(string code) { this.code = code; }\n\toverride string toString() { return code; }\n}\n\nstatic this()\n{\n\timport dfeed.common : createService;\n\tif (auto c = createService!Recaptcha(\"apis/recaptcha\"))\n\t\tcaptchas[\"recaptcha\"] = c;\n}\n"
  },
  {
    "path": "src/dfeed/web/lint.d",
    "content": "﻿/*  Copyright (C) 2015, 2016, 2017, 2018, 2020, 2021, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.lint;\n\nimport core.time;\n\nimport std.algorithm;\nimport std.conv : to;\nimport std.datetime.systime;\nimport std.exception;\nimport std.functional : not;\nimport std.range;\nimport std.regex;\nimport std.string;\n\nimport ae.sys.persistence;\nimport ae.utils.aa; // `require` polyfill\nimport ae.utils.array : contains;\nimport ae.utils.regex;\n\nimport dfeed.loc;\nimport dfeed.message;\nimport dfeed.web.markdown;\nimport dfeed.web.posting;\nimport dfeed.web.web.part.postbody : reURL;\nimport dfeed.web.web.postinfo : getPost;\n\nclass LintRule\n{\n\t/// ID string - used in forms for button names, etc.\n\tabstract @property string id();\n\n\t/// Short description - visible by default\n\tabstract @property string shortDescription();\n\n\t/// Long description - shown on request, should contain rationale (HTML)\n\tabstract @property string longDescription();\n\n\t/// Check if the lint rule is triggered.\n\t/// Return true if there is a problem with the post according to this rule.\n\tabstract bool check(in ref PostDraft);\n\n\t/// Should the \"Fix it for me\" option be presented to the user?\n\tabstract bool canFix(in ref PostDraft);\n\n\t/// Fix up the post according to the rule.\n\tabstract void fix(ref PostDraft);\n}\n\nclass NotQuotingRule : LintRule\n{\n\toverride @property string id() { return \"notquoting\"; }\n\toverride @property string shortDescription() { return _!\"Parent post is not quoted.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"When replying to someone's post, you should provide some context for your replies by quoting the revelant parts of their post.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Depending on the software (or its configuration) used to read your message, it may not be obvious which post you're replying to.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Thus, when writing a reply, don't delete all quoted text: instead, leave just enough to provide context for your reply.\" ~ \" \" ~\n\t\t\t_!\"You can also insert your replies inline (interleaved with quoted text) to address specific parts of the parent post.\" ~ \"</p>\";\n\t}\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tif (!hasParent(draft))\n\t\t\treturn false;\n\t\tauto lines = draft.clientVars.get(\"text\", null).splitLines();\n\t\treturn !lines.canFind!(line => line.startsWith(\">\"));\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return true; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\tauto text = getParent(draft).replyTemplate().content.strip();\n\t\tdraft.clientVars[\"text\"] = text ~ \"\\n\\n\" ~ draft.clientVars.get(\"text\", null);\n\t\t(new OverquotingRule).fix(draft);\n\t}\n}\n\nstring[] getLines(in ref PostDraft draft)\n{\n\treturn draft.clientVars.get(\"text\", null).strip().splitLines();\n}\n\nbool isWroteLine(string line) { return line.startsWith(\"On \") && line.canFind(\", \") && line.endsWith(\" wrote:\"); }\n\nstring[] getWroteLines(in ref PostDraft draft)\n{\n\treturn getLines(draft).filter!isWroteLine.array();\n}\n\nstring[] getNonQuoteLines(in ref PostDraft draft)\n{\n\treturn getLines(draft).filter!(line => !line.startsWith(\">\") && !line.isWroteLine).array();\n}\n\nbool hasParent(in ref PostDraft draft) { return \"parent\" in draft.serverVars && getPost(draft.serverVars[\"parent\"]) !is null; }\nRfc850Post getParent(in ref PostDraft draft) { return getPost(draft.serverVars[\"parent\"]).enforce(\"Can't find parent post\"); }\n\nstring[] getParentLines(in ref PostDraft draft)\n{\n\treturn getParent(draft).content.strip().splitLines();\n}\n\nstring[] getQuotedParentLines(in ref PostDraft draft)\n{\n\treturn getParent(draft).replyTemplate().content.strip().splitLines();\n}\n\nclass WrongParentRule : LintRule\n{\n\toverride @property string id() { return \"wrongparent\"; }\n\toverride @property string shortDescription() { return _!\"You are quoting a post other than the parent.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"When replying a message, the message you are replying to is referenced in the post's headers.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Depending on the software (or its configuration) used to read your message, your message may be displayed below its parent post.\" ~ \" \" ~\n\t\t\t_!\"If your message contains a reply to a different post, following the conversation may become somewhat confusing.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Thus, make sure to click the \\\"Reply\\\" link on the actual post you're replying to, and quote the parent post for context.\" ~ \"</p>\";\n\t}\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tif (!hasParent(draft))\n\t\t\treturn false;\n\t\tauto wroteLines = getWroteLines(draft);\n\t\treturn wroteLines.length && !wroteLines.canFind(getQuotedParentLines(draft)[0]);\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return false; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\t//(new NotQuotingRule).fix(draft);\n\t\tassert(false);\n\t}\n}\n\nclass NoParentRule : LintRule\n{\n\toverride @property string id() { return \"noparent\"; }\n\toverride @property string shortDescription() { return _!\"Parent post is not indicated.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"When quoting someone's post, you should leave the \\\"On (date), (author) wrote:\\\" line.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Depending on the software (or its configuration) used to read your message, it may not be obvious which post you're replying to.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Thus, this line provides important context for your replies regarding the structure of the conversation.\" ~ \"</p>\";\n\t}\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tif (!hasParent(draft))\n\t\t\treturn false;\n\t\treturn getWroteLines(draft).length == 0 && getLines(draft).canFind!(line => line.startsWith(\">\"));\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return true; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\tauto qpLines = getQuotedParentLines(draft);\n\t\tauto lines = getLines(draft);\n\t\tforeach (i, line; lines)\n\t\t\tif (line.length > 5 && line.startsWith(\">\") && qpLines.canFind(line))\n\t\t\t{\n\t\t\t\tauto j = i;\n\t\t\t\twhile (j && lines[j-1].startsWith(\">\"))\n\t\t\t\t\tj--;\n\t\t\t\tlines = lines[0..j] ~ qpLines[0] ~ lines[j..$];\n\t\t\t\tdraft.clientVars[\"text\"] = lines.join(\"\\n\");\n\t\t\t\t(new OverquotingRule).fix(draft);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t// Can't find any bit of quoted text in parent\n\t\t(new NotQuotingRule).fix(draft);\n\t}\n}\n\nclass MultiParentRule : LintRule\n{\n\toverride @property string id() { return \"multiparent\"; }\n\toverride @property string shortDescription() { return _!\"You are quoting multiple posts.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"When replying a message, the message you are replying to is referenced in the post's headers.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Depending on the software (or its configuration) used to read your message, your message may be displayed below its parent post.\" ~ \" \" ~\n\t\t   \"If your message contains a reply to a different post, following the conversation may become somewhat confusing.</p>\" ~\n\t\t\"<p>\" ~ _!\"Thus, you should avoid replying to multiple posts in one reply.\" ~ \" \" ~\n\t\t   _!\"If applicable, you should split your message into several, each as a reply to its corresponding parent post.\" ~ \"</p>\";\n\t}\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tif (!hasParent(draft))\n\t\t\treturn false;\n\t\treturn getWroteLines(draft).sort().uniq().walkLength > 1;\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return false; }\n\n\toverride void fix(ref PostDraft draft) { assert(false); }\n}\n\nclass TopPostingRule : LintRule\n{\n\toverride @property string id() { return \"topposting\"; }\n\toverride @property string shortDescription() { return _!\"You are top-posting.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"When replying a message, it is generally preferred to add your reply under the quoted parent text.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Depending on the software (or its configuration) used to read your message, your message may not be displayed below its parent post.\" ~ \" \" ~\n\t\t   _!\"In such cases, readers would need to first read the quoted text below your reply for context.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Thus, you should add your reply below the quoted text (or reply to individual paragraphs inline), rather than above it.\" ~ \"</p>\";\n\t}\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tif (!hasParent(draft))\n\t\t\treturn false;\n\n\t\tauto lines = getLines(draft);\n\t\tbool inQuote;\n\t\tforeach (line; lines)\n\t\t{\n\t\t\tif (line.startsWith(\">\"))\n\t\t\t\tinQuote = true;\n\t\t\telse\n\t\t\t\tif (inQuote)\n\t\t\t\t\treturn false;\n\t\t}\n\t\treturn inQuote;\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return true; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\tauto lines = getLines(draft);\n\t\tauto start = lines.countUntil!(line => line.startsWith(\">\"));\n\t\tif (start && lines[start-1].isWroteLine())\n\t\t\tstart--;\n\t\tlines = lines[start..$] ~ [string.init] ~ lines[0..start];\n\n\t\tif (!lines[0].isWroteLine())\n\t\t{\n\t\t\tauto i = lines.countUntil!isWroteLine();\n\t\t\tif (i > 0)\n\t\t\t\tlines = [lines[i]] ~ lines[0..i] ~ lines[i+1..$];\n\t\t}\n\n\t\tdraft.clientVars[\"text\"] = lines.join(\"\\n\").strip();\n\t}\n}\n\nclass OverquotingRule : LintRule\n{\n\toverride @property string id() { return \"overquoting\"; }\n\toverride @property string shortDescription() { return _!\"You are overquoting.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"The ratio between quoted and added text is vastly disproportional.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Quoting should be limited to the amount necessary to provide context for your replies.\" ~ \" \" ~\n\t\t   _!\"Quoting posts in their entirety is thus rarely necessary, and is a waste of vertical space.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Please trim the quoted text to just the relevant parts you're addressing in your reply, or add more content to your post.\" ~ \"</p>\";\n\t}\n\n\tbool checkLines(string[] lines)\n\t{\n\t\tauto quoted   = lines.filter!(line =>  line.startsWith(\">\")).map!(line => line.length).sum();\n\t\tauto unquoted = lines.filter!(line => !line.startsWith(\">\")).map!(line => line.length).sum();\n\t\tif (unquoted < 200)\n\t\t\tunquoted = 200;\n\t\treturn unquoted && quoted > unquoted * 4;\n\t}\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tauto lines = draft.clientVars.get(\"text\", null).splitLines();\n\t\treturn checkLines(lines);\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return true; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\tauto lines = draft.clientVars.get(\"text\", null).splitLines();\n\n\t\tstatic string quotePrefix(string s)\n\t\t{\n\t\t\tint i;\n\t\t\tfor (; i<s.length; i++)\n\t\t\t\tif (s[i] == '>' || (s[i] == ' ' && i != 0))\n\t\t\t\t\tcontinue;\n\t\t\t\telse\n\t\t\t\t\tbreak;\n\t\t\treturn s[0..i];\n\t\t}\n\n\t\tstatic size_t quoteLevel(string quotePrefix)\n\t\t{\n\t\t\treturn quotePrefix.count(\">\");\n\t\t}\n\n\t\tbool check()\n\t\t{\n\t\t\tdraft.clientVars[\"text\"] = lines.join(\"\\n\");\n\t\t\treturn !checkLines(lines);\n\t\t}\n\n\t\tif (check())\n\t\t\treturn; // Nothing to do\n\n\t\t// First, try to trim inner posting levels\n\t\tvoid trimBeyond(int trimLevel)\n\t\t{\n\t\t\tbool trimming;\n\t\t\tforeach_reverse (i, s; lines)\n\t\t\t{\n\t\t\t\tauto prefix = quotePrefix(s);\n\t\t\t\tauto level = prefix.count(\">\");\n\t\t\t\tif (level >= trimLevel)\n\t\t\t\t{\n\t\t\t\t\tif (!trimming)\n\t\t\t\t\t{\n\t\t\t\t\t\tlines[i] = prefix ~ \"[...]\";\n\t\t\t\t\t\ttrimming = true;\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t\tlines = lines[0..i] ~ lines[i+1..$];\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t\ttrimming = false;\n\t\t\t}\n\t\t}\n\n\t\tforeach_reverse (trimLevel; 2..6)\n\t\t{\n\t\t\ttrimBeyond(trimLevel);\n\t\t\tif (check())\n\t\t\t\treturn;\n\t\t}\n\n\t\t// Next, try to trim to just the first quoted paragraph\n\t\tstring[] newLines;\n\t\tint sawContent;\n\t\tbool trimming;\n\t\tforeach (line; lines)\n\t\t{\n\t\t\tif (line.startsWith(\">\"))\n\t\t\t{\n\t\t\t\tif (line.strip() == \">\")\n\t\t\t\t{\n\t\t\t\t\tif (!trimming && sawContent > 1)\n\t\t\t\t\t{\n\t\t\t\t\t\tnewLines ~= \">\";\n\t\t\t\t\t\tnewLines ~= \"> [...]\";\n\t\t\t\t\t\ttrimming = true;\n\t\t\t\t\t\tsawContent = 0;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\tif (!line.endsWith(\" wrote:\")\n\t\t\t\t && !line.endsWith(\"[...]\"))\n\t\t\t\t\tsawContent++;\n\t\t\t}\n\t\t\telse\n\t\t\t\ttrimming = false;\n\t\t\tif (!trimming)\n\t\t\t\tnewLines ~= line;\n\t\t}\n\t\tlines = newLines;\n\t\tif (check())\n\t\t\treturn;\n\n\t\t// Lastly, just trim all quoted text\n\t\ttrimBeyond(1);\n\t\tcheck();\n\t}\n}\n\nclass ShortLinkRule : LintRule\n{\n\toverride @property string id() { return \"shortlink\"; }\n\toverride @property string shortDescription() { return _!\"Don't use URL shorteners.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"URL shortening services, such as TinyURL, are useful in cases where space is at a premium, e.g. in IRC or Twitter messages.\" ~ \" \" ~\n\t\t   _!\"In other circumstances, however, they provide little benefit, and have the significant disadvantage of being opaque:\" ~ \" \" ~\n\t\t   _!\"readers can only guess where the link will lead to before they click it.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Additionally, URL shortening services come and go - your link may work today, but might not in a year or two.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Thus, do not use URL shorteners when posting messages online - post the full link instead, even if it seems exceedingly long.\" ~ \" \" ~\n\t\t   _!\"If it is too long to be inserted inline, add it as a footnote instead.\" ~ \"</p>\";\n\t}\n\n\t// http://longurl.org/services\n\tstatic const string[] urlShorteners =\n\t[\"0rz.tw\", \"1link.in\", \"1url.com\", \"2.gp\", \"2big.at\", \"2tu.us\", \"3.ly\", \"307.to\", \"4ms.me\", \"4sq.com\", \"4url.cc\", \"6url.com\", \"7.ly\", \"a.gg\", \"a.nf\", \"aa.cx\", \"abcurl.net\", \"ad.vu\", \"adf.ly\", \"adjix.com\", \"afx.cc\", \"all.fuseurl.com\", \"alturl.com\", \"amzn.to\", \"ar.gy\", \"arst.ch\", \"atu.ca\", \"azc.cc\", \"b23.ru\", \"b2l.me\", \"bacn.me\", \"bcool.bz\", \"binged.it\", \"bit.ly\", \"bizj.us\", \"bloat.me\", \"bravo.ly\", \"bsa.ly\", \"budurl.com\", \"canurl.com\", \"chilp.it\", \"chzb.gr\", \"cl.lk\", \"cl.ly\", \"clck.ru\", \"cli.gs\", \"cliccami.info\", \"clickthru.ca\", \"clop.in\", \"conta.cc\", \"cort.as\", \"cot.ag\", \"crks.me\", \"ctvr.us\", \"cutt.us\", \"dai.ly\", \"decenturl.com\", \"dfl8.me\", \"digbig.com\", \"digg.com\", \"disq.us\", \"dld.bz\", \"dlvr.it\", \"do.my\", \"doiop.com\", \"dopen.us\", \"easyuri.com\", \"easyurl.net\", \"eepurl.com\", \"eweri.com\", \"fa.by\", \"fav.me\", \"fb.me\", \"fbshare.me\", \"ff.im\", \"fff.to\", \"fire.to\", \"firsturl.de\", \"firsturl.net\", \"flic.kr\", \"flq.us\", \"fly2.ws\", \"fon.gs\", \"freak.to\", \"fuseurl.com\", \"fuzzy.to\", \"fwd4.me\", \"fwib.net\", \"g.ro.lt\", \"gizmo.do\", \"gl.am\", \"go.9nl.com\", \"go.ign.com\", \"go.usa.gov\", \"goo.gl\", \"goshrink.com\", \"gurl.es\", \"hex.io\", \"hiderefer.com\", \"hmm.ph\", \"href.in\", \"hsblinks.com\", \"htxt.it\", \"huff.to\", \"hulu.com\", \"hurl.me\", \"hurl.ws\", \"icanhaz.com\", \"idek.net\", \"ilix.in\", \"is.gd\", \"its.my\", \"ix.lt\", \"j.mp\", \"jijr.com\", \"kl.am\", \"klck.me\", \"korta.nu\", \"krunchd.com\", \"l9k.net\", \"lat.ms\", \"liip.to\", \"liltext.com\", \"linkbee.com\", \"linkbun.ch\", \"liurl.cn\", \"ln-s.net\", \"ln-s.ru\", \"lnk.gd\", \"lnk.ms\", \"lnkd.in\", \"lnkurl.com\", \"lru.jp\", \"lt.tl\", \"lurl.no\", \"macte.ch\", \"mash.to\", \"merky.de\", \"migre.me\", \"miniurl.com\", \"minurl.fr\", \"mke.me\", \"moby.to\", \"moourl.com\", \"mrte.ch\", \"myloc.me\", \"myurl.in\", \"n.pr\", \"nbc.co\", \"nblo.gs\", \"nn.nf\", \"not.my\", \"notlong.com\", \"nsfw.in\", \"nutshellurl.com\", \"nxy.in\", \"nyti.ms\", \"o-x.fr\", \"oc1.us\", \"om.ly\", \"omf.gd\", \"omoikane.net\", \"on.cnn.com\", \"on.mktw.net\", \"onforb.es\", \"orz.se\", \"ow.ly\", \"ping.fm\", \"pli.gs\", \"pnt.me\", \"politi.co\", \"post.ly\", \"pp.gg\", \"profile.to\", \"ptiturl.com\", \"pub.vitrue.com\", \"qlnk.net\", \"qte.me\", \"qu.tc\", \"qy.fi\", \"r.im\", \"rb6.me\", \"read.bi\", \"readthis.ca\", \"reallytinyurl.com\", \"redir.ec\", \"redirects.ca\", \"redirx.com\", \"retwt.me\", \"ri.ms\", \"rickroll.it\", \"riz.gd\", \"rt.nu\", \"ru.ly\", \"rubyurl.com\", \"rurl.org\", \"rww.tw\", \"s4c.in\", \"s7y.us\", \"safe.mn\", \"sameurl.com\", \"sdut.us\", \"shar.es\", \"shink.de\", \"shorl.com\", \"short.ie\", \"short.to\", \"shortlinks.co.uk\", \"shorturl.com\", \"shout.to\", \"show.my\", \"shrinkify.com\", \"shrinkr.com\", \"shrt.fr\", \"shrt.st\", \"shrten.com\", \"shrunkin.com\", \"simurl.com\", \"slate.me\", \"smallr.com\", \"smsh.me\", \"smurl.name\", \"sn.im\", \"snipr.com\", \"snipurl.com\", \"snurl.com\", \"sp2.ro\", \"spedr.com\", \"srnk.net\", \"srs.li\", \"starturl.com\", \"su.pr\", \"surl.co.uk\", \"surl.hu\", \"t.cn\", \"t.co\", \"t.lh.com\", \"ta.gd\", \"tbd.ly\", \"tcrn.ch\", \"tgr.me\", \"tgr.ph\", \"tighturl.com\", \"tiniuri.com\", \"tiny.cc\", \"tiny.ly\", \"tiny.pl\", \"tinylink.in\", \"tinyuri.ca\", \"tinyurl.com\", \"tk.\", \"tl.gd\", \"tmi.me\", \"tnij.org\", \"tnw.to\", \"tny.com\", \"to.\", \"to.ly\", \"togoto.us\", \"totc.us\", \"toysr.us\", \"tpm.ly\", \"tr.im\", \"tra.kz\", \"trunc.it\", \"twhub.com\", \"twirl.at\", \"twitclicks.com\", \"twitterurl.net\", \"twitterurl.org\", \"twiturl.de\", \"twurl.cc\", \"twurl.nl\", \"u.mavrev.com\", \"u.nu\", \"u76.org\", \"ub0.cc\", \"ulu.lu\", \"updating.me\", \"ur1.ca\", \"url.az\", \"url.co.uk\", \"url.ie\", \"url360.me\", \"url4.eu\", \"urlborg.com\", \"urlbrief.com\", \"urlcover.com\", \"urlcut.com\", \"urlenco.de\", \"urli.nl\", \"urls.im\", \"urlshorteningservicefortwitter.com\", \"urlx.ie\", \"urlzen.com\", \"usat.ly\", \"use.my\", \"vb.ly\", \"vgn.am\", \"vl.am\", \"vm.lc\", \"w55.de\", \"wapo.st\", \"wapurl.co.uk\", \"wipi.es\", \"wp.me\", \"x.vu\", \"xr.com\", \"xrl.in\", \"xrl.us\", \"xurl.es\", \"xurl.jp\", \"y.ahoo.it\", \"yatuc.com\", \"ye.pe\", \"yep.it\", \"yfrog.com\", \"yhoo.it\", \"yiyd.com\", \"yuarel.com\", \"z0p.de\", \"zi.ma\", \"zi.mu\", \"zipmyurl.com\", \"zud.me\", \"zurl.ws\", \"zz.gd\", \"zzang.kr\", \"›.ws\", \"✩.ws\", \"✿.ws\", \"❥.ws\", \"➔.ws\", \"➞.ws\", \"➡.ws\", \"➨.ws\", \"➯.ws\", \"➹.ws\", \"➽.ws\"];\n\n\tstatic Regex!char re;\n\n\tthis()\n\t{\n\t\tif (re.empty)\n\t\t\tre = regex(`https?://(` ~ urlShorteners.map!escapeRE.join(\"|\") ~ `)/\\w+`);\n\t}\n\n\tstatic string expandURLImpl(string url)\n\t{\n\t\timport std.net.curl;\n\t\tstring result;\n\n\t\tauto http = HTTP(url);\n\t\thttp.setUserAgent(\"DFeed (+https://github.com/CyberShadow/DFeed)\");\n\t\thttp.method = HTTP.Method.head;\n\t\thttp.verifyPeer(false);\n\t\thttp.onReceiveHeader =\n\t\t\t(in char[] key, in char[] value)\n\t\t\t{\n\t\t\t\tif (icmp(key, \"Location\")==0)\n\t\t\t\t\tresult = value.idup;\n\t\t\t};\n\t\thttp.perform();\n\n\t\tenforce(result, _!\"Could not expand URL:\" ~ \" \" ~ url);\n\t\treturn result;\n\t}\n\n\tenum urlCache = \"data/shorturls.json\";\n\tauto expandURL = PersistentMemoized!expandURLImpl(urlCache);\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\treturn !draft.getNonQuoteLines.join(\"\\n\").match(re).empty;\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return true; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\tdraft.clientVars[\"text\"] = draft.getLines()\n\t\t\t.map!(line =>\n\t\t\t\tline.startsWith(\">\")\n\t\t\t\t\t? line\n\t\t\t\t\t: line.replaceAll!(captures => expandURL(captures[0]))(re)\n\t\t\t)\n\t\t\t.join(\"\\n\")\n\t\t;\n\t}\n}\n\nclass LinkInSubjectRule : LintRule\n{\n\toverride @property string id() { return \"linkinsubject\"; }\n\toverride @property string shortDescription() { return _!\"Don't put links in the subject.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"Links in message subjects are usually not clickable.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Please move the link in the message body instead.\" ~ \"</p>\";\n\t}\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tauto subject = draft.clientVars.get(\"subject\", null);\n\t\tif (subject.startsWith(\"Re: \") || !subject.canFind(\"://\"))\n\t\t\treturn false;\n\t\tauto text = draft.clientVars.get(\"text\", null);\n\t\tforeach (url; subject.match(re!reURL))\n\t\t\tif (!text.canFind(url.captures[0]))\n\t\t\t\treturn true;\n\t\treturn false; // all URLs are also in the body\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return true; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\tauto subject = draft.clientVars.get(\"subject\", null);\n\t\tdraft.clientVars[\"text\"] = subject ~ \"\\n\\n\" ~ draft.clientVars.get(\"text\", null);\n\t\t//draft.clientVars[\"subject\"] = subject.replaceAll(reUrl, \"(URL inside)\");\n\t}\n}\n\nclass NecropostingRule : LintRule\n{\n\toverride @property string id() { return \"necroposting\"; }\n\toverride @property string shortDescription() { return _!\"Avoid replying to very old threads.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"The thread / post you are replying to is very old.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"Consider creating a new thread instead of replying to an existing one.\" ~ \"</p>\";\n\t}\n\n\tenum warnThreshold = (4 * 3).weeks;\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tif (!hasParent(draft))\n\t\t\treturn false;\n\t\tauto parent = getParent(draft);\n\t\treturn (Clock.currTime - parent.time) > warnThreshold;\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return true; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\tauto parent = getParent(draft);\n\t\tdraft.clientVars[\"text\"] = parent.url ~ \"\\n\\n\" ~ draft.clientVars.get(\"text\", null);\n\n\t\tauto subject = draft.clientVars.get(\"subject\", null);\n\t\tif (subject.skipOver(\"Re: \"))\n\t\t\tdraft.clientVars[\"subject\"] = subject;\n\n\t\tdraft.serverVars.remove(\"parent\");\n\t}\n}\n\nclass MarkdownHTMLRule : LintRule\n{\n\toverride @property string id() { return \"markdownhtml\"; }\n\toverride @property string shortDescription() { return _!\"HTML-like text was discarded.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"Your message seems to contain content which the Markdown renderer has interpreted as raw HTML.\" ~ \" \" ~\n\t\t\t_!\"Since using raw HTML is not allowed, this content has been discarded from the rendered output.\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"If your intention was to use HTML for formatting, please revise your message to use the %savailable Markdown formatting syntax%s instead.\".format(\n\t\t\t`<a href=\"/help#markdown\">`, `</a>`,\n\t\t) ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"If your intention was to use characters such as &gt; &lt; &amp; verbatim in your message, you can prevent them from being interpreted as special characters by escaping them with a backslash character (\\\\).\" ~ \" \" ~\n\t\t\t_!`Clicking \"Fix it for me\" will apply this escaping automatically.` ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!`Finally, if you do not want any special characters to be treated as formatting at all, you may uncheck the \"Enable Markdown\" checkbox to disable Markdown rendering completely.` ~ \"</p>\" ~\n\t\t\"\";\n\t}\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tif (\"markdown\" !in draft.clientVars)\n\t\t\treturn false;\n\n\t\t// Note: this is an approximation of how text content is\n\t\t// transformed into a post and then to rendered Markdown\n\t\t// (normally that goes through draftToPost and then\n\t\t// unwrapText), but it doesn't matter for this check.\n\t\tauto result = renderMarkdownCached(draft.clientVars.get(\"text\", null));\n\t\tif (result.error)\n\t\t\treturn false;\n\t\treturn result.html.contains(\"<!-- raw HTML omitted -->\");\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return true; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\tstring result;\n\t\tsize_t numEscapes;\n\t\tforeach (c; draft.clientVars.get(\"text\", null))\n\t\t{\n\t\t\tif (c.among('<') && numEscapes % 2 == 0)\n\t\t\t\tresult ~= '\\\\';\n\t\t\telse\n\t\t\tif (c == '\\\\')\n\t\t\t\tnumEscapes++;\n\t\t\telse\n\t\t\t\tnumEscapes = 0;\n\t\t\tresult ~= c;\n\t\t}\n\t\tdraft.clientVars[\"text\"] = result;\n\t}\n}\n\nclass MarkdownEntitiesRule : LintRule\n{\n\timport ae.utils.xml.entities : entities;\n\n\toverride @property string id() { return \"markdownentities\"; }\n\toverride @property string shortDescription() { return _!\"Avoid using HTML entities.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!`HTML character entities, such as \"&amp;mdash;\", are rendered to the corresponding character when using Markdown, but will still appear as you typed them to users of software where Markdown rendering is unavailable or disabled.` ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"As such, it is preferable to use the Unicode characters directly instead of their HTML entity encoded form (e.g. \\\"\\&mdash;\\\" instead of \\\"&amp;mdash;\\\").\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!`If you did not mean to use an HTML entity to represent a character, escape the leading ampersand (&amp;) by prepending a backslash (e.g. \"\\&\").` ~ \"</p>\" ~\n\t\t\"\";\n\t}\n\n\talias reEntity = re!(`(?<=[^\\\\](?:\\\\\\\\)*)&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-fA-F]{1,6});`, \"ig\");\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tif (\"markdown\" !in draft.clientVars)\n\t\t\treturn false;\n\n\t\treturn !!draft.clientVars.get(\"text\", null).matchFirst(reEntity);\n\t}\n\n\toverride bool canFix(in ref PostDraft draft)\n\t{\n\t\treturn !draft.clientVars.get(\"text\", null)\n\t\t\t.matchAll(reEntity)\n\t\t\t.map!(match => match[1])\n\t\t\t.filter!(entityName => entityName.startsWith(\"#\") || entityName in entities)\n\t\t\t.empty;\n\t}\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\tstring dg(Captures!string m)\n\t\t{\n\t\t\tif (m[1].startsWith(\"#x\"))\n\t\t\t\treturn dchar(m[1][2 .. $].to!uint(16)).to!string;\n\t\t\telse\n\t\t\tif (m[1].startsWith(\"#\"))\n\t\t\t\treturn dchar(m[1][1 .. $].to!uint(10)).to!string;\n\t\t\telse\n\t\t\tif (auto c = m[1] in entities)\n\t\t\t\treturn (*c).to!string;\n\t\t\telse\n\t\t\t\treturn m[0];\n\t\t}\n\t\tdraft.clientVars[\"text\"] = draft.clientVars.get(\"text\", null)\n\t\t\t.replaceAll!dg(reEntity);\n\t}\n}\n\nclass MarkdownCodeRule : LintRule\n{\n\toverride @property string id() { return \"markdowncode\"; }\n\toverride @property string shortDescription() { return _!\"A code block may be misformatted.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"It looks like your post may include a code block, but it is not formatted as such. (Click \\\"Save and preview\\\" to see how your message will look once posted.)\" ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!\"When using %sMarkdown formatting%s, you should either wrap code blocks in fences (<code>```</code> lines), or indent all lines by four spaces.\".format(\n\t\t\t`<a href=\"/help#markdown\">`, `</a>`,\n\t\t) ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!`Click \"Fix it for me\" to have the forum software attempt to do this automatically.` ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!`Alternatively, you may uncheck the \"Enable Markdown\" checkbox to disable Markdown rendering completely, which will cause whitespace to be rendered verbatim.` ~ \"</p>\" ~\n\t\t\"\";\n\t}\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\tif (\"markdown\" !in draft.clientVars)\n\t\t\treturn false;\n\n\t\t// Attempt to detect lines with leading indentation which has\n\t\t// been lost after conversion.  Avoid false positives by also\n\t\t// tracking lines which were not indented.\n\n\t\tstruct TrieNode { TrieNode[char] children; bool[2] sawWithIndent; }\n\t\tTrieNode root;\n\n\t\tbool detectIndent(ref string line)\n\t\t{\n\t\t\tif (line.startsWith(\" \") || line.startsWith(\"\\t\"))\n\t\t\t{\n\t\t\t\tline = line.strip();\n\t\t\t\treturn true;\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\t// Detect fenced code block delimiters (``` or ~~~)\n\t\tstatic bool isFenceDelimiter(string line)\n\t\t{\n\t\t\tauto stripped = line.stripLeft();\n\t\t\treturn stripped.startsWith(\"```\") || stripped.startsWith(\"~~~\");\n\t\t}\n\n\t\tauto paragraphs = draft.clientVars.get(\"text\", null).replace(\"\\r\\n\", \"\\n\").split(\"\\n\\n\").map!splitLines.array;\n\t\tif (!paragraphs.canFind!(paragraph => !paragraph.all!detectIndent && !paragraph.all!(not!detectIndent)))\n\t\t\treturn false;\n\n\t\tbool inFencedBlock = false;\n\t\tforeach (line; draft.clientVars.get(\"text\", null).splitLines())\n\t\t{\n\t\t\t// Track fenced code blocks and skip their contents\n\t\t\tif (isFenceDelimiter(line))\n\t\t\t{\n\t\t\t\tinFencedBlock = !inFencedBlock;\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tif (inFencedBlock)\n\t\t\t\tcontinue;\n\n\t\t\tbool isIndented = detectIndent(line);\n\t\t\tTrieNode* n = &root;\n\t\t\tforeach (c; line)\n\t\t\t{\n\t\t\t\tn.sawWithIndent[isIndented] = true;\n\t\t\t\tn = &n.children.require(c, TrieNode.init);\n\t\t\t}\n\t\t\tn.sawWithIndent[isIndented] = true;\n\t\t}\n\n\t\t// Note: this is an approximation of how text content is\n\t\t// transformed into a post and then to rendered Markdown\n\t\t// (normally that goes through draftToPost and then\n\t\t// unwrapText), but it doesn't matter for this check.\n\t\tauto result = renderMarkdownCached(draft.clientVars.get(\"text\", null));\n\t\tif (result.error)\n\t\t\treturn false;\n\n\t\t// We trigger a positive if and only if there exists a line prefix which:\n\t\t// 1. Exists in an INDENTED line in the Markdown source\n\t\t// 2. Does NOT exist in a NON-indented line in the Markdown source\n\t\t// 3. Exists in a NON-indented line in the rendered Markdown HTML\n\n\t\tforeach (line; result.html.splitLines())\n\t\t{\n\t\t\tbool isIndented = detectIndent(line);\n\t\t\tif (isIndented)\n\t\t\t\tcontinue; // Look only at non-indented lines in output\n\n\t\t\tTrieNode* n = &root;\n\t\t\tforeach (c; line)\n\t\t\t{\n\t\t\t\tif (n.sawWithIndent[true] && !n.sawWithIndent[false])\n\t\t\t\t\treturn true; // This prefix only occurred as indented.\n\t\t\t\tn = c in n.children;\n\t\t\t\tif (!n)\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\treturn false;\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return true; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\tauto paragraphs = draft.clientVars.get(\"text\", null).replace(\"\\r\\n\", \"\\n\").split(\"\\n\\n\");\n\t\tforeach (ref paragraph; paragraphs)\n\t\t{\n\t\t\tauto lines = paragraph.split(\"\\n\");\n\t\t\tif (lines.canFind!(line => line.startsWith(\" \") || line.startsWith(\"\\t\")))\n\t\t\t{\n\t\t\t\tforeach (ref line; lines)\n\t\t\t\t\tline = \"    \" ~ line;\n\t\t\t\tparagraph = lines.join(\"\\n\");\n\t\t\t}\n\t\t}\n\t\tdraft.clientVars[\"text\"] = paragraphs.join(\"\\n\\n\").replace(\"\\n\", \"\\r\\n\");\n\t}\n}\n\nunittest\n{\n\timport dfeed.web.markdown : haveMarkdown;\n\tif (!haveMarkdown())\n\t\treturn;\n\n\tbool check(string text)\n\t{\n\t\tPostDraft draft;\n\t\tdraft.clientVars[\"markdown\"] = \"on\";\n\t\tdraft.clientVars[\"text\"] = text;\n\t\treturn (new MarkdownCodeRule).check(draft);\n\t}\n\n\tassert(check(q\"EOF\nif (true)\n    code();\nEOF\"));\n\n\tassert(!check(q\"EOF\ncode\n    code\nEOF\"));\n\n\t// https://github.com/CyberShadow/DFeed/issues/125#issuecomment-830469649\n\tassert(!check(\"    Code\"));\n\n\t// Fenced code blocks with internal indentation should not trigger\n\tassert(!check(q\"EOF\nHere is some code:\n\n```d\nint x = 1;\nstring result = {\n\tswitch(x) {\n\t\tcase 0:\n\t\t\treturn \"hi\";\n\t\tdefault:\n\t\t\treturn \"bye\";\n\t}\n}();\n```\nEOF\"));\n\n\t// Tilde fences should also work\n\tassert(!check(q\"EOF\n~~~\n\tindented content\n~~~\nEOF\"));\n}\n\nclass MarkdownSyntaxRule : LintRule\n{\n\toverride @property string id() { return \"markdownsyntax\"; }\n\toverride @property string shortDescription() { return _!\"Markdown syntax was used, but Markdown is disabled.\"; }\n\toverride @property string longDescription() { return\n\t\t\"<p>\" ~ _!\"It looks like your post may include Markdown syntax, but %sMarkdown%s is not enabled. (Click \\\"Save and preview\\\" to see how your message will look once posted.)\".format(\n\t\t\t`<a href=\"/help#markdown\">`, `</a>`,\n\t\t) ~ \"</p>\" ~\n\t\t\"<p>\" ~ _!`Click \"Fix it for me\" to enable Markdown rendering automatically.` ~ \"</p>\" ~\n\t\t\"\";\n\t}\n\n\toverride bool check(in ref PostDraft draft)\n\t{\n\t\t// Only check when Markdown is DISABLED\n\t\tif (\"markdown\" in draft.clientVars)\n\t\t\treturn false;\n\n\t\tauto text = draft.clientVars.get(\"text\", null);\n\n\t\t// Check for Markdown links: [text](url)\n\t\t// Require the URL part to look like an actual URL (contain :// or start with http/https/www or /)\n\t\tif (!text.matchFirst(re!`\\[.+?\\]\\((https?://|www\\.|/|\\.\\./).+?\\)`).empty)\n\t\t\treturn true;\n\n\t\t// Check for GFM fenced code blocks\n\t\tauto lines = text.splitLines();\n\t\tforeach (line; lines)\n\t\t\tif (line.startsWith(\"```\"))\n\t\t\t\treturn true;\n\n\t\treturn false;\n\t}\n\n\toverride bool canFix(in ref PostDraft draft) { return true; }\n\n\toverride void fix(ref PostDraft draft)\n\t{\n\t\t// Enable Markdown\n\t\tdraft.clientVars[\"markdown\"] = \"on\";\n\t}\n}\n\nunittest\n{\n\tbool check(string text)\n\t{\n\t\tPostDraft draft;\n\t\t// Markdown is disabled (no \"markdown\" in clientVars)\n\t\tdraft.clientVars[\"text\"] = text;\n\t\treturn (new MarkdownSyntaxRule).check(draft);\n\t}\n\n\t// Fenced code blocks\n\tassert(check(\"```\\ncode\\n```\"));\n\tassert(check(\"```d\\ncode\\n```\"));\n\n\t// Links\n\tassert(check(\"Click [here](http://example.com)\"));\n\n\t// Should not trigger when Markdown is enabled\n\tPostDraft draft;\n\tdraft.clientVars[\"markdown\"] = \"on\";\n\tdraft.clientVars[\"text\"] = \"[link](url)\";\n\tassert(!(new MarkdownSyntaxRule).check(draft));\n\n\t// Should not trigger on plain text formatting or D code\n\tassert(!check(\"This is **bold** text\"));\n\tassert(!check(\"This is *italic* text\"));\n\tassert(!check(\"- item\"));\n\tassert(!check(\"> quote\"));\n\tassert(!check(\"Use `code` here\"));\n\tassert(!check(`auto str = r\"raw string\";`));\n\tassert(!check(`auto str = q\"EOS\\ntext\\nEOS\";`));\n\tassert(!check(`auto str = q{code};`));\n\tassert(!check(`myFunctions[0]()`));\n\tassert(!check(`urlHandlers[i](\"http://google.com\")`));\n\tassert(!check(`array[index](param)`));\n\tassert(!check(\"Just plain text\"));\n}\n\n@property LintRule[] lintRules()\n{\n\tstatic LintRule[] result;\n\tif (!result.length)\n\t\tresult = [\n\t\t\tnew NotQuotingRule,\n\t\t\tnew WrongParentRule,\n\t\t\tnew NoParentRule,\n\t\t\tnew MultiParentRule,\n\t\t\tnew TopPostingRule,\n\t\t\tnew OverquotingRule,\n\t\t\tnew ShortLinkRule,\n\t\t\tnew LinkInSubjectRule,\n\t\t\tnew NecropostingRule,\n\t\t\tnew MarkdownHTMLRule,\n\t\t\tnew MarkdownEntitiesRule,\n\t\t\tnew MarkdownCodeRule,\n\t\t\tnew MarkdownSyntaxRule,\n\t\t];\n\treturn result;\n}\n\nLintRule getLintRule(string id)\n{\n\tforeach (rule; lintRules)\n\t\tif (rule.id == id)\n\t\t\treturn rule;\n\tthrow new Exception(\"Unknown lint rule: \" ~ id);\n}\n"
  },
  {
    "path": "src/dfeed/web/list.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Infer a list template from an example,\n/// and allow rendering that template\n/// with arbitrary list items.\n\nmodule dfeed.web.list;\n\nimport std.algorithm;\nimport std.array;\nimport std.exception;\nimport std.range;\nimport std.string;\n\nstruct ListTemplate\n{\n\tstring listPrefix, listSuffix;\n\tstring[] varPrefix;\n\tstring itemSuffix, itemSeparator;\n\n\tstring render(in string[][] items)\n\t{\n\t\treturn\n\t\t\tlistPrefix ~\n\t\t\titems.map!(row =>\n\t\t\t\trow.length.iota.map!(n =>\n\t\t\t\t\tvarPrefix[n] ~ row[n]\n\t\t\t\t).join ~ itemSuffix\n\t\t\t).join(itemSeparator) ~\n\t\t\tlistSuffix;\n\t}\n}\n\nListTemplate inferList(string s, string[][] anchors)\n{\n\tassert(anchors.length > 1 && anchors[0].length > 0, \"Insufficient anchors\");\n\tenforce(anchors.all!(row => row.length == anchors[0].length), \"Jagged anchor specification array\");\n\n\tauto anchorStarts = anchors.map!(row => row.map!(anchor => s.indexOf(anchor)                ).array).array;\n\tauto anchorEnds   = anchors.map!(row => row.map!(anchor => s.indexOf(anchor) + anchor.length).array).array;\n\tenforce(anchorStarts.joiner.all!(i => i>=0), \"An anchor was not found when inferring list\");\n\n\tListTemplate result;\n\tforeach (varIndex; 0..anchors[0].length)\n\t{\n\t\tsize_t l = 0;\n\t\tauto maxL = varIndex ? anchorStarts[0][varIndex] - anchorEnds[0][varIndex-1] : anchorStarts[0][0];\n\t\twhile (l < maxL &&\n\t\t\tanchors.length.iota.all!(rowIndex => s[anchorStarts[rowIndex][varIndex]-l-1] ==\n\t\t\t                                     s[anchorStarts[0       ][varIndex]-l-1]))\n\t\t\tl++;\n\t\tresult.varPrefix ~= s[anchorStarts[0][varIndex]-l .. anchorStarts[0][varIndex]];\n\t}\n\tsize_t l = 0;\n\tauto maxSuffixLength = min(s.length - anchorEnds[$-1][$-1], anchorStarts[1][0] - result.varPrefix[0].length - anchorEnds[0][$-1]);\n\twhile (l < maxSuffixLength &&\n\t\tanchors.length.iota.all!(rowIndex => s[anchorEnds[rowIndex][$-1]+l] ==\n\t\t                                     s[anchorEnds[0       ][$-1]+l]))\n\t\tl++;\n\tresult.itemSuffix = s[anchorEnds[0][$-1] .. anchorEnds[0][$-1]+l];\n\tresult.itemSeparator = s[anchorEnds[0][$-1]+l .. anchorStarts[1][0] - result.varPrefix[0].length];\n\n\tresult.listPrefix = s[0 .. anchorStarts[0][0] - result.varPrefix[0].length];\n\tresult.listSuffix = s[anchorEnds[$-1][$-1] + result.itemSuffix.length .. $];\n\n\treturn result;\n}\n\nunittest\n{\n\tauto s = q\"EOF\n<p>\n\t<a href=\"<?url1?>\"><?title1?></a>,\n\t<a href=\"<?url2?>\"><?title2?></a>\n</p>\nEOF\";\n\n\tauto anchors = [[\"<?url1?>\", \"<?title1?>\"], [\"<?url2?>\", \"<?title2?>\"]];\n\tauto list = inferList(s, anchors);\n\tassert(list.listPrefix == \"<p>\");\n\tassert(list.varPrefix[0] == \"\\n\\t<a href=\\\"\");\n\tassert(list.varPrefix[1] == \"\\\">\");\n\tassert(list.itemSuffix == \"</a>\");\n\tassert(list.itemSeparator == \",\");\n\tassert(list.listSuffix == \"\\n</p>\\n\");\n\n\tauto s2 = list.render(anchors);\n\tassert(s == s2);\n}\n"
  },
  {
    "path": "src/dfeed/web/mailhide.d",
    "content": "/*  Copyright (C) 2015, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.mailhide;\n\nimport std.base64;\nimport std.exception;\nimport std.uri;\n\nimport deimos.openssl.aes;\nimport deimos.openssl.evp;\n\nimport ae.net.ssl.openssl;\nimport ae.utils.sini;\nimport ae.utils.text;\n\nclass MailHide\n{\n\tstatic struct Config\n\t{\n\t\tstring publicKey, privateKey;\n\t}\n\nprivate:\n\timmutable Config config;\n\n\tstring pubKey;\n\tstatic if (!OPENSSL_VERSION_AT_LEAST(1, 1, 0))\n\t{\n\t\tEVP_CIPHER_CTX ctx_storage;\n\n\t\tinout(EVP_CIPHER_CTX*) ctx() pure @safe nothrow @nogc inout scope return\n\t\t{\n\t\t\treturn &ctx_storage;\n\t\t}\n\t}\n\telse\n\t\tEVP_CIPHER_CTX* ctx;\n\n\tvoid aesInit(ubyte[] key)\n\t{\n\t\tenforce(key.length == 16, \"Invalid private key length\");\n\n\t\tstatic if (OPENSSL_VERSION_AT_LEAST(1, 1, 0))\n\t\t\tctx = enforce(EVP_CIPHER_CTX_new(), \"Failed to allocate cipher context\");\n\t\telse\n\t\t\tctx = &ctx_storage;\n\t\tEVP_CIPHER_CTX_init(ctx);\n\t\tEVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), null, key.ptr, null).sslEnforce();\n\t}\n\n\tubyte[] aesEncrypt(ubyte[] plaintext)\n\t{\n\t\tauto valLength = plaintext.length;\n\t\tauto padLength = ((plaintext.length + 15) / 16) * 16;\n\t\tplaintext.length = padLength;\n\t\tplaintext[valLength..padLength] = 16 - valLength % 16;\n\t\t\n\t\tint c_len = cast(uint)plaintext.length + AES_BLOCK_SIZE, f_len = 0;\n\t\tubyte[] ciphertext = new ubyte[c_len];\n\n\t\tEVP_EncryptInit_ex(ctx, null, null, null, null).sslEnforce();\n\t\tEVP_EncryptUpdate(ctx, ciphertext.ptr, &c_len, plaintext.ptr, cast(uint)plaintext.length).sslEnforce();\n\t\tEVP_EncryptFinal_ex(ctx, ciphertext.ptr+c_len, &f_len).sslEnforce();\n\n\t\treturn ciphertext[0..c_len+f_len];\n\t}\n\n\tenum API_MAILHIDE_SERVER = \"http://mailhide.recaptcha.net\";\n\npublic:\n\tthis(Config config)\n\t{\n\t\tthis.config = config;\n\t\taesInit(arrayFromHex(config.privateKey));\n\t}\n\n\tstring getUrl(string email)\n\t{\n\t\treturn API_MAILHIDE_SERVER ~ \"/d\" ~\n\t\t\t\"?hl=en\" ~\n\t\t\t\"&k=\" ~ encodeComponent(pubKey) ~ \n\t\t\t\"&c=\" ~ cast(string)Base64URL.encode(aesEncrypt(cast(ubyte[])email))\n\t\t;\n\t}\n}\n\nMailHide mailHide;\n\nstatic this()\n{\n\timport dfeed.common : createService;\n\tmailHide = createService!MailHide(\"apis/mailhide\");\n}\n"
  },
  {
    "path": "src/dfeed/web/markdown.d",
    "content": "/*  Copyright (C) 2021, 2022  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Markdown rendering using cmark.\n\nmodule dfeed.web.markdown;\n\nimport std.array;\nimport std.concurrency : initOnce;\nimport std.exception;\nimport std.functional : memoize;\nimport std.process;\nimport std.utf : validate;\n\nimport ae.sys.file : readFile;\nimport ae.utils.path;\n\n/// Do we have the ability to render Markdown on this system?\nbool haveMarkdown()\n{\n\t__gshared bool result;\n\treturn initOnce!result(haveExecutable(\"cmark-gfm\"));\n}\n\n/// Render this text as Markdown to HTML now.\nstring renderMarkdown(string s)\n{\n\t// Pre-process the string\n\ts = s\n\t\t.replace(\"\\n-- \\n\", \"\\n\\n-- \\n\") // Disambiguate signatures (from setext headings)\n\t;\n\n\tauto p = pipeProcess([\n\t\t\t\"timeout\", \"1\",\n\t\t\t\"cmark-gfm\",\n\t\t\t\"--hardbreaks\", // paragraphs are unwrapped in formatBody\n\t\t\t\"--extension\", \"table\",\n\t\t\t\"--extension\", \"strikethrough\", \"--strikethrough-double-tilde\",\n\t\t\t\"--extension\", \"autolink\",\n\t\t], Redirect.stdin | Redirect.stdout);\n\t// cmark reads all input before emitting any output, so it's safe\n\t// for us to write all input while not reading anything.\n\tp.stdin.rawWrite(s);\n\tp.stdin.close();\n\tauto result = cast(string)readFile(p.stdout);\n\tp.stdout.close();\n\tauto status = wait(p.pid);\n\tenforce(status != 124, \"Time-out\");\n\tenforce(status == 0, \"cmark failed\");\n\tvalidate(result);\n\n\t// Post-process the results\n\tresult = result\n\t\t.replace(\"<blockquote>\\n\", `<span class=\"forum-quote\"><span class=\"forum-quote-prefix\">&gt; </span>`)\n\t\t.replace(\"\\n</blockquote>\", `</span>`)\n\t\t.replace(\"</blockquote>\", `</span>`)\n\t\t.replace(`<a href=\"`, `<a rel=\"nofollow\" href=\"`)\n\t;\n\n\treturn result;\n}\n\n/// Result of a cached attempt to render some Markdown.\nstruct MarkdownResult\n{\n\tstring html, error;\n}\n\n/// Try to render some Markdown and return a struct indicating success / failure.\n/*private*/ MarkdownResult tryRenderMarkdown(string markdown)\n{\n\ttry\n\t\treturn MarkdownResult(renderMarkdown(markdown), null);\n\tcatch (Exception e)\n\t\treturn MarkdownResult(null, e.msg);\n}\n\n/// Try to render some Markdown, and cache the results.\nalias renderMarkdownCached = memoize!(tryRenderMarkdown, 1024);\n"
  },
  {
    "path": "src/dfeed/web/moderation.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.moderation;\n\nimport std.array : split, join;\nimport std.file : exists, read, rename;\nimport std.stdio : File;\n\nimport ae.utils.text : splitAsciiLines;\n\nenum banListFileName = \"data/banned.txt\";\n\nstring[string] banned;\n\nvoid loadBanList()\n{\n\tif (banListFileName.exists())\n\t\tforeach (string line; splitAsciiLines(cast(string)read(banListFileName)))\n\t\t{\n\t\t\tauto parts = line.split(\"\\t\");\n\t\t\tif (parts.length >= 2)\n\t\t\t\tbanned[parts[0]] = parts[1..$].join(\"\\t\");\n\t\t}\n}\n\nvoid saveBanList()\n{\n\tconst inProgressFileName = banListFileName ~ \".inprogress\";\n\tauto f = File(inProgressFileName, \"wb\");\n\tforeach (key, reason; banned)\n\t\tf.writefln(\"%s\\t%s\", key, reason);\n\tf.close();\n\trename(inProgressFileName, banListFileName);\n}\n\n/// Parse parent keys from a propagated ban reason string\nstring[] parseParents(string s)\n{\n\timport std.algorithm.searching : findSplit;\n\tstring[] result;\n\twhile ((s = s.findSplit(\" (propagated from \")[2]) != null)\n\t{\n\t\tauto p = s.findSplit(\")\");\n\t\tresult ~= p[0];\n\t\ts = p[2];\n\t}\n\treturn result;\n}\n"
  },
  {
    "path": "src/dfeed/web/posting.d",
    "content": "/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.posting;\n\nimport std.algorithm;\nimport std.conv;\nimport std.datetime;\nimport std.exception;\nimport std.range.primitives;\nimport std.string;\nimport std.file;\n\nimport ae.net.asockets : socketManager, onNextTick;\nimport ae.net.ietf.headers;\nimport ae.net.ietf.url;\nimport ae.net.nntp.client;\nimport ae.net.smtp.client;\nimport ae.sys.log;\nimport ae.utils.array;\nimport ae.utils.sini;\nimport ae.utils.json;\nimport ae.utils.text;\n\nimport dfeed.loc;\nimport dfeed.paths : resolveSiteFile;\nimport dfeed.common;\nimport dfeed.database;\nimport dfeed.groups;\nimport dfeed.message;\nimport dfeed.site;\nimport dfeed.sources.newsgroups : NntpConfig;\nimport dfeed.web.captcha;\nimport dfeed.web.spam : Spamicity, spamCheck, spamThreshold;\nimport dfeed.web.user;\nimport dfeed.web.web.postmod : ModerationReason, shouldModerate;\nimport dfeed.web.web.posting : moderateMessage;\n\nstruct PostDraft\n{\n\t/// Note: convert this to int before writing to database!\n\tenum Status : int\n\t{\n\t\t/// Unused. Default value, invalid.\n\t\treserved   = 0,\n\n\t\t/// Unsent draft.\n\t\tedited     = 1,\n\n\t\t/// Sent draft.\n\t\tsent       = 2,\n\n\t\t/// Discarded draft.\n\t\t/// Persisted in the database, at least for a while, to enable one-click undo.\n\t\tdiscarded  = 3,\n\n\t\t/// In the moderation queue.\n\t\t/// Inaccessible to the author while in this state (mainly so\n\t\t/// they can't vandalize the message if they know a moderator\n\t\t/// will reject it, or recover its text and attempt to repost\n\t\t/// it from another identity).\n\t\tmoderation = 4,\n\t}\n\n\tStatus status;\n\tUrlParameters clientVars;\n\tstring[string] serverVars;\n}\n\nenum PostingStatus\n{\n\tnone,\n\tcaptcha,\n\tspamCheck,\n\tconnecting,\n\tposting,\n\twaiting,\n\tposted,\n\tmoderated,\n\n\tcaptchaFailed,\n\tspamCheckFailed,\n\tserverError,\n\n\tredirect,\n}\n\nstruct PostError\n{\n\tstring message;\n\tCaptchaErrorData captchaError;\n\tstring extraHTML;\n}\n\nfinal class PostProcess\n{\n\tPostDraft draft;\n\tstring pid, ip;\n\tHeaders headers;\n\tRfc850Post post;\n\tPostingStatus status;\n\tPostError error;\n\tbool captchaPresent;\n\tUser user;\n\n\tthis(PostDraft draft, User user, string userID, string ip, Headers headers, Rfc850Post parent)\n\t{\n\t\tthis.draft = draft;\n\t\tthis.ip = ip;\n\t\tthis.headers = headers;\n\t\tthis.user = user;\n\n\t\tthis.post = createPost(draft, headers, ip, parent);\n\n\t\tenforce(draft.clientVars.get(\"name\", \"\").length, _!\"Please enter a name\");\n\t\tenforce(draft.clientVars.get(\"email\", \"\").length, _!\"Please enter an email address\");\n\t\tenforce(draft.clientVars.get(\"subject\", \"\").length, _!\"Please enter a message subject\");\n\t\tenforce(draft.clientVars.get(\"text\", \"\").length, _!\"Please enter a message\");\n\n\t\tthis.pid = randomString();\n\t\tpostProcesses[pid] = this;\n\t\tthis.post.id = pidToMessageID(pid);\n\n\t\tlog = createLogger(\"PostProcess-\" ~ pid);\n\t\tlog(\"IP: \" ~ ip);\n\t\tforeach (name, value; draft.clientVars)\n\t\t\tforeach (line; splitAsciiLines(value))\n\t\t\t\tlog(\"[Form] \" ~ name ~ \": \" ~ line);\n\t\tforeach (name, value; draft.serverVars)\n\t\t\tforeach (line; splitAsciiLines(value))\n\t\t\t\tlog(\"[ServerVar] \" ~ name ~ \": \" ~ line);\n\t\tforeach (name, value; headers)\n\t\t\tlog(\"[Header] \" ~ name ~ \": \" ~ value);\n\n\t\t// Discard duplicate posts (redirect to original)\n\t\tstring allContent = draftContent(draft);\n\t\tif (allContent in postsByContent && postsByContent[allContent] in postProcesses && postProcesses[postsByContent[allContent]].status != PostingStatus.serverError)\n\t\t{\n\t\t\tstring original = postsByContent[allContent];\n\t\t\tlog(\"Duplicate post, redirecting to \" ~ original);\n\t\t\tpid = original;\n\t\t\tstatus = PostingStatus.redirect;\n\t\t\treturn;\n\t\t}\n\t\telse\n\t\t\tpostsByContent[allContent] = pid;\n\n\t\tpost.compile();\n\t}\n\n\t/// Parse a log file\n\tthis(string fileName)\n\t{\n\t\tpid = \"unknown\";\n\n\t\t{\n\t\t\timport std.regex;\n\n\t\t\tauto m = fileName.match(` - PostProcess-([a-z]{20})\\.log`);\n\t\t\tif (m)\n\t\t\t\tpid = m.captures[1];\n\t\t}\n\t\tforeach (line; split(cast(string)read(fileName), \"\\n\"))\n\t\t{\n\t\t\tif (line.length < 30 || line[0] != '[')\n\t\t\t\tcontinue;\n\t\t\tline = line.findSplit(\"] \")[2]; // trim timestamp\n\n\t\t\tstatic void addLine(T)(ref T aa, string var, string line)\n\t\t\t{\n\t\t\t\tif (var in aa)\n\t\t\t\t{\n\t\t\t\t\tif (!line.isOneOf(aa[var].split(\"\\n\")))\n\t\t\t\t\t\taa[var] ~= \"\\n\" ~ line;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t\taa[var] = line;\n\t\t\t}\n\n\t\t\tif (line.skipOver(\"[Form] \"))\n\t\t\t{\n\t\t\t\tauto var = line.skipUntil(\": \");\n\t\t\t\tif (var==\"where\" || var==\"parent\")\n\t\t\t\t\taddLine(draft.serverVars, var, line);\n\t\t\t\telse\n\t\t\t\t\taddLine(draft.clientVars, var, line);\n\t\t\t}\n\t\t\telse\n\t\t\tif (line.skipOver(\"[ServerVar] \"))\n\t\t\t{\n\t\t\t\tauto var = line.skipUntil(\": \");\n\t\t\t\taddLine(draft.serverVars, var, line);\n\t\t\t}\n\t\t\telse\n\t\t\tif (line.skipOver(\"[Header] \"))\n\t\t\t{\n\t\t\t\tauto name = line.skipUntil(\": \");\n\t\t\t\theaders[name] = line;\n\t\t\t}\n\t\t\telse\n\t\t\tif (line.skipOver(\"IP: \"))\n\t\t\t\tip = line;\n\t\t\telse\n\t\t\tif (line.skipOver(\"< Message-ID: <\"))\n\t\t\t\tpid = line.skipUntil(\"@\");\n\t\t}\n\t\tpost = createPost(draft, headers, ip, null);\n\t\tpost.id = pidToMessageID(pid);\n\t\tpost.compile();\n\t}\n\n\t// Parse back a Rfc850Post (e.g. to check spam of an arbitrary message)\n\tthis(Rfc850Post post)\n\t{\n\t\tthis.post = post;\n\n\t\tdraft.clientVars[\"name\"] = post.author;\n\t\tdraft.clientVars[\"email\"] = post.authorEmail;\n\t\tdraft.clientVars[\"subject\"] = post.subject;\n\t\tdraft.clientVars[\"text\"] = post.content; // TODO: unwrap\n\t\tdraft.serverVars[\"where\"] = post.where;\n\n\t\tforeach (name, value; post.headers)\n\t\t\tif (name.skipOver(\"X-Web-\"))\n\t\t\t{\n\t\t\t\tif (name == \"Originating-IP\")\n\t\t\t\t\tthis.ip = value;\n\t\t\t\telse\n\t\t\t\t\tthis.headers.add(name, value);\n\t\t\t}\n\t}\n\n\tstatic string pidToMessageID(string pid)\n\t{\n\t\treturn format(\"<%s@%s>\", pid, site.host);\n\t}\n\n\tvoid logLine(string s)\n\t{\n\t\ttry\n\t\t\tlog.log(s);\n\t\tcatch (Exception e) {}\n\t}\n\n\tvoid run()\n\t{\n\t\tassert(status != PostingStatus.redirect, \"Attempting to run a duplicate PostProcess\");\n\n\t\t// Allow the scope(exit) in callers to run before we begin our own processing\n\t\tsocketManager.onNextTick(&runImpl);\n\t}\n\n\tprivate void runImpl()\n\t{\n\t\tif (\"preapproved\" in draft.serverVars)\n\t\t{\n\t\t\tlog(\"Pre-approved, skipping spam check / CAPTCHA\");\n\t\t\tpostMessage();\n\t\t\treturn;\n\t\t}\n\n\t\tauto captcha = getCaptcha(post.captcha);\n\t\tcaptchaPresent = captcha ? captcha.isPresent(draft.clientVars) : false;\n\t\tif (captchaPresent)\n\t\t{\n\t\t\tlog(\"Checking CAPTCHA\");\n\t\t\tauto challengeDesc = captcha.getChallengeDescription(draft.clientVars);\n\t\t\tif (challengeDesc)\n\t\t\t\tlog(\"  CAPTCHA question: \" ~ challengeDesc.toJson);\n\t\t\tstatus = PostingStatus.captcha;\n\t\t\tcaptcha.verify(draft.clientVars, ip, &onCaptchaResult);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tif (user)\n\t\t\t{\n\t\t\t\tauto n = user.get(\"solved-captchas\", \"0\", SettingType.registered).to!uint;\n\t\t\t\tenum captchaThreshold = 10;\n\t\t\t\tif (n >= captchaThreshold)\n\t\t\t\t{\n\t\t\t\t\tlog(\"User is trusted, skipping spam check\");\n\t\t\t\t\tpostMessage();\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tlog(\"Checking for spam\");\n\t\t\tstatus = PostingStatus.spamCheck;\n\t\t\tspamCheck(this, &onSpamResult, &logLine);\n\t\t}\n\t}\n\n\tstatic Rfc850Post createPost(PostDraft draft, Headers headers, string ip, Rfc850Post parent = null)\n\t{\n\t\tRfc850Post post;\n\t\tif (\"parent\" in draft.serverVars)\n\t\t{\n\t\t\tif (parent)\n\t\t\t{\n\t\t\t\tauto parentID = draft.serverVars[\"parent\"];\n\t\t\t\tassert(parent.id == parentID, \"Invalid parent ID\");\n\t\t\t\tpost = parent.replyTemplate();\n\t\t\t}\n\t\t\telse\n\t\t\t\tpost = Rfc850Post.newPostTemplate(null);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tassert(parent is null, \"Parent specified but not parent in serverVars\");\n\n\t\t\tif (\"where\" in draft.serverVars)\n\t\t\t\tpost = Rfc850Post.newPostTemplate(draft.serverVars[\"where\"]);\n\t\t\telse\n\t\t\t\tassert(false, \"No 'parent' or 'where'\");\n\t\t}\n\n\t\tpost.author = draft.clientVars.get(\"name\", null);\n\t\tpost.authorEmail = draft.clientVars.get(\"email\", null);\n\t\tpost.subject = post.rawSubject = draft.clientVars.get(\"subject\", null);\n\t\tpost.setText(draft.clientVars.get(\"text\", null));\n\t\tif (\"markdown\" in draft.clientVars)\n\t\t\tpost.markup = \"markdown\";\n\n\t\tif (auto pUserAgent = \"User-Agent\" in headers)\n\t\t\tpost.headers[\"X-Web-User-Agent\"] = *pUserAgent;\n\t\tif (ip)\n\t\t\tpost.headers[\"X-Web-Originating-IP\"] = ip;\n\n\t\tif (\"did\" in draft.clientVars)\n\t\t\tpost.id = format(\"<draft-%s@%s>\", draft.clientVars[\"did\"], site.host);\n\t\tpost.msg.time = post.time;\n\n\t\treturn post;\n\t}\n\n\t// **********************************************************************\n\n\tprivate static string draftContent(ref /*const*/ PostDraft draft)\n\t{\n\t\treturn draft.clientVars.values.sort().release().join(\"\\0\");\n\t}\n\n\tstatic void allowReposting(ref /*const*/ PostDraft draft)\n\t{\n\t\tpostsByContent.remove(draftContent(draft));\n\t}\n\n\t// **********************************************************************\n\nprivate:\n\tLogger log;\n\n\tvoid onCaptchaResult(bool ok, string errorMessage, CaptchaErrorData errorData)\n\t{\n\t\tif (!ok)\n\t\t{\n\t\t\tthis.status = PostingStatus.captchaFailed;\n\t\t\tthis.error = PostError(_!\"CAPTCHA error:\" ~ \" \" ~ errorMessage, errorData);\n\t\t\tthis.user = User.init;\n\t\t\tlog(\"CAPTCHA failed: \" ~ errorMessage);\n\t\t\tif (errorData) log(\"CAPTCHA error data: \" ~ errorData.toString());\n\t\t\tlog.close();\n\t\t\treturn;\n\t\t}\n\n\t\tlog(\"CAPTCHA OK\");\n\t\tif (user)\n\t\t{\n\t\t\tauto n = user.get(\"solved-captchas\", \"0\", SettingType.registered).to!uint;\n\t\t\tn++;\n\t\t\tuser.set(\"solved-captchas\", text(n), SettingType.registered);\n\t\t\tlog(\"  (user solved %d CAPTCHAs)\".format(n));\n\t\t}\n\n\t\t// Now run spam check to get spamicity for shouldModerate()\n\t\tlog(\"Running spam check after CAPTCHA\");\n\t\tstatus = PostingStatus.spamCheck;\n\t\tspamCheck(this, &onSpamResultAfterCaptcha, &logLine);\n\t}\n\n\tvoid onSpamResult(Spamicity spamicity, string errorMessage)\n\t{\n\t\t// Cache the overall spamicity for later retrieval\n\t\tdraft.serverVars[\"spamicity\"] = spamicity.text;\n\n\t\tif (spamicity >= spamThreshold)\n\t\t{\n\t\t\tlog(\"Spam check failed (spamicity: %.2f): %s\".format(spamicity, errorMessage));\n\n\t\t\t// Check if CAPTCHA is available to challenge the user\n\t\t\tif (getCaptcha(post.captcha))\n\t\t\t{\n\t\t\t\t// CAPTCHA available - let user try to solve it\n\t\t\t\tthis.status = PostingStatus.spamCheckFailed;\n\t\t\t\tthis.error = PostError(errorMessage);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\t// No CAPTCHA configured - quarantine for moderation\n\t\t\t\tauto reason = ModerationReason(ModerationReason.Kind.spam, \"No CAPTCHA configured and spam check failed: \" ~ errorMessage);\n\t\t\t\tthis.status = PostingStatus.moderated;\n\t\t\t\tmoderateMessage(draft, headers, reason);\n\t\t\t\tlog(\"Quarantined for moderation: \" ~ reason.toString());\n\t\t\t}\n\n\t\t\tthis.user = User.init;\n\t\t\tlog.close();\n\t\t\treturn;\n\t\t}\n\t\tlog(\"Spam check OK (spamicity: %.2f)\".format(spamicity));\n\n\t\tcheckForModeration();\n\t}\n\n\tvoid onSpamResultAfterCaptcha(Spamicity spamicity, string errorMessage)\n\t{\n\t\t// Cache the overall spamicity for later retrieval\n\t\tdraft.serverVars[\"spamicity\"] = spamicity.text;\n\t\tlog(\"Spam check after CAPTCHA: spamicity %.2f\".format(spamicity));\n\n\t\t// CAPTCHA was solved, so proceed to moderation check.\n\t\t// shouldModerate() will quarantine if spamicity is very high.\n\t\tcheckForModeration();\n\t}\n\n\tvoid checkForModeration()\n\t{\n\t\tauto moderationReason = shouldModerate(draft);\n\t\tif (moderationReason.kind != ModerationReason.Kind.none)\n\t\t{\n\t\t\tthis.status = PostingStatus.moderated;\n\t\t\tthis.user = User.init;\n\t\t\tmoderateMessage(draft, headers, moderationReason);\n\t\t\tlog(\"Quarantined for moderation: \" ~ moderationReason.toString());\n\t\t\tlog.close();\n\t\t\treturn;\n\t\t}\n\n\t\tpostMessage();\n\t}\n\n\t// **********************************************************************\n\n\tvoid postMessage()\n\t{\n\t\tauto groups = post.xref.map!(x => x.group.getGroupInfo());\n\t\tenforce(groups.length, \"No groups\");\n\t\tauto group = groups.front;\n\t\tauto sinkTypes = groups.map!(group => group.sinkType.dup); // Issue 17264\n\t\tenforce(sinkTypes.uniq.walkLength == 1, \"Can't cross-post across protocols\");\n\t\tswitch (group.sinkType)\n\t\t{\n\t\t\tcase null:\n\t\t\t\tthrow new Exception(_!\"You can't post to this group.\");\n\t\t\tcase \"nntp\":\n\t\t\t\tnntpSend(group.sinkName);\n\t\t\t\tbreak;\n\t\t\tcase \"smtp\":\n\t\t\t\tsmtpSend(group);\n\t\t\t\tbreak;\n\t\t\tcase \"local\":\n\t\t\t\tlocalSend();\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tassert(false, \"Unknown sinkType: \" ~ group.sinkType);\n\t\t}\n\t}\n\n\tvoid nntpSend(string name)\n\t{\n\t\tNntpClient nntp;\n\n\t\tvoid onDisconnect(string reason, DisconnectType type)\n\t\t{\n\t\t\tthis.status = PostingStatus.serverError;\n\t\t\tthis.error = PostError(_!\"NNTP connection error:\" ~ \" \" ~ reason);\n\t\t\tthis.user = User.init;\n\t\t\tlog(\"NNTP connection error: \" ~ reason);\n\t\t\tlog.close();\n\t\t}\n\n\t\tvoid onError(string error)\n\t\t{\n\t\t\tthis.status = PostingStatus.serverError;\n\t\t\tthis.error = PostError(_!\"NNTP error:\" ~ \" \" ~ error);\n\t\t\tthis.user = User.init;\n\t\t\tnntp.handleDisconnect = null;\n\t\t\tif (nntp.connected)\n\t\t\t\tnntp.disconnect();\n\t\t\tlog(\"NNTP error: \" ~ error);\n\t\t\tlog.close();\n\t\t}\n\n\t\tvoid onPosted()\n\t\t{\n\t\t\tif (this.status == PostingStatus.posting)\n\t\t\t\tthis.status = PostingStatus.waiting;\n\t\t\tthis.user = User.init;\n\t\t\tnntp.handleDisconnect = null;\n\t\t\tnntp.disconnect();\n\t\t\tlog(\"Message posted successfully.\");\n\t\t\tlog.close();\n\t\t}\n\n\t\tvoid onConnect()\n\t\t{\n\t\t\tthis.status = PostingStatus.posting;\n\t\t\tnntp.postMessage(post.message.splitAsciiLines(), &onPosted, &onError);\n\t\t}\n\n\t\tstatus = PostingStatus.connecting;\n\n\t\tauto config = loadIni!NntpConfig(resolveSiteFile(\"config/sources/nntp/\" ~ name ~ \".ini\"));\n\t\tif (!config.postingAllowed)\n\t\t\tthrow new Exception(_!\"Posting is disabled\");\n\n\t\tnntp = new NntpClient(log);\n\t\tnntp.handleDisconnect = &onDisconnect;\n\t\tnntp.connect(config.host, &onConnect);\n\t}\n\n\tvoid smtpSend(in dfeed.groups.Config.Group* group)\n\t{\n\t\tSmtpClient smtp;\n\n\t\tvoid onError(string error)\n\t\t{\n\t\t\tthis.status = PostingStatus.serverError;\n\t\t\tthis.error = PostError(_!\"SMTP error:\" ~ \" \" ~ error);\n\t\t\tthis.user = User.init;\n\t\t\tlog(\"SMTP error: \" ~ error);\n\t\t\tlog.close();\n\t\t}\n\n\t\tvoid onSent()\n\t\t{\n\t\t\tif (this.status == PostingStatus.posting)\n\t\t\t\tthis.status = PostingStatus.waiting;\n\t\t\tthis.user = User.init;\n\t\t\tlog(\"Message posted successfully.\");\n\t\t\tlog.close();\n\t\t}\n\n\t\tvoid onStateChanged()\n\t\t{\n\t\t\tif (smtp.state == SmtpClient.State.mailFrom)\n\t\t\t\tstatus = PostingStatus.posting;\n\t\t}\n\n\t\tstatus = PostingStatus.connecting;\n\n\t\tauto config = loadIni!SmtpConfig(resolveSiteFile(\"config/sources/smtp/\" ~ group.sinkName ~ \".ini\"));\n\t\tauto recipient = \"<\" ~ toLower(group.internalName) ~ \"@\" ~ config.domain ~ \">\";\n\n\t\tsmtp = new SmtpClient(log, site.host, config.server, config.port);\n\t\tsmtp.handleSent = &onSent;\n\t\tsmtp.handleError = &onError;\n\t\tsmtp.handleStateChanged = &onStateChanged;\n\t\tsmtp.sendMessage(\n\t\t\t\"<\" ~ post.authorEmail ~ \">\",\n\t\t\trecipient,\n\t\t\t[\"To: \" ~ recipient] ~ post.message.splitAsciiLines()\n\t\t);\n\t}\n\n\tvoid localSend()\n\t{\n\t\tstatus = PostingStatus.posting;\n\t\tannouncePost(post, Fresh.yes);\n\t\tthis.status = PostingStatus.posted;\n\t\tthis.user = User.init;\n\t\tlog(\"Message stored locally.\");\n\t\tlog.close();\n\t}\n}\n\nstruct SmtpConfig\n{\n\tstring domain;\n\tstring server;\n\tushort port = 25;\n\tstring listInfo;\n}\n\nPostProcess[string] postProcesses;\nstring[string] postsByContent;\n\nfinal class PostingNotifySink : NewsSink\n{\n\toverride void handlePost(Post post, Fresh fresh)\n\t{\n\t\tauto rfc850post = cast(Rfc850Post)post;\n\t\tif (rfc850post)\n\t\t{\n\t\t\tauto id = rfc850post.id;\n\t\t\tif (id.endsWith(\"@\" ~ site.host ~ \">\"))\n\t\t\t{\n\t\t\t\tauto pid = id.split(\"@\")[0][1..$];\n\t\t\t\tif (pid in postProcesses)\n\t\t\t\t{\n\t\t\t\t\tpostProcesses[pid].status = PostingStatus.posted;\n\t\t\t\t\tpostProcesses[pid].user = User.init;\n\t\t\t\t\tpostProcesses[pid].post.url = rfc850post.url;\n\t\t\t\t\tquery!\"UPDATE [Drafts] SET [Status]=? WHERE [ID]=?\".exec(PostDraft.Status.sent, postProcesses[pid].draft.clientVars.get(\"did\", pid));\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/spam/akismet.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.spam.akismet;\n\nimport ae.net.http.client;\n\nimport dfeed.loc;\nimport dfeed.site;\nimport dfeed.web.posting;\nimport dfeed.web.spam;\n\nclass Akismet : SpamChecker\n{\n\tstruct Config { string key; }\n\tConfig config;\n\tthis(Config config) { this.config = config; }\n\n\toverride void check(PostProcess process, SpamResultHandler handler)\n\t{\n\t\tif (!config.key)\n\t\t\treturn handler(unconfiguredHam, \"Akismet is not set up\");\n\n\t\tstring[string] params = [\n\t\t\t\"blog\"                 : site.proto ~ \"://\" ~ site.host ~ \"/\",\n\t\t\t\"user_ip\"              : process.ip,\n\t\t\t\"user_agent\"           : process.headers.get(\"User-Agent\", \"\"),\n\t\t\t\"referrer\"             : process.headers.get(\"Referer\", \"\"),\n\t\t\t\"comment_author\"       : process.draft.clientVars.get(\"name\", \"\"),\n\t\t\t\"comment_author_email\" : process.draft.clientVars.get(\"email\", \"\"),\n\t\t\t\"comment_content\"      : process.draft.clientVars.get(\"text\", \"\"),\n\t\t];\n\n\t\treturn httpPost(\"http://\" ~ config.key ~ \".rest.akismet.com/1.1/comment-check\", UrlParameters(params), (string result) {\n\t\t\tif (result == \"false\")\n\t\t\t\thandler(likelyHam, null);\n\t\t\telse\n\t\t\tif (result == \"true\")\n\t\t\t\thandler(likelySpam, _!\"Akismet thinks your post looks like spam\");\n\t\t\telse\n\t\t\t\thandler(errorSpam, _!\"Akismet error:\" ~ \" \" ~ result);\n\t\t}, (string error) {\n\t\t\thandler(errorSpam, _!\"Akismet error:\" ~ \" \" ~ error);\n\t\t});\n\t}\n\n\toverride void sendFeedback(PostProcess process, SpamResultHandler handler, SpamFeedback feedback)\n\t{\n\t\tif (!config.key)\n\t\t\treturn handler(unconfiguredHam, \"Akismet is not set up\");\n\n\t\tstring[string] params = [\n\t\t\t\"blog\"                 : site.proto ~ \"://\" ~ site.host ~ \"/\",\n\t\t\t\"user_ip\"              : process.ip,\n\t\t\t\"user_agent\"           : process.headers.get(\"User-Agent\", \"\"),\n\t\t\t\"referrer\"             : process.headers.get(\"Referer\", \"\"),\n\t\t\t\"comment_author\"       : process.draft.clientVars.get(\"name\", \"\"),\n\t\t\t\"comment_author_email\" : process.draft.clientVars.get(\"email\", \"\"),\n\t\t\t\"comment_content\"      : process.draft.clientVars.get(\"text\", \"\"),\n\t\t];\n\n\t\tstring[SpamFeedback] names = [ SpamFeedback.spam : \"spam\", SpamFeedback.ham : \"ham\" ];\n\t\treturn httpPost(\"http://\" ~ config.key ~ \".rest.akismet.com/1.1/submit-\" ~ names[feedback], UrlParameters(params), (string result) {\n\t\t\tif (result == \"Thanks for making the web a better place.\")\n\t\t\t\thandler(likelyHam, null);\n\t\t\telse\n\t\t\t\thandler(errorSpam, \"Akismet error: \" ~ result);\n\t\t}, (string error) {\n\t\t\thandler(errorSpam, \"Akismet error: \" ~ error);\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/spam/bayes.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.spam.bayes;\n\nimport std.algorithm.searching;\nimport std.file;\nimport std.string;\n\nimport ae.utils.json;\nimport ae.utils.text;\n\nimport dfeed.loc;\nimport dfeed.bayes;\nimport dfeed.web.lint;\nimport dfeed.web.posting;\nimport dfeed.web.spam;\n\nclass BayesChecker : SpamChecker\n{\n\tBayesModel model;\n\tbool modelLoaded;\n\n\tthis()\n\t{\n\t\tauto fn = \"data/bayes/model.json\";\n\t\tif (fn.exists)\n\t\t{\n\t\t\tmodel = fn.readText.jsonParse!BayesModel;\n\t\t\tmodelLoaded = true;\n\t\t}\n\t}\n\n\tstatic string messageFromDraft(in ref PostDraft draft)\n\t{\n\t\tstring message;\n\t\tauto subject = draft.clientVars.get(\"subject\", \"\").toLower();\n\t\tif (\"parent\" !in draft.serverVars || !subject.startsWith(\"Re: \")) // top-level or custom subject\n\t\t\tmessage = subject ~ \"\\n\\n\";\n\t\tmessage ~= draft.getNonQuoteLines().join(\"\\n\");\n\t\treturn message;\n\t}\n\n\tdouble checkDraft(in ref PostDraft draft)\n\t{\n\t\treturn model.checkMessage(messageFromDraft(draft));\n\t}\n\n\toverride void check(PostProcess process, SpamResultHandler handler)\n\t{\n\t\tif (!modelLoaded)\n\t\t\treturn handler(likelyHam, \"No model\");\n\n\t\tauto spamicity = checkDraft(process.draft);\n\t\tauto percent = cast(int)(spamicity * 100);\n\t\thandler(spamicity, \"%d%%\".format(percent));\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/spam/blogspam.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.spam.blogspam;\n\nimport ae.net.http.client;\nimport ae.sys.data;\nimport ae.utils.json;\n\nimport dfeed.loc;\nimport dfeed.site;\nimport dfeed.web.posting;\nimport dfeed.web.spam;\n\nclass BlogSpam : SpamChecker\n{\n\tprivate string[string] getParams(PostProcess process)\n\t{\n\t\treturn [\n\t\t\t\"comment\"              : process.draft.clientVars.get(\"text\", \"\"),\n\t\t\t\"ip\"                   : process.ip,\n\t\t\t\"agent\"                : process.headers.get(\"User-Agent\", \"\"),\n\t\t\t\"email\"                : process.draft.clientVars.get(\"email\", \"\"),\n\t\t\t\"name\"                 : process.draft.clientVars.get(\"name\", \"\"),\n\t\t\t\"site\"                 : site.proto ~ \"://\" ~ site.host ~ \"/\",\n\t\t\t\"subject\"              : process.draft.clientVars.get(\"subject\", \"\"),\n\t\t\t\"version\"              : \"DFeed (+https://github.com/CyberShadow/DFeed)\",\n\t\t];\n\t}\n\n\toverride void check(PostProcess process, SpamResultHandler handler)\n\t{\n\t\tauto params = getParams(process);\n\n\t\treturn httpPost(\"http://test.blogspam.net:9999/\", DataVec(Data(toJson(params))), \"application/json\", (string responseText) {\n\t\t\tauto response = responseText.jsonParse!(string[string]);\n\t\t\tauto result = response.get(\"result\", null);\n\t\t\tauto reason = response.get(\"reason\", \"no reason given\");\n\t\t\tif (result == \"OK\")\n\t\t\t\thandler(likelyHam, reason);\n\t\t\telse\n\t\t\tif (result == \"SPAM\")\n\t\t\t\thandler(likelySpam, _!\"BlogSpam.net thinks your post looks like spam:\" ~ \" \" ~ reason);\n\t\t\telse\n\t\t\tif (result == \"ERROR\")\n\t\t\t\thandler(errorSpam, _!\"BlogSpam.net error:\" ~ \" \" ~ reason);\n\t\t\telse\n\t\t\t\thandler(errorSpam, _!\"BlogSpam.net unexpected response:\" ~ \" \" ~ result);\n\t\t}, (string error) {\n\t\t\thandler(errorSpam, _!\"BlogSpam.net error:\" ~ \" \" ~ error);\n\t\t});\n\t}\n\n\toverride void sendFeedback(PostProcess process, SpamResultHandler handler, SpamFeedback feedback)\n\t{\n\t\tauto params = getParams(process);\n\t\tstring[SpamFeedback] names = [ SpamFeedback.spam : \"spam\", SpamFeedback.ham : \"ok\" ];\n\t\tparams[\"train\"] = names[feedback];\n\t\treturn httpPost(\"http://test.blogspam.net:9999/classify\", DataVec(Data(toJson(params))), \"application/json\", (string responseText) {\n\t\t\tauto response = responseText.jsonParse!(string[string]);\n\t\t\tauto result = response.get(\"result\", null);\n\t\t\tauto reason = response.get(\"reason\", \"no reason given\");\n\t\t\tif (result == \"OK\")\n\t\t\t\thandler(likelyHam, reason);\n\t\t\telse\n\t\t\tif (result == \"ERROR\")\n\t\t\t\thandler(errorSpam, _!\"BlogSpam.net error:\" ~ \" \" ~ reason);\n\t\t\telse\n\t\t\t\thandler(errorSpam, _!\"BlogSpam.net unexpected response:\" ~ \" \" ~ result);\n\t\t}, (string error) {\n\t\t\thandler(errorSpam, _!\"BlogSpam.net error:\" ~ \" \" ~ error);\n\t\t});\n\t}\n}\n\n"
  },
  {
    "path": "src/dfeed/web/spam/openai.d",
    "content": "/*  Copyright (C) 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.spam.openai;\n\nimport std.algorithm;\nimport std.conv;\nimport std.format;\nimport std.math;\nimport std.string;\n\nimport ae.net.http.client;\nimport ae.sys.data;\nimport ae.sys.dataset;\nimport ae.utils.json;\n\nimport dfeed.loc;\nimport dfeed.site;\nimport dfeed.web.posting;\nimport dfeed.web.spam;\n\nclass OpenAI : SpamChecker\n{\n\tstruct Config\n\t{\n\t\tstring apiKey;\n\t\tstring model = \"gpt-4o-mini\";\n\t}\n\tConfig config;\n\n\tthis(Config config) { this.config = config; }\n\n\toverride void check(PostProcess process, SpamResultHandler handler)\n\t{\n\t\tif (!config.apiKey)\n\t\t\treturn handler(unconfiguredHam, \"OpenAI is not set up\");\n\t\tif (!site.name.length)\n\t\t\treturn handler(unconfiguredHam, \"Site name is not set - edit config/site.ini\");\n\n\t\t// Build the prompt - ask for reasoning first, then verdict\n\t\t// This helps the model think through the decision while still being parseable\n\t\tauto systemPrompt = format(\n\t\t\t\"You are a spam detection system for the online forum titled \\\"%s\\\". \" ~\n\t\t\t\"Analyze posts and determine if they are spam or legitimate (ham). \" ~\n\t\t\t\"First, briefly explain your reasoning (1-2 sentences), then on a new line, \" ~\n\t\t\t\"provide your verdict as either 'VERDICT: spam' or 'VERDICT: ham'.\\n\\n\" ~\n\t\t\t\"Consider spam to be:\\n\" ~\n\t\t\t\"- New threads completely unrelated to this forum's topic (even if they appear helpful for other topics)\\n\" ~\n\t\t\t\"- Unsolicited advertising or promotional content\\n\" ~\n\t\t\t\"- Generic troubleshooting guides for consumer products unrelated to the forum's purpose\\n\" ~\n\t\t\t\"- Malicious links or suspicious URLs\\n\" ~\n\t\t\t\"- Repetitive patterns or poor grammar used to evade filters\\n\\n\" ~\n\t\t\t\"Consider ham to be:\\n\" ~\n\t\t\t\"- Posts relevant to this forum's topic and purpose\\n\" ~\n\t\t\t\"- Posts that continue an existing discussion, even if tangential to the forum's topic\",\n\t\t\tsite.name\n\t\t);\n\n\t\tauto userMessage = format(\n\t\t\t\"Author: %s\\nEmail: %s\\nSubject: %s\\n\\nContent:\\n%s\",\n\t\t\tprocess.draft.clientVars.get(\"name\", \"\"),\n\t\t\tprocess.draft.clientVars.get(\"email\", \"\"),\n\t\t\tprocess.draft.clientVars.get(\"subject\", \"\"),\n\t\t\tprocess.draft.clientVars.get(\"text\", \"\")\n\t\t);\n\n\t\t// Build the API request as JSON string\n\t\timport std.json : JSONValue, JSONType;\n\n\t\tJSONValue requestBody = JSONValue([\n\t\t\t\"model\": JSONValue(config.model),\n\t\t\t\"messages\": JSONValue([\n\t\t\t\tJSONValue([\n\t\t\t\t\t\"role\": JSONValue(\"system\"),\n\t\t\t\t\t\"content\": JSONValue(systemPrompt),\n\t\t\t\t]),\n\t\t\t\tJSONValue([\n\t\t\t\t\t\"role\": JSONValue(\"user\"),\n\t\t\t\t\t\"content\": JSONValue(userMessage),\n\t\t\t\t]),\n\t\t\t]),\n\t\t\t\"logprobs\": JSONValue(true),\n\t\t\t\"top_logprobs\": JSONValue(5),\n\t\t\t\"max_tokens\": JSONValue(100),\n\t\t\t\"temperature\": JSONValue(0.0),\n\t\t]);\n\n\t\tauto requestData = requestBody.toString();\n\n\t\t// Make the API call\n\t\t// Note: We need to use httpRequest instead of httpPost to add custom headers (Authorization)\n\t\tauto request = new HttpRequest;\n\t\trequest.resource = \"https://api.openai.com/v1/chat/completions\";\n\t\trequest.method = \"POST\";\n\t\trequest.headers[\"Authorization\"] = \"Bearer \" ~ config.apiKey;\n\t\trequest.headers[\"Content-Type\"] = \"application/json\";\n\t\trequest.data = DataVec(Data(requestData));\n\n\t\thttpRequest(request, (HttpResponse response, string disconnectReason) {\n\t\t\tif (!response)\n\t\t\t{\n\t\t\t\thandler(errorSpam, \"OpenAI error: \" ~ disconnectReason);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tif (response.status != HttpStatusCode.OK)\n\t\t\t{\n\t\t\t\tauto errorMsg = cast(string)response.getContent().toGC();\n\t\t\t\thandler(errorSpam, format(\"OpenAI API error (HTTP %d): %s\",\n\t\t\t\t\tresponse.status, errorMsg.length > 200 ? errorMsg[0..200] ~ \"...\" : errorMsg));\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tauto responseText = cast(string)response.getContent().toGC();\n\n\t\t\ttry\n\t\t\t{\n\t\t\t\timport std.json : parseJSON;\n\t\t\t\tauto responseJson = parseJSON(responseText);\n\n\t\t\t\t// Extract the response text\n\t\t\t\tauto choices = responseJson[\"choices\"].array;\n\t\t\t\tif (choices.length == 0)\n\t\t\t\t{\n\t\t\t\t\thandler(errorSpam, \"OpenAI error: No choices in response\");\n\t\t\t\t\treturn;\n\t\t\t\t}\n\n\t\t\t\tauto choice = choices[0];\n\t\t\t\tauto message = choice[\"message\"];\n\t\t\t\tauto content = message[\"content\"].str;\n\n\t\t\t\t// Parse verdict from response (should contain \"VERDICT: spam\" or \"VERDICT: ham\")\n\t\t\t\tbool isSpam;\n\t\t\t\tif (content.toLower().canFind(\"verdict: spam\") || content.toLower().canFind(\"verdict:spam\"))\n\t\t\t\t\tisSpam = true;\n\t\t\t\telse if (content.toLower().canFind(\"verdict: ham\") || content.toLower().canFind(\"verdict:ham\"))\n\t\t\t\t\tisSpam = false;\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t// Fallback: check for spam/ham keywords in response\n\t\t\t\t\tauto lowerContent = content.toLower();\n\t\t\t\t\tif (lowerContent.canFind(\"spam\") && !lowerContent.canFind(\"not spam\"))\n\t\t\t\t\t\tisSpam = true;\n\t\t\t\t\telse if (lowerContent.canFind(\"ham\") || lowerContent.canFind(\"legitimate\"))\n\t\t\t\t\t\tisSpam = false;\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\thandler(errorSpam, format(\"OpenAI error: Could not parse verdict from response: %s\",\n\t\t\t\t\t\t\tcontent.length > 200 ? content[0..200] ~ \"...\" : content));\n\t\t\t\t\t\treturn;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// Extract confidence from logprobs by finding \"spam\" and \"ham\" probabilities\n\t\t\t\tSpamicity spamicity;\n\t\t\t\tbool hasLogprobs = false;\n\n\t\t\t\tif (auto logprobs = \"logprobs\" in choice.object)\n\t\t\t\t{\n\t\t\t\t\tif (auto content_logprobs = \"content\" in logprobs.object)\n\t\t\t\t\t{\n\t\t\t\t\t\t// Check the last token's top_logprobs for \" spam\" and \" ham\"\n\t\t\t\t\t\tdouble spamProb = 0.0;\n\t\t\t\t\t\tdouble hamProb = 0.0;\n\n\t\t\t\t\t\tauto tokens = content_logprobs.array;\n\t\t\t\t\t\tif (tokens.length > 0)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tauto lastToken = tokens[$-1];\n\n\t\t\t\t\t\t\t// Check if the last token has top_logprobs\n\t\t\t\t\t\t\tif (auto top_logprobs = \"top_logprobs\" in lastToken.object)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t// Search through the top alternatives for \" spam\" or \" ham\"\n\t\t\t\t\t\t\t\tforeach (altToken; top_logprobs.array)\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tauto tokenStr = altToken[\"token\"].str;\n\t\t\t\t\t\t\t\t\tauto logprob = altToken[\"logprob\"].floating;\n\t\t\t\t\t\t\t\t\tauto prob = exp(logprob); // Convert log to linear probability\n\n\t\t\t\t\t\t\t\t\tif (tokenStr == \" spam\")\n\t\t\t\t\t\t\t\t\t\tspamProb = prob;\n\t\t\t\t\t\t\t\t\telse if (tokenStr == \" ham\")\n\t\t\t\t\t\t\t\t\t\thamProb = prob;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Calculate spamicity as weighted proportion\n\t\t\t\t\t\tauto totalProb = spamProb + hamProb;\n\t\t\t\t\t\tif (totalProb > 0)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tspamicity = spamProb / totalProb;\n\t\t\t\t\t\t\thasLogprobs = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\t// If no logprobs available, use likely constants\n\t\t\t\tif (!hasLogprobs)\n\t\t\t\t\tspamicity = isSpam ? likelySpam : likelyHam;\n\n\t\t\t\t// Return full model response in the message\n\t\t\t\tauto verdict = isSpam ? \"spam\" : \"ham\";\n\t\t\t\tauto resultMessage = format(\"%s thinks your post is %s: %s\",\n\t\t\t\t\tconfig.model, verdict, content);\n\t\t\t\thandler(spamicity, resultMessage);\n\t\t\t}\n\t\t\tcatch (Exception e)\n\t\t\t{\n\t\t\t\thandler(errorSpam, format(\"OpenAI error: %s\", e.msg));\n\t\t\t}\n\t\t});\n\t}\n}\n\nversion (main_openai)\nvoid main(string[] args)\n{\n\timport std.exception : enforce;\n\timport std.file : dirEntries, SpanMode;\n\timport std.stdio : stdout;\n\timport ae.net.asockets : socketManager;\n\tstatic import ae.net.ssl.openssl;\n\n\timport dfeed.common : createService;\n\tauto openai = createService!OpenAI(\"apis/openai\").enforce(\"OpenAI is not configured\");\n\n\tforeach (fn; args[1..$])\n\t{\n\t\tif (fn.length == 20)\n\t\t\tfn = dirEntries(\"logs\", \"* - PostProcess-\" ~ fn ~ \".log\", SpanMode.shallow).front.name;\n\n\t\tstdout.writeln(\"--------------------------------------------------------------------\");\n\t\tauto pp = new PostProcess(fn);\n\t\tstdout.write(pp.post.message);\n\t\tstdout.writeln();\n\t\tstdout.writeln(\"--------------------------------------------------------------------\");\n\n\t\tvoid handler(Spamicity spamicity, string message) { stdout.writefln(\"%s: %s\", message, spamicity); }\n\t\topenai.check(pp, &handler);\n\t\tsocketManager.loop();\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/spam/package.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.spam;\n\nimport std.algorithm;\nimport std.exception;\nimport std.file : readText;\nimport std.string;\n\nimport ae.net.http.client;\nimport ae.sys.data;\nimport ae.utils.array;\nimport ae.utils.json;\nimport ae.utils.text;\n\nimport dfeed.loc;\nimport dfeed.site;\nimport dfeed.web.posting;\nimport dfeed.web.spam.akismet;\nimport dfeed.web.spam.bayes;\nimport dfeed.web.spam.blogspam;\nimport dfeed.web.spam.openai;\nimport dfeed.web.spam.projecthoneypot;\nimport dfeed.web.spam.simple;\nimport dfeed.web.spam.stopforumspam;\n\nvoid spamCheck(PostProcess process, SpamResultHandler handler, void delegate(string) log = null)\n{\n\tif (!spamCheckers)\n\t\tinitSpamCheckers();\n\n\tint totalResults = 0;\n\tbool foundSpam = false;\n\tSpamicity maxSpamicity = 0.0;\n\tstring maxSpamicityMessage = null;\n\n\t// Start all checks simultaneously\n\tforeach (checker; spamCheckers)\n\t{\n\t\ttry\n\t\t\t(SpamChecker checker) {\n\t\t\t\tchecker.check(process, (Spamicity spamicity, string message) {\n\t\t\t\t\ttotalResults++;\n\t\t\t\t\tif (log) log(\"Got reply from spam checker %s: spamicity %.2f (%s)\".format(\n\t\t\t\t\t\tchecker.classinfo.name, spamicity, message));\n\t\t\t\t\tif (!foundSpam)\n\t\t\t\t\t{\n\t\t\t\t\t\t// Track the highest spamicity score\n\t\t\t\t\t\tif (spamicity > maxSpamicity)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tmaxSpamicity = spamicity;\n\t\t\t\t\t\t\tmaxSpamicityMessage = message;\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// If spamicity exceeds threshold, immediately report as spam\n\t\t\t\t\t\tif (spamicity >= spamThreshold)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\thandler(spamicity, message);\n\t\t\t\t\t\t\tfoundSpam = true;\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// If all checkers are done and none found spam, report max spamicity\n\t\t\t\t\t\t\tif (totalResults == spamCheckers.length)\n\t\t\t\t\t\t\t\thandler(maxSpamicity, maxSpamicityMessage);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t});\n\t\t\t} (checker);\n\t\tcatch (Exception e)\n\t\t{\n\t\t\tif (log) log(\"Error with spam checker %s: %s\".format(\n\t\t\t\tchecker.classinfo.name, e.msg));\n\t\t\tfoundSpam = true;\n\t\t\thandler(errorSpam, _!\"Spam check error:\" ~ \" \" ~ e.msg);\n\t\t}\n\n\t\t// Avoid starting slow checks if the first engines instantly return a positive\n\t\tif (foundSpam)\n\t\t\tbreak;\n\t}\n}\n\nvoid sendSpamFeedback(PostProcess process, SpamResultHandler handler, SpamFeedback feedback)\n{\n\tif (!spamCheckers)\n\t\tinitSpamCheckers();\n\n\tforeach (checker; spamCheckers)\n\t\tchecker.sendFeedback(process, handler, feedback);\n}\n\n// **************************************************************************\n\n/// Spam confidence score: 0.0 = definitely ham (not spam), 1.0 = definitely spam\n/// This follows industry-standard semantics where higher values indicate higher spam probability\nalias Spamicity = double;\n\n/// Confidence threshold - scores >= this value are considered spam\nenum Spamicity spamThreshold = 0.5;\n\n/// Very high confidence threshold - scores >= this value should be quarantined/moderated\nenum Spamicity certainlySpamThreshold = 0.98;\n\n/// Predefined spamicity levels for checkers that don't provide granular scores\nenum Spamicity certainlyHam  = 0.0;  /// Definitely not spam\nenum Spamicity likelyHam     = 0.25; /// Probably not spam\nenum Spamicity likelySpam    = 0.75; /// Probably spam\nenum Spamicity certainlySpam = 1.0;  /// Definitely spam\n\n/// Confidence level returned by spam checkers when they are not configured\n/// (missing API keys or other configuration).\n/// We return 0 so that the maximizing logic in spamCheck treats this result\n/// synonymously with the spam checker not being present.\nalias unconfiguredHam = certainlyHam;\n\n/// Confidence level for errors (challenge instead of outright rejection)\nalias errorSpam = likelySpam;\n\nalias void delegate(Spamicity spamicity, string message) SpamResultHandler;\n\nenum SpamFeedback { unknown, spam, ham }\n\nclass SpamChecker\n{\n\tabstract void check(PostProcess process, SpamResultHandler handler);\n\n\tvoid sendFeedback(PostProcess process, SpamResultHandler handler, SpamFeedback feedback)\n\t{\n\t\thandler(likelyHam, \"Not implemented\");\n\t}\n}\n\n// **************************************************************************\n\nBayesChecker bayes()\n{\n\tif (!spamCheckers)\n\t\tinitSpamCheckers();\n\n\treturn bayesInst;\n}\n\nSpamicity getSpamicity(in ref PostDraft draft)\n{\n\tauto p = *(\"spamicity\" in draft.serverVars)\n\t\t.enforce(\"getSpamicity called without running spam check first\");\n\n\timport std.conv : to;\n\treturn p.to!Spamicity;\n}\n\n// **************************************************************************\n\nSpamChecker[] spamCheckers;\nprivate BayesChecker bayesInst;\n\nvoid initSpamCheckers()\n{\n\tassert(spamCheckers is null);\n\n\timport dfeed.common;\n\tspamCheckers ~= new SimpleChecker();\n\tspamCheckers ~= bayesInst = new BayesChecker();\n\tif (auto c = createService!ProjectHoneyPot(\"apis/projecthoneypot\"))\n\t\tspamCheckers ~= c;\n\tif (auto c = createService!Akismet(\"apis/akismet\"))\n\t\tspamCheckers ~= c;\n\tif (auto c = createService!OpenAI(\"apis/openai\"))\n\t\tspamCheckers ~= c;\n\tif (auto c = createService!StopForumSpam(\"apis/stopforumspam\"))\n\t\tspamCheckers ~= c;\n\t//spamCheckers ~= new BlogSpam();\n}\n"
  },
  {
    "path": "src/dfeed/web/spam/projecthoneypot.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.spam.projecthoneypot;\n\nimport std.algorithm.mutation;\nimport std.array;\nimport std.exception;\nimport std.string;\n\nimport dfeed.loc;\nimport dfeed.site;\nimport dfeed.web.posting;\nimport dfeed.web.spam;\n\nclass ProjectHoneyPot : SpamChecker\n{\n\tstruct Config { string key; }\n\tConfig config;\n\tthis(Config config) { this.config = config; }\n\n\toverride void check(PostProcess process, SpamResultHandler handler)\n\t{\n\t\tif (!config.key)\n\t\t\treturn handler(certainlyHam, \"ProjectHoneyPot is not set up\");\n\n\t\tenum DAYS_THRESHOLD  =  7; // consider an IP match as a positive if it was last seen at most this many days ago\n\t\tenum SCORE_THRESHOLD = 10; // consider an IP match as a positive if its ProjectHoneyPot score is at least this value\n\n\t\tstruct PHPResult\n\t\t{\n\t\t\tbool present;\n\t\t\tubyte daysLastSeen, threatScore, type;\n\t\t}\n\n\t\tPHPResult phpCheck(string ip)\n\t\t{\n\t\t\timport std.socket;\n\t\t\tstring[] sections = split(ip, \".\");\n\t\t\tif (sections.length != 4) // IPv6\n\t\t\t\treturn PHPResult(false);\n\t\t\tsections.reverse();\n\t\t\tstring addr = ([config.key] ~ sections ~ [\"dnsbl.httpbl.org\"]).join(\".\");\n\t\t\tInternetHost ih = new InternetHost;\n\t\t\tif (!ih.getHostByName(addr))\n\t\t\t\treturn PHPResult(false);\n\t\t\tauto resultIP = cast(ubyte[])(&ih.addrList[0])[0..1];\n\t\t\tresultIP.reverse();\n\t\t\tenforce(resultIP[0] == 127, \"PHP API error\");\n\t\t\treturn PHPResult(true, resultIP[1], resultIP[2], resultIP[3]);\n\t\t}\n\n\t\tauto result = phpCheck(process.ip);\n\t\twith (result)\n\t\t\tif (present && daysLastSeen <= DAYS_THRESHOLD && threatScore >= SCORE_THRESHOLD)\n\t\t\t{\n\t\t\t\t// Normalize threat score (0-255) to spamicity (0.0-1.0)\n\t\t\t\tauto spamicity = threatScore / 255.0;\n\t\t\t\thandler(spamicity, format(\n\t\t\t\t\t_!\"ProjectHoneyPot thinks you may be a spammer (%s last seen: %d days ago, threat score: %d/255, type: %s)\",\n\t\t\t\t\tprocess.ip,\n\t\t\t\t\tdaysLastSeen,\n\t\t\t\t\tthreatScore,\n\t\t\t\t\t(\n\t\t\t\t\t\t( type == 0      ? [\"Search Engine\"  ] : []) ~\n\t\t\t\t\t\t((type & 0b0001) ? [\"Suspicious\"     ] : []) ~\n\t\t\t\t\t\t((type & 0b0010) ? [\"Harvester\"      ] : []) ~\n\t\t\t\t\t\t((type & 0b0100) ? [\"Comment Spammer\"] : [])\n\t\t\t\t\t).join(\", \")));\n\t\t\t}\n\t\t\telse\n\t\t\t\thandler(likelyHam, null);\n\t}\n\n}\n"
  },
  {
    "path": "src/dfeed/web/spam/simple.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.spam.simple;\n\nimport std.algorithm.searching;\nimport std.string;\n\nimport ae.utils.text;\n\nimport dfeed.loc;\nimport dfeed.site;\nimport dfeed.web.posting;\nimport dfeed.web.spam;\n\nclass SimpleChecker : SpamChecker\n{\n\toverride void check(PostProcess process, SpamResultHandler handler)\n\t{\n\t\tauto ua = process.headers.get(\"User-Agent\", \"\");\n\n\t\tif (ua.startsWith(\"WWW-Mechanize\"))\n\t\t\treturn handler(likelySpam, _!\"You seem to be posting using an unusual user-agent\");\n\n\t\tauto subject = process.draft.clientVars.get(\"subject\", \"\").toLower();\n\n\t\t// \"hardspamtest\" triggers certainlySpam (for testing moderation flow)\n\t\tif (subject.contains(\"hardspamtest\"))\n\t\t\treturn handler(certainlySpam, _!\"Your subject contains a keyword that triggers moderation\");\n\n\t\tforeach (keyword; [\"kitchen\", \"spamtest\"])\n\t\t\tif (subject.contains(keyword))\n\t\t\t\treturn handler(likelySpam, _!\"Your subject contains a suspicious keyword or character sequence\");\n\n\t\tauto text = process.draft.clientVars.get(\"text\", \"\").toLower();\n\t\tforeach (keyword; [\"<a href=\", \"[url=\", \"[url]http\"])\n\t\t\tif (text.contains(keyword))\n\t\t\t\treturn handler(likelySpam, _!\"Your post contains a suspicious keyword or character sequence\");\n\n\t\tif (subject.length + text.length < 30 && \"parent\" !in process.draft.serverVars)\n\t\t\treturn handler(likelySpam, _!\"Your top-level post is suspiciously short\");\n\n\t\thandler(likelyHam, null);\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/spam/stopforumspam.d",
    "content": "/*  Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.spam.stopforumspam;\n\nimport std.algorithm.searching;\nimport std.array;\nimport std.exception;\nimport std.string;\n\nimport ae.net.http.client;\n\nimport dfeed.loc;\nimport dfeed.site;\nimport dfeed.web.posting;\nimport dfeed.web.spam;\n\nstatic import ae.utils.xml; // Issue 7016\n\nclass StopForumSpam : SpamChecker\n{\n\tstruct Config { bool enabled; }\n\tConfig config;\n\tthis(Config config) { this.config = config; }\n\n\toverride void check(PostProcess process, SpamResultHandler handler)\n\t{\n\t\tif (!config.enabled)\n\t\t\treturn handler(unconfiguredHam, \"StopForumSpam is disabled\");\n\n\t\tenum DAYS_THRESHOLD = 3; // consider an IP match as a positive if it was last seen at most this many days ago\n\n\t\tauto ip = process.ip;\n\n\t\tif (ip.canFind(':') || ip.split(\".\").length != 4)\n\t\t{\n\t\t\t// Not an IPv4 address, skip StopForumSpam check\n\t\t\treturn handler(certainlyHam, \"Not an IPv4 address\");\n\t\t}\n\n\t\thttpGet(\"http://api.stopforumspam.org/api?ip=\" ~ ip, (string result) {\n\t\t\timport std.datetime;\n\t\t\timport ae.utils.xml;\n\t\t\timport ae.utils.time : parseTime;\n\n\t\t\tauto xml = new XmlDocument(result);\n\t\t\tauto response = xml[\"response\"];\n\t\t\tif (response.attributes[\"success\"] != \"true\")\n\t\t\t{\n\t\t\t\tstring error = result;\n\t\t\t\tauto errorNode = response.findChild(\"error\");\n\t\t\t\tif (errorNode)\n\t\t\t\t\terror = errorNode.text;\n\t\t\t\tenforce(false, _!\"StopForumSpam API error:\" ~ \" \" ~ error);\n\t\t\t}\n\n\t\t\tif (response[\"appears\"].text == \"no\")\n\t\t\t\thandler(likelyHam, null);\n\t\t\telse\n\t\t\t{\n\t\t\t\tauto date = response[\"lastseen\"].text.parseTime!\"Y-m-d H:i:s\"();\n\t\t\t\tif (Clock.currTime() - date < dur!\"days\"(DAYS_THRESHOLD))\n\t\t\t\t\thandler(likelySpam, format(\n\t\t\t\t\t\t_!\"StopForumSpam thinks you may be a spammer (%s last seen: %s, frequency: %s)\",\n\t\t\t\t\t\tprocess.ip, response[\"lastseen\"].text, response[\"frequency\"].text));\n\t\t\t\telse\n\t\t\t\t\thandler(likelyHam, null);\n\t\t\t}\n\t\t}, (string errorMessage) {\n\t\t\thandler(errorSpam, \"StopForumSpam error: \" ~ errorMessage);\n\t\t});\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/user.d",
    "content": "/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\nmodule dfeed.web.user;\n\nimport core.bitop;\n\nimport std.datetime : SysTime, Clock;\nimport std.functional;\nimport std.string;\nimport std.exception;\nimport std.base64;\nimport ae.net.shutdown;\nimport ae.sys.data;\nimport std.typecons : RefCounted, refCounted;\nimport ae.sys.log;\nimport ae.sys.timing;\nimport ae.utils.math : flipBits;\nimport ae.utils.text;\nimport ae.utils.time : StdTime;\nimport ae.utils.time.common;\nimport ae.utils.time.format;\nimport ae.utils.meta.rcclass : RCClass, rcClass;\nimport ae.utils.zlib;\n\nenum SettingType\n{\n\tregistered,  /// Always saved server-side, for registered users only\n\tserver,      /// Stored on the server for registered users, and cookies for non-registered ones\n\tclient,      /// Always stored in cookies\n\tsession,     /// Always stored in cookies, and expires at the end of the session\n}\n\nstruct AccountData // for export\n{\n\tstring username;\n\tint level;\n\tStdTime creationDate;\n\tstring[string] settings;\n}\n\nabstract class CUser\n{\n\tabstract string get(string name, string defaultValue, SettingType settingType);\n\tabstract void set(string name, string value, SettingType settingType);\n\tabstract void remove(string name, SettingType settingType);\n\tabstract string[] save();\n\n\tabstract void logIn(string username, string password, bool remember);\n\tabstract bool checkPassword(string password);\n\tabstract void changePassword(string password);\n\tabstract void logOut();\n\tabstract void register(string username, string password, bool remember);\n\tabstract AccountData exportData();\n\tabstract void deleteAccount();\n\tabstract bool isLoggedIn();\n\tabstract SysTime createdAt();\n\n\tenum Level : int\n\t{\n\t\tguest            =    0, /// Default user level\n\t\thasRawLink       =    1, /// Get a clickable \"raw post\" link.\n\t\tcanFlag          =    2, /// Can flag posts\n\t\tcanApproveDrafts =   90, /// Can approve moderated drafts\n\t\tcanModerate      =  100, /// Can delete posts locally/remotely and ban users\n\t\tsysadmin         = 1000, /// Can edit the database (presumably)\n\t}\n\n\tstring getName() { return null; }\n\tLevel getLevel() { return Level.init; }\n\nprotected:\n\t/// Save misc data to string settings\n\tfinal void finalize()\n\t{\n\t\tflushReadPosts();\n\t}\n\n\t// ***********************************************************************\n\n\talias ReadPostsData = RefCounted!Data;\n\n\tvoid getReadPosts()\n\tin  { assert(this.readPosts is ReadPostsData.init); }\n\tout { assert(this.readPosts !is ReadPostsData.init); }\n\tdo\n\t{\n\t\tauto b64 = get(\"readposts\", null, SettingType.server);\n\t\tif (b64.length)\n\t\t{\n\t\t\t// Temporary hack to catch Phobos bug\n\t\t\tubyte[] zcode;\n\n\t\t\tstring advice = _!\"Try clearing your browser's cookies. Create an account to avoid repeated incidents.\";\n\n\t\t\ttry\n\t\t\t\tzcode = Base64.decode(b64);\n\t\t\tcatch (Throwable /* Base64 throws AssertErrors on invalid data */)\n\t\t\t{\n\t\t\t\timport std.file; write(\"bad-base64.txt\", b64);\n\t\t\t\tthrow new Exception(_!\"Malformed Base64 in read post history cookie.\" ~ \" \" ~ advice);\n\t\t\t}\n\n\t\t\ttry\n\t\t\t\treadPosts = refCounted(uncompress(Data(zcode)));\n\t\t\tcatch (ZlibException e)\n\t\t\t{\n\t\t\t\timport std.file; write(\"bad-zlib.z\", zcode);\n\t\t\t\tthrow new Exception(_!\"Malformed deflated data in read post history cookie\" ~ \" (\" ~ e.msg ~ \"). \" ~ advice);\n\t\t\t}\n\t\t}\n\t\telse\n\t\t\treadPosts = refCounted(Data());\n\t}\n\n\tstatic string encodeReadPosts(ref ReadPostsData readPosts)\n\t{\n\t\tauto b64 = Base64.encode(cast(ubyte[])compress(readPosts, 1).contents);\n\t\treturn assumeUnique(b64);\n\t}\n\n\tvoid saveReadPosts()\n\tin  { assert(readPosts !is ReadPostsData.init && readPosts.length && readPostsDirty); }\n\tdo\n\t{\n\t\tset(\"readposts\", encodeReadPosts(readPosts), SettingType.server);\n\t}\n\n\tReadPostsData readPosts;\n\tbool readPostsDirty;\n\nfinal:\n\tvoid needReadPosts()\n\t{\n\t\tif (readPosts is ReadPostsData.init)\n\t\t\tgetReadPosts();\n\t}\n\n\tvoid flushReadPosts()\n\t{\n\t\tif (readPosts !is ReadPostsData.init && readPosts.length && readPostsDirty)\n\t\t{\n\t\t\tsaveReadPosts();\n\t\t\treadPostsDirty = false;\n\t\t}\n\t}\n\n\tpublic bool isRead(size_t post)\n\t{\n\t\tneedReadPosts();\n\t\tauto pos = post/8;\n\t\tif (pos >= readPosts.length)\n\t\t\treturn false;\n\t\telse\n\t\t\treturn ((cast(ubyte[])readPosts.contents)[pos] & (1 << (post % 8))) != 0;\n\t}\n\n\tpublic void setRead(size_t post, bool value)\n\t{\n\t\tneedReadPosts();\n\t\tauto pos = post/8;\n\t\tif (pos >= readPosts.length)\n\t\t{\n\t\t\tif (value)\n\t\t\t\treadPosts.length = pos+1;\n\t\t\telse\n\t\t\t\treturn;\n\t\t}\n\t\tubyte mask = cast(ubyte)(1 << (post % 8));\n\t\tassert(pos < readPosts.length);\n\t\tauto pbyte = (cast(ubyte*)readPosts.ptr) + pos;\n\t\tif (value)\n\t\t\t*pbyte = *pbyte | mask;\n\t\telse\n\t\t\t*pbyte = *pbyte & mask.flipBits;\n\t\treadPostsDirty = true;\n\t}\n\n\tpublic size_t countRead()\n\t{\n\t\tneedReadPosts();\n\t\tif (!readPosts.length)\n\t\t\treturn 0;\n\n\t\tsize_t count;\n\t\tauto uints = cast(uint*)readPosts.contents.ptr;\n\t\tforeach (uint u; uints[0..readPosts.length/uint.sizeof])\n\t\t\tcount += popcnt(u);\n\t\tforeach (ubyte b; cast(ubyte[])readPosts.contents[$/uint.sizeof*uint.sizeof..$])\n\t\t\tcount += popcnt(b);\n\t\treturn count;\n\t}\n}\nalias User = RCClass!CUser;\n\n// ***************************************************************************\n\nclass CGuestUser : CUser\n{\n\tstring[string] cookies, newCookies;\n\tSettingType[string] settingTypes;\n\n\tthis(string cookieHeader)\n\t{\n\t\tauto segments = cookieHeader.split(\";\");\n\t\tforeach (segment; segments)\n\t\t{\n\t\t\tsegment = segment.strip();\n\t\t\tauto p = segment.indexOf('=');\n\t\t\tif (p > 0)\n\t\t\t{\n\t\t\t    string name = segment[0..p];\n\t\t\t    if (name.startsWith(\"dfeed_\"))\n\t\t\t\t\tcookies[name[6..$]] = segment[p+1..$];\n\t\t\t}\n\t\t}\n\t}\n\n\toverride string get(string name, string defaultValue, SettingType settingType)\n\t{\n\t\tauto pCookie = name in newCookies;\n\t\tif (pCookie)\n\t\t\treturn *pCookie;\n\t\tpCookie = name in cookies;\n\t\tif (pCookie)\n\t\t\treturn *pCookie;\n\t\treturn defaultValue;\n\t}\n\n\toverride void set(string name, string value, SettingType settingType)\n\t{\n\t\tnewCookies[name] = value;\n\t\tsettingTypes[name] = settingType;\n\t}\n\n\toverride void remove(string name, SettingType settingType)\n\t{\n\t\tnewCookies[name] = null;\n\t\tsettingTypes[name] = settingType;\n\t}\n\n\toverride string[] save()\n\t{\n\t\tfinalize();\n\n\t\tstring[] result;\n\t\tforeach (name, value; newCookies)\n\t\t{\n\t\t\tif (value is null)\n\t\t\t{\n\t\t\t\tif (name !in cookies)\n\t\t\t\t\tcontinue;\n\n\t\t\t\tresult ~= \"dfeed_\" ~ name ~ \"=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/\";\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tauto settingType = settingTypes[name];\n\n\t\t\t\tif (settingType == SettingType.registered)\n\t\t\t\t\tcontinue;\n\n\t\t\t\tif (name in cookies && cookies[name] == value)\n\t\t\t\t\tcontinue;\n\n\t\t\t\tif (settingType == SettingType.session)\n\t\t\t\t\tresult ~= \"dfeed_\" ~ name ~ \"=\" ~ value ~ \"; Path=/\";\n\t\t\t\telse\n\t\t\t\t\tresult ~= \"dfeed_\" ~ name ~ \"=\" ~ value ~ \"; Expires=\" ~ (Clock.currTime() + 365.days).formatTime!(TimeFormats.HTTP) ~ \"; Path=/\";\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\tstatic string encryptPassword(string password)\n\t{\n\t\t// TODO: use bcrypt()\n\t\tenforce(config.salt.length, \"Salt not set!\");\n\t\timport std.digest.md;\n\t\treturn (password ~ config.salt).md5Of().toHexString!(LetterCase.lower)().idup; // Issue 9279\n\t}\n\n\toverride void logIn(string username, string password, bool remember)\n\t{\n\t\tforeach (string session; query!\"SELECT `Session` FROM `Users` WHERE `Username` = ? AND `Password` = ?\".iterate(username, encryptPassword(password)))\n\t\t{\n\t\t\tset(\"session\", session, remember ? SettingType.client : SettingType.session);\n\t\t\treturn;\n\t\t}\n\t\tthrow new Exception(\"No such username/password combination\");\n\t}\n\n\tenum maxPasswordLength = 64;\n\n\toverride void register(string username, string password, bool remember)\n\t{\n\t\tenforce(username.length, _!\"Please enter a username\");\n\t\tenforce(username.length <= 32, _!\"Username too long\");\n\t\tenforce(password.length <= maxPasswordLength, _!\"Password too long\");\n\n\t\t// Create user\n\t\tauto session = randomString();\n\t\tquery!\"INSERT INTO `Users` (`Username`, `Password`, `Session`, `Created`) VALUES (?, ?, ?, ?)\"\n\t\t\t.exec(username, encryptPassword(password), session, Clock.currTime.stdTime);\n\n\t\t// Copy cookies to database\n\t\tauto user = registeredUser(username);\n\t\tforeach (name, value; cookies)\n\t\t\tuser.set(name, value, SettingType.server);\n\t\tuser.save();\n\n\t\t// Log them in\n\t\tthis.set(\"session\", session, remember ? SettingType.client : SettingType.session);\n\t}\n\n\toverride bool checkPassword(string password) { throw new Exception(_!\"Not logged in\"); }\n\toverride void changePassword(string password) { throw new Exception(_!\"Not logged in\"); }\n\toverride void logOut() { throw new Exception(_!\"Not logged in\"); }\n\toverride AccountData exportData() { throw new Exception(_!\"Not logged in\"); } // just check your cookies\n\toverride void deleteAccount() { throw new Exception(_!\"Not logged in\"); } // just clear your cookies\n\toverride bool isLoggedIn() { return false; }\n\toverride SysTime createdAt() { return Clock.currTime(); }\n}\nalias GuestUser = RCClass!CGuestUser;\nalias guestUser = rcClass!CGuestUser;\n\n// ***************************************************************************\n\nimport dfeed.loc;\nimport dfeed.database;\n\nfinal class CRegisteredUser : CGuestUser\n{\n\tstring[string] settings, newSettings;\n\tstring username;\n\tLevel level;\n\tStdTime creationTime;\n\n\tthis(string username, string cookieHeader = null, Level level = Level.init, StdTime creationTime = 0)\n\t{\n\t\tsuper(cookieHeader);\n\t\tthis.username = username;\n\t\tthis.level = level;\n\t\tthis.creationTime = creationTime;\n\t}\n\n\toverride string get(string name, string defaultValue, SettingType settingType)\n\t{\n\t\tif (settingType != SettingType.server && settingType != SettingType.registered)\n\t\t\treturn super.get(name, defaultValue, settingType);\n\n\t\tauto pSetting = name in newSettings;\n\t\tif (pSetting)\n\t\t\treturn *pSetting;\n\n\t\tpSetting = name in settings;\n\t\tstring value;\n\t\tif (pSetting)\n\t\t\tvalue = *pSetting;\n\t\telse\n\t\t{\n\t\t\tforeach (string v; query!\"SELECT `Value` FROM `UserSettings` WHERE `User` = ? AND `Name` = ?\".iterate(username, name))\n\t\t\t\tvalue = v;\n\t\t\tsettings[name] = value;\n\t\t}\n\n\t\treturn value ? value : defaultValue;\n\t}\n\n\toverride void set(string name, string value, SettingType settingType)\n\t{\n\t\tif (settingType == SettingType.server || settingType == SettingType.registered)\n\t\t\tnewSettings[name] = value;\n\t\telse\n\t\t\tsuper.set(name, value, settingType);\n\t}\n\n\toverride void remove(string name, SettingType settingType)\n\t{\n\t\tif (settingType == SettingType.server)\n\t\t\tnewSettings[name] = null;\n\t\telse\n\t\t\tsuper.remove(name, settingType);\n\t}\n\n\toverride string[] save()\n\t{\n\t\tfinalize();\n\n\t\tforeach (name, value; newSettings)\n\t\t{\n\t\t\tif (value is null)\n\t\t\t{\n\t\t\t\tif (name !in settings)\n\t\t\t\t\tcontinue;\n\n\t\t\t\tquery!\"DELETE FROM `UserSettings` WHERE `User` = ? AND `Name` = ?\".exec(username, name);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tif (name in settings && settings[name] == value)\n\t\t\t\t\tcontinue;\n\n\t\t\t\tquery!\"INSERT OR REPLACE INTO `UserSettings` (`User`, `Name`, `Value`) VALUES (?, ?, ?)\".exec(username, name, value);\n\t\t\t}\n\t\t}\n\n\t\treturn super.save();\n\t}\n\n\toverride void logIn(string username, string password, bool remember) { throw new Exception(_!\"Already logged in\"); }\n\toverride bool isLoggedIn() { return true; }\n\toverride void register(string username, string password, bool remember) { throw new Exception(_!\"Already registered\"); }\n\toverride string getName() { return username; }\n\toverride Level getLevel() { return level; }\n\toverride SysTime createdAt() { return SysTime(creationTime); }\n\n\toverride bool checkPassword(string password)\n\t{\n\t\treturn query!\"SELECT COUNT(*) FROM `Users` WHERE `Username` = ? AND `Password` = ?\"\n\t\t\t.iterate(username, encryptPassword(password))\n\t\t\t.selectValue!int != 0;\n\t}\n\n\toverride void changePassword(string password)\n\t{\n\t\tenforce(password.length <= maxPasswordLength, _!\"Password too long\");\n\t\tquery!\"UPDATE `Users` SET `Password` = ? WHERE `Username` = ?\"\n\t\t\t.exec(encryptPassword(password), username);\n\t}\n\n\toverride void logOut()\n\t{\n\t\tquery!\"UPDATE `Users` SET `Session` = ? WHERE `Username` = ?\".exec(randomString(), username);\n\t\tsuper.remove(\"session\", SettingType.client);\n\t}\n\n\toverride AccountData exportData()\n\t{\n\t\tAccountData result;\n\t\tresult.username = username;\n\t\t// Omit password hash here for security reasons\n\t\t// Omit session; it is already in a cookie\n\t\tresult.level = level;\n\t\tresult.creationDate = query!\"SELECT `Created` FROM `Users` WHERE `Username` = ?\"\n\t\t\t.iterate(username).selectValue!StdTime;\n\t\tforeach (string name, string value; query!\"SELECT `Name`, `Value` FROM `UserSettings` WHERE `User` = ?\".iterate(username))\n\t\t\tresult.settings[name] = value;\n\t\treturn result;\n\t}\n\n\toverride void deleteAccount()\n\t{\n\t\t// Delete all preferences\n\t\tforeach (string name; query!\"SELECT `Name` FROM `UserSettings` WHERE `User` = ?\".iterate(username))\n\t\t\tthis.remove(name, SettingType.server);\n\t\tsave();\n\n\t\t// Delete user\n\t\tquery!\"DELETE FROM `Users` WHERE `Username` = ?\".exec(username);\n\t\tquery!\"DELETE FROM `UserSettings` WHERE `User` = ?\".exec(username);\n\t}\n\n\t// ***************************************************************************\n\n\t/// Keep read posts for registered users in memory,\n\t/// and flush them out to the database periodically.\n\n\tstatic class ReadPostsCache\n\t{\n\t\tstatic struct Entry\n\t\t{\n\t\t\tReadPostsData readPosts;\n\t\t\tbool dirty;\n\t\t}\n\t\tEntry[string] entries;\n\t\tLogger log;\n\n\t\tthis()\n\t\t{\n\t\t\tauto flushTimer = setInterval(&flushReadPostCache, 5.minutes);\n\t\t\taddShutdownHandler((scope const(char)[] reason){ flushTimer.cancel(); flushReadPostCache(); });\n\t\t\tlog = createLogger(\"ReadPostsCache\");\n\t\t}\n\n\t\tint counter;\n\n\t\tvoid flushReadPostCache()\n\t\t{\n\t\t\tmixin(DB_TRANSACTION);\n\t\t\tforeach (username, ref cacheEntry; entries)\n\t\t\t\tif (cacheEntry.dirty)\n\t\t\t\t{\n\t\t\t\t\tlog(\"Flushing \" ~ username);\n\t\t\t\t\tauto user = registeredUser(username);\n\t\t\t\t\tuser.set(\"readposts\", encodeReadPosts(cacheEntry.readPosts), SettingType.server);\n\t\t\t\t\tuser.save();\n\t\t\t\t\tcacheEntry.dirty = false;\n\t\t\t\t}\n\t\t\tif (++counter % 100 == 0)\n\t\t\t{\n\t\t\t\tlog(\"Clearing cache.\");\n\t\t\t\tforeach (username, ref cacheEntry; entries)\n\t\t\t\t\tcacheEntry.readPosts = ReadPostsData.init; // Free memory now\n\t\t\t\tentries = null;\n\t\t\t}\n\t\t}\n\t}\n\tstatic ReadPostsCache readPostsCache;\n\n\toverride void getReadPosts()\n\t{\n\t\tif (!readPostsCache) readPostsCache = new ReadPostsCache();\n\t\tauto pcache = username in readPostsCache.entries;\n\t\tif (pcache)\n\t\t\treadPosts = pcache.readPosts;\n\t\telse\n\t\t{\n\t\t\tsuper.getReadPosts();\n\t\t\treadPostsCache.entries[username] = ReadPostsCache.Entry(readPosts, false);\n\t\t}\n\t}\n\n\toverride void saveReadPosts()\n\t{\n\t\tif (!readPostsCache) readPostsCache = new ReadPostsCache();\n\t\tauto pcache = username in readPostsCache.entries;\n\t\tif (pcache)\n\t\t{\n\t\t\tassert(readPosts is pcache.readPosts);\n\t\t\tpcache.dirty = true;\n\t\t}\n\t\telse\n\t\t\treadPostsCache.entries[username] = ReadPostsCache.Entry(readPosts, true);\n\t}\n}\nalias RegisteredUser = RCClass!CRegisteredUser;\nalias registeredUser = rcClass!CRegisteredUser;\n\n// ***************************************************************************\n\nUser getUser(string cookieHeader)\n{\n\tauto guest = guestUser(cookieHeader);\n\tif (\"session\" in guest.cookies)\n\t{\n\t\tforeach (string username, int level, StdTime creationTime; query!\"SELECT `Username`, `Level`, `Created` FROM `Users` WHERE `Session` = ?\".iterate(guest.cookies[\"session\"]))\n\t\t\treturn User(registeredUser(username, cookieHeader, cast(CUser.Level)level, creationTime));\n\t}\n\treturn User(guest);\n}\n\n// ***************************************************************************\n\nstruct Config\n{\n\tstring salt;\n}\nimmutable Config config;\n\nimport ae.utils.sini;\nimport dfeed.paths : resolveSiteFile;\nshared static this() { config = loadIni!Config(resolveSiteFile(\"config/user.ini\")); }\n"
  },
  {
    "path": "src/dfeed/web/web/cache.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Some cached data.\nmodule dfeed.web.web.cache;\n\nimport dfeed.database;\nimport dfeed.sinks.cache;\nimport dfeed.web.web.perf;\n\nint[string] getThreadCounts()\n{\n\tenum PERF_SCOPE = \"getThreadCounts\"; mixin(MeasurePerformanceMixin);\n\tint[string] threadCounts;\n\tforeach (string group, int count; query!\"SELECT `Group`, COUNT(*) FROM `Threads` GROUP BY `Group`\".iterate())\n\t\tthreadCounts[group] = count;\n\treturn threadCounts;\n}\n\nint[string] getPostCounts()\n{\n\tenum PERF_SCOPE = \"getPostCounts\"; mixin(MeasurePerformanceMixin);\n\tint[string] postCounts;\n\tforeach (string group, int count; query!\"SELECT `Group`, COUNT(*) FROM `Groups`  GROUP BY `Group`\".iterate())\n\t\tpostCounts[group] = count;\n\treturn postCounts;\n}\n\nCached!(int[string]) threadCountCache, postCountCache;\n"
  },
  {
    "path": "src/dfeed/web/web/config.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Performance logging.\nmodule dfeed.web.web.config;\n\nimport ae.utils.sini : loadIni;\nimport dfeed.paths : resolveSiteFile;\n\nstruct ListenConfig\n{\n\tstring addr;\n\tushort port = 8080;\n}\n\nstruct Config\n{\n\tListenConfig listen;\n\tstring remoteIPHeader;  // e.g. \"X-Forwarded-For\" when behind a reverse proxy\n\tstring staticDomain = null;\n\tstring apiSecret = null;\n\tbool indexable = false;\n\n\t// Widget configuration\n\tstring announceGroup;              // Group for \"Latest announcements\" widget\n\tstring[] activeDiscussionExclude;  // Groups to exclude from \"Active discussions\"\n}\nconst Config config;\n\nshared static this() { config = loadIni!Config(resolveSiteFile(\"config/web.ini\")); }\n"
  },
  {
    "path": "src/dfeed/web/web/draft.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Post draft utility code.\nmodule dfeed.web.web.draft;\n\nimport std.conv : to;\nimport std.datetime.systime : Clock;\nimport std.typecons : Flag, No;\n\nimport ae.net.ietf.headers : Headers;\nimport ae.net.ietf.url : UrlParameters;\nimport ae.utils.json : toJson, jsonParse;\nimport ae.utils.text : randomString;\n\nimport dfeed.loc;\nimport dfeed.database : query;\nimport dfeed.groups : GroupInfo;\nimport dfeed.message : Rfc850Post, isMarkdown;\nimport dfeed.web.posting : PostDraft, PostProcess;\nimport dfeed.web.web.postinfo : getPost;\nimport dfeed.web.web.user : user, userSettings;\n\nvoid createDraft(PostDraft draft)\n{\n\tquery!\"INSERT INTO [Drafts] ([ID], [UserID], [Status], [ClientVars], [ServerVars], [Time]) VALUES (?, ?, ?, ?, ?, ?)\"\n\t\t.exec(draft.clientVars[\"did\"], userSettings.id, int(draft.status), draft.clientVars.toJson, draft.serverVars.toJson, Clock.currTime.stdTime);\n}\n\n// Handle backwards compatibility in stored drafts\nUrlParameters jsonParseUrlParameters(string json)\n{\n\tif (!json)\n\t\treturn UrlParameters.init;\n\ttry\n\t\treturn jsonParse!UrlParameters(json);\n\tcatch (Exception e)\n\t{\n\t\tstatic struct S { string[][string] items; }\n\t\tS s = jsonParse!S(json);\n\t\treturn UrlParameters(s.items);\n\t}\n}\n\nPostDraft getDraft(string draftID)\n{\n\tT parse(T)(string json) { return json ? json.jsonParse!T : T.init; }\n\tforeach (int status, string clientVars, string serverVars; query!\"SELECT [Status], [ClientVars], [ServerVars] FROM [Drafts] WHERE [ID] == ?\".iterate(draftID))\n\t\treturn PostDraft(status.to!(PostDraft.Status), jsonParseUrlParameters(clientVars), parse!(string[string])(serverVars));\n\tthrow new Exception(\"Can't find this message draft\");\n}\n\nPostDraft.Status getDraftStatus(string draftID)\n{\n\tforeach (int status; query!\"SELECT [Status] FROM [Drafts] WHERE [ID] == ?\".iterate(draftID))\n\t\treturn status.to!(PostDraft.Status);\n\treturn PostDraft.Status.reserved;\n}\n\nvoid ensureDraftWritable(string draftID)\n{\n\tfinal switch (getDraftStatus(draftID))\n\t{\n\t\tcase PostDraft.status.reserved:\n\t\tcase PostDraft.status.edited:\n\t\tcase PostDraft.status.discarded:\n\t\t\treturn;\n\t\tcase PostDraft.status.sent:\n\t\t\tthrow new Exception(_!\"Can't edit this message. It has already been sent.\");\n\t\tcase PostDraft.status.moderation:\n\t\t\tthrow new Exception(_!\"Can't edit this message. It has already been submitted for moderation.\");\n\t}\n}\n\nvoid saveDraft(PostDraft draft, Flag!\"force\" force = No.force)\n{\n\tauto draftID = draft.clientVars.get(\"did\", null);\n\tauto postID = draft.serverVars.get(\"pid\", null);\n\tif (!force)\n\t\tensureDraftWritable(draftID);\n\tquery!\"UPDATE [Drafts] SET [PostID]=?, [ClientVars]=?, [ServerVars]=?, [Time]=?, [Status]=? WHERE [ID] == ?\"\n\t\t.exec(postID, draft.clientVars.toJson, draft.serverVars.toJson, Clock.currTime.stdTime, int(draft.status), draftID);\n}\n\nvoid autoSaveDraft(UrlParameters clientVars)\n{\n\tauto draftID = clientVars.get(\"did\", null);\n\tensureDraftWritable(draftID);\n\tquery!\"UPDATE [Drafts] SET [ClientVars]=?, [Time]=?, [Status]=? WHERE [ID] == ?\"\n\t\t.exec(clientVars.toJson, Clock.currTime.stdTime, PostDraft.Status.edited, draftID);\n}\n\nPostDraft newPostDraft(GroupInfo groupInfo, UrlParameters parameters = null)\n{\n\tauto draftID = randomString();\n\tauto draft = PostDraft(PostDraft.Status.reserved, UrlParameters([\n\t\t\"did\" : draftID,\n\t\t\"name\" : userSettings.name,\n\t\t\"email\" : userSettings.email,\n\t\t\"subject\" : parameters.get(\"subject\", null),\n\t\t\"markdown\" : \"on\",\n\t]), [\n\t\t\"where\" : groupInfo.internalName,\n\t]);\n\tcreateDraft(draft);\n\treturn draft;\n}\n\nPostDraft newReplyDraft(Rfc850Post post)\n{\n\tauto postTemplate = post.replyTemplate();\n\tauto draftID = randomString();\n\tauto draft = PostDraft(PostDraft.Status.reserved, UrlParameters([\n\t\t\"did\" : draftID,\n\t\t\"name\" : userSettings.name,\n\t\t\"email\" : userSettings.email,\n\t\t\"subject\" : postTemplate.subject,\n\t\t\"text\" : postTemplate.content,\n\t]), [\n\t\t\"where\" : post.where,\n\t\t\"parent\" : post.id,\n\t]);\n\tif (post.isMarkdown())\n\t\tdraft.clientVars[\"markdown\"] = \"on\";\n\n\tcreateDraft(draft);\n\treturn draft;\n}\n\nRfc850Post draftToPost(PostDraft draft, Headers headers = Headers.init, string ip = null)\n{\n\tauto parent = \"parent\" in draft.serverVars ? getPost(draft.serverVars[\"parent\"]) : null;\n\treturn PostProcess.createPost(draft, headers, ip, parent);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/moderation.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Moderation tools.\nmodule dfeed.web.web.moderation;\n\nimport std.algorithm.iteration : map, filter, uniq;\nimport std.algorithm.sorting : sort;\nimport std.array : array, split;\nimport std.exception : enforce;\nimport std.file : exists, read, rename;\nimport std.format : format;\nimport std.process : execute;\nimport std.range : chain, only;\nimport std.range.primitives : empty;\nimport std.regex : match;\nimport std.stdio : File;\nimport std.string : splitLines, indexOf;\nimport std.typecons : Flag, Yes;\n\nimport ae.net.http.common : HttpRequest;\nimport ae.net.ietf.headers : Headers;\nimport ae.net.ietf.url : UrlParameters;\nimport ae.sys.log : Logger, fileLogger;\nimport ae.utils.json : toJson, jsonParse;\nimport ae.utils.meta : identity;\nimport ae.utils.regex : escapeRE;\nimport ae.utils.sini : loadIni;\n\nimport dfeed.paths : resolveSiteFile;\nimport ae.utils.text : splitAsciiLines, asciiStrip;\n\nimport dfeed.common : handleModeration;\nimport dfeed.database : query;\nimport dfeed.groups : getGroupInfo;\nimport dfeed.message : Rfc850Post;\nimport dfeed.sinks.cache : dbVersion;\nimport dfeed.site : site;\nimport dfeed.sources.newsgroups : NntpConfig;\nimport dfeed.web.moderation : banned, saveBanList, parseParents;\nimport dfeed.web.posting : PostProcess, PostDraft;\nimport dfeed.web.web.draft : getDraft, saveDraft;\nimport dfeed.web.web.postinfo : getPost;\nimport dfeed.web.web.posting : postDraft;\nimport dfeed.web.web.postmod : learnModeratedMessage;\nimport dfeed.web.web.user : userSettings;\n\nstring findPostingLog(string id)\n{\n\tif (id.match(`^<[a-z]{20}@` ~ site.host.escapeRE() ~ `>`))\n\t{\n\t\tauto post = id[1..21];\n\t\tversion (Windows)\n\t\t\tauto logs = dirEntries(\"logs\", \"*PostProcess-\" ~ post ~ \".log\", SpanMode.depth).array;\n\t\telse\n\t\t{\n\t\t\timport std.process;\n\t\t\tauto result = execute([\"find\", \"logs/\", \"-name\", \"*PostProcess-\" ~ post ~ \".log\"]); // This is MUCH faster than dirEntries.\n\t\t\tenforce(result.status == 0, \"find error\");\n\t\t\tauto logs = splitLines(result.output);\n\t\t}\n\t\tif (logs.length == 1)\n\t\t\treturn logs[0];\n\t}\n\treturn null;\n}\n\nstruct DeletedPostInfo\n{\n\tstring timestamp;\n\tstring moderator;\n\tstring reason;\n\tstring messageContent;\n\tstring[string] postsRow;\n\tstring[string] threadsRow;\n}\n\nDeletedPostInfo findDeletedPostInfo(string messageID)\n{\n\timport std.file : dirEntries, SpanMode;\n\timport std.algorithm : startsWith;\n\n\tDeletedPostInfo result;\n\n\tforeach (de; dirEntries(\"logs\", \"* - Deleted.log\", SpanMode.shallow))\n\t{\n\t\tauto content = cast(string) read(de.name);\n\t\tauto lines = splitLines(content);\n\n\t\tfor (size_t i = 0; i < lines.length; i++)\n\t\t{\n\t\t\tauto line = lines[i];\n\t\t\t// Look for deletion line: \"[timestamp] User X is deleting post <id> (reason)\"\n\t\t\tauto deletingPos = line.indexOf(\" is deleting post \");\n\t\t\tif (deletingPos < 0)\n\t\t\t\tcontinue;\n\n\t\t\t// Check if this line is for our message ID\n\t\t\tif (line.indexOf(messageID) < 0)\n\t\t\t\tcontinue;\n\n\t\t\t// Extract timestamp (everything before first \"]\")\n\t\t\tauto bracketPos = line.indexOf(\"]\");\n\t\t\tif (bracketPos > 0 && line.length > 1 && line[0] == '[')\n\t\t\t\tresult.timestamp = line[1 .. bracketPos];\n\n\t\t\t// Extract moderator name: \"User X is deleting\"\n\t\t\tauto userPos = line.indexOf(\"User \");\n\t\t\tif (userPos >= 0)\n\t\t\t{\n\t\t\t\tauto afterUser = line[userPos + 5 .. deletingPos];\n\t\t\t\tresult.moderator = afterUser;\n\t\t\t}\n\n\t\t\t// Extract reason from parentheses at end\n\t\t\tauto lastParen = line.indexOf(\"(\");\n\t\t\tauto lastCloseParen = line.indexOf(\")\");\n\t\t\tif (lastParen >= 0 && lastCloseParen > lastParen)\n\t\t\t\tresult.reason = line[lastParen + 1 .. lastCloseParen];\n\n\t\t\t// Extract message content (lines containing \"] > \")\n\t\t\t// Log format: \"[timestamp] > content\"\n\t\t\tstring[] contentLines;\n\t\t\tsize_t j = i + 1;\n\t\t\twhile (j < lines.length)\n\t\t\t{\n\t\t\t\tauto contentMarker = lines[j].indexOf(\"] > \");\n\t\t\t\tif (contentMarker >= 0)\n\t\t\t\t{\n\t\t\t\t\tcontentLines ~= lines[j][contentMarker + 4 .. $];\n\t\t\t\t\tj++;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t\tbreak;\n\t\t\t}\n\t\t\timport std.array : join;\n\t\t\tresult.messageContent = contentLines.join(\"\\n\");\n\n\t\t\t// Look for [Posts] and [Threads] rows\n\t\t\twhile (j < lines.length)\n\t\t\t{\n\t\t\t\tauto postsMarker = lines[j].indexOf(\"[Posts] row: \");\n\t\t\t\tauto threadsMarker = lines[j].indexOf(\"[Threads] row: \");\n\t\t\t\tif (postsMarker >= 0)\n\t\t\t\t{\n\t\t\t\t\ttry\n\t\t\t\t\t\tresult.postsRow = lines[j][postsMarker + 13 .. $].jsonParse!(string[string]);\n\t\t\t\t\tcatch (Exception)\n\t\t\t\t\t{ }\n\t\t\t\t}\n\t\t\t\telse if (threadsMarker >= 0)\n\t\t\t\t{\n\t\t\t\t\ttry\n\t\t\t\t\t\tresult.threadsRow = lines[j][threadsMarker + 15 .. $].jsonParse!(string[string]);\n\t\t\t\t\tcatch (Exception)\n\t\t\t\t\t{ }\n\t\t\t\t}\n\t\t\t\telse if (lines[j].indexOf(\" is \") >= 0 && lines[j].indexOf(\"post \") >= 0)\n\t\t\t\t{\n\t\t\t\t\t// Start of next log entry\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tj++;\n\t\t\t}\n\n\t\t\treturn result;\n\t\t}\n\t}\n\n\treturn result;\n}\n\nvoid moderatePost(\n\tstring messageID, string reason, string userName,\n\tFlag!\"deleteLocally\" deleteLocally,\n\tFlag!\"ban\" ban,\n\tFlag!\"deleteSource\" deleteSource,\n\tFlag!\"callSinks\" callSinks,\n\tvoid delegate(string) feedbackCallback,\n)\n{\n\tauto post = getPost(messageID);\n\tenforce(post, \"Post not found\");\n\n\tauto moderationLog = fileLogger(\"Deleted\");\n\tscope(exit) moderationLog.close();\n\tscope(failure) moderationLog(\"An error occurred\");\n\n\tvoid feedback(string message)\n\t{\n\t\tmoderationLog(message);\n\t\tfeedbackCallback(message);\n\t}\n\n\tmoderationLog(\"User %s is %s post %s (%s)\".format(\n\t\t\tuserName, deleteLocally ? \"deleting\" : \"moderating\", post.id, reason));\n\tforeach (line; post.message.splitAsciiLines())\n\t\tmoderationLog(\"> \" ~ line);\n\n\tforeach (string[string] values; query!\"SELECT * FROM `Posts` WHERE `ID` = ?\".iterate(post.id))\n\t\tmoderationLog(\"[Posts] row: \" ~ values.toJson());\n\tforeach (string[string] values; query!\"SELECT * FROM `Threads` WHERE `ID` = ?\".iterate(post.id))\n\t\tmoderationLog(\"[Threads] row: \" ~ values.toJson());\n\n\tif (ban)\n\t{\n\t\tbanPoster(userName, post.id, reason);\n\t\tfeedback(\"User banned.\");\n\t}\n\n\tif (callSinks)\n\t{\n\t\thandleModeration(post, ban);\n\t}\n\n\tif (deleteSource)\n\t{\n\t\tauto deleteCommands = post.xref\n\t\t\t.map!(x => x.group.getGroupInfo())\n\t\t\t.filter!(g => g.sinkType == \"nntp\")\n\t\t\t.map!(g => loadIni!NntpConfig(resolveSiteFile(\"config/sources/nntp/\" ~ g.sinkName ~ \".ini\")).deleteCommand)\n\t\t\t.filter!identity\n\t\t\t.array.sort.uniq\n\t\t;\n\t\tauto args = chain(messageID.only, post.xref.map!(xref => \"%s:%d\".format(xref.group, xref.num))).array;\n\t\tforeach (deleteCommand; deleteCommands)\n\t\t{\n\t\t\tauto result = execute([deleteCommand] ~ args);\n\t\t\tfeedback(\"Deletion from source %s with %s\".format(\n\t\t\t\tresult.status == 0 ? \"was successful\" : \"failed with status %d\".format(result.status),\n\t\t\t\tresult.output.empty ? \"no output.\" : \"the following output:\",\n\t\t\t));\n\t\t\tforeach (line; result.output.asciiStrip.splitAsciiLines)\n\t\t\t\tfeedback(\"> \" ~ line);\n\t\t}\n\t}\n\n\tif (deleteLocally)\n\t{\n\t\tquery!\"DELETE FROM `Posts` WHERE `ID` = ?\".exec(post.id);\n\t\tquery!\"DELETE FROM `Threads` WHERE `ID` = ?\".exec(post.id);\n\n\t\tdbVersion++;\n\t\tfeedback(\"Post deleted.\");\n\t}\n}\n\nstring approvePost(string draftID, string who)\n{\n\tauto draft = getDraft(draftID);\n\tdraft.serverVars[\"preapproved\"] = null;\n\tauto headers = Headers(draft.serverVars.get(\"headers\", \"null\").jsonParse!(string[][string]));\n\tPostProcess.allowReposting(draft);\n\tauto pid = postDraft(draft, headers);\n\tsaveDraft(draft, Yes.force);\n\n\tneedBanLog();\n\tbanLog(\"User %s is approving draft %s (post %s) titled %(%s%) by %(%s%)\".format(\n\t\twho, draftID, pid,\n\t\t[draft.clientVars.get(\"subject\", \"\")],\n\t\t[draft.clientVars.get(\"name\", \"\")],\n\t));\n\n\tlearnModeratedMessage(draft, false, 10);\n\n\treturn pid;\n}\n\n// Create logger on demand, to avoid creating empty log files\nLogger banLog;\nvoid needBanLog() { if (!banLog) banLog = fileLogger(\"Banned\"); }\n\nvoid banPoster(string who, string id, string reason)\n{\n\tneedBanLog();\n\tbanLog(\"User %s is banning poster of post %s (%s)\".format(who, id, reason));\n\tauto fn = findPostingLog(id);\n\tenforce(fn && fn.exists, \"Can't find posting log\");\n\n\tauto pp = new PostProcess(fn);\n\tstring[] keys;\n\tkeys ~= pp.ip;\n\tkeys ~= pp.draft.clientVars.get(\"secret\", null);\n\tforeach (cookie; pp.headers.get(\"Cookie\", null).split(\"; \"))\n\t{\n\t\tauto p = cookie.indexOf(\"=\");\n\t\tif (p<0) continue;\n\t\tauto name = cookie[0..p];\n\t\tauto value = cookie[p+1..$];\n\t\tif (name == \"dfeed_secret\" || name == \"dfeed_session\")\n\t\t\tkeys ~= value;\n\t}\n\n\tforeach (key; keys)\n\t\tif (key.length)\n\t\t{\n\t\t\tif (key in banned)\n\t\t\t\tbanLog(\"Key already known: \" ~ key);\n\t\t\telse\n\t\t\t{\n\t\t\t\tbanned[key] = reason;\n\t\t\t\tbanLog(\"Adding key: \" ~ key);\n\t\t\t}\n\t\t}\n\n\tsaveBanList();\n\tbanLog(\"Done.\");\n}\n\nstruct BanCheckResult\n{\n\tstring key;     // The matched ban key (empty if not banned)\n\tstring reason;  // The ban reason (empty if not banned)\n\n\tbool opCast(T : bool)() const\n\t{\n\t\treturn key.length > 0;\n\t}\n}\n\n/// If the user is banned, returns the matched key and ban reason.\n/// Otherwise, returns an empty BanCheckResult.\nBanCheckResult banCheck(string ip, HttpRequest request)\n{\n\tstring[] keys = [ip];\n\tforeach (cookie; request.headers.get(\"Cookie\", null).split(\"; \"))\n\t{\n\t\tauto p = cookie.indexOf(\"=\");\n\t\tif (p<0) continue;\n\t\tauto name = cookie[0..p];\n\t\tauto value = cookie[p+1..$];\n\t\tif (name == \"dfeed_secret\" || name == \"dfeed_session\")\n\t\t\tif (value.length)\n\t\t\t\tkeys ~= value;\n\t}\n\tstring secret = userSettings.secret;\n\tif (secret.length)\n\t\tkeys ~= secret;\n\n\tstring bannedKey = null, reason = null;\n\tforeach (key; keys)\n\t\tif (key in banned)\n\t\t{\n\t\t\tbannedKey = key;\n\t\t\treason = banned[key];\n\t\t\tbreak;\n\t\t}\n\n\tif (!bannedKey)\n\t\treturn BanCheckResult.init;\n\n\tneedBanLog();\n\tbanLog(\"Request from banned user: \" ~ request.resource);\n\tforeach (name, value; request.headers)\n\t\tbanLog(\"* %s: %s\".format(name, value));\n\n\tbanLog(\"Matched on: %s (%s)\".format(bannedKey, reason));\n\tbool propagated;\n\tforeach (key; keys)\n\t\tif (key !in banned)\n\t\t{\n\t\t\tbanLog(\"Propagating: %s -> %s\".format(bannedKey, key));\n\t\t\tbanned[key] = \"%s (propagated from %s)\".format(reason, bannedKey);\n\t\t\tpropagated = true;\n\t\t}\n\n\tif (propagated)\n\t\tsaveBanList();\n\n\treturn BanCheckResult(bannedKey, reason);\n}\n\nstruct UnbanTree\n{\n\tstruct Node\n\t{\n\t\tstring key;\n\t\tstring reason;\n\t\tstring unbanReason;\n\t\tNode*[] children;\n\t}\n\n\tNode*[] roots;\n\tNode*[string] allNodes;\n}\n\nUnbanTree getUnbanPreviewByKey(string key)\n{\n\tif (key !in banned)\n\t\treturn UnbanTree.init;\n\n\t// Build parent-child relationships from ban list\n\tstring[][string] parents;\n\tstring[][string] children;\n\tforeach (k, reason; banned)\n\t{\n\t\tauto p = parseParents(reason);\n\t\tparents[k] = p;\n\t\tforeach (parent; p)\n\t\t\tchildren[parent] ~= k;\n\t}\n\n\t// Build the tree structure starting from the given key\n\tUnbanTree result;\n\tstring[] queue;\n\tbool[string] visited;\n\n\tvoid addNode(string k, string unbanReason, UnbanTree.Node* parent = null)\n\t{\n\t\tif (k in visited || k !in banned)\n\t\t\treturn;\n\t\tvisited[k] = true;\n\n\t\tauto node = new UnbanTree.Node(k, banned[k], unbanReason);\n\t\tresult.allNodes[k] = node;\n\n\t\tif (parent)\n\t\t\tparent.children ~= node;\n\t\telse\n\t\t\tresult.roots ~= node;\n\n\t\tqueue ~= k;\n\t}\n\n\t// Start with the specified key as root\n\taddNode(key, \"specified key\");\n\n\t// Process queue to add children and parents\n\twhile (queue.length)\n\t{\n\t\tauto k = queue[0];\n\t\tqueue = queue[1..$];\n\t\tauto node = result.allNodes[k];\n\n\t\t// Add parent keys as children in the tree\n\t\tforeach (p; parents.get(k, null))\n\t\t\taddNode(p, \"parent of \" ~ k, node);\n\n\t\t// Add child keys\n\t\tforeach (c; children.get(k, null))\n\t\t\taddNode(c, \"child of \" ~ k, node);\n\t}\n\n\treturn result;\n}\n\nvoid unbanPoster(string who, string id, string[] keysToUnban)\n{\n\tneedBanLog();\n\tbanLog(\"User %s is unbanning poster of post %s\".format(who, id));\n\n\tsize_t count = 0;\n\tforeach (key; keysToUnban)\n\t{\n\t\tif (key in banned)\n\t\t{\n\t\t\tbanLog(format(\"Unbanning %s\", key));\n\t\t\tbanned.remove(key);\n\t\t\tcount++;\n\t\t}\n\t}\n\n\tbanLog(format(\"Unbanned a total of %d keys.\", count));\n\tsaveBanList();\n\tbanLog(\"Done.\");\n}\n"
  },
  {
    "path": "src/dfeed/web/web/page.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Current page.\nmodule dfeed.web.web.page;\n\nimport ae.utils.textout : StringBuffer;\n\nimport dfeed.loc;\n\nStringBuffer html;\n\n// ***********************************************************************\n\nclass Redirect : Throwable\n{\n\tstring url;\n\tthis(string url) { this.url = url; super(\"Uncaught redirect\"); }\n}\n\nclass NotFoundException : Exception\n{\n\tthis(string str = null) { super(str ? str : _!\"The specified resource cannot be found on this server.\"); }\n}\n"
  },
  {
    "path": "src/dfeed/web/web/part/gravatar.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Gravatar rendering\nmodule dfeed.web.web.part.gravatar;\n\nimport std.conv : text;\nimport std.format : format;\nimport std.string;\nimport std.uni : toLower;\n\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.web.web.page : html;\n\nstring gravatar(string authorEmail, int size)\n{\n\treturn `https://www.gravatar.com/avatar/%s?d=identicon&s=%d`.format(getGravatarHash(authorEmail), size);\n}\n\nenum gravatarMetaSize = 256;\n\nstring getGravatarHash(string email)\n{\n\timport std.digest.md;\n\timport std.ascii : LetterCase;\n\treturn email.toLower().strip().md5Of().toHexString!(LetterCase.lower)().idup; // Issue 9279\n}\n\nvoid putGravatar(string gravatarHash, string personName, string linkTarget, string linkDescription, string aProps = null, int size = 0)\n{\n\thtml.put(\n\t\t`<a `, aProps, ` href=\"`), html.putEncodedEntities(linkTarget), html.put(`\">` ~\n\t\t\t`<img class=\"post-gravatar\" alt=\"Gravatar of `), html.putEncodedEntities(personName),\n\t\t\thtml.put(`\" `);\n\tif (linkDescription.length)\n\t{\n\t\thtml.put(`title=\"`), html.putEncodedEntities(linkDescription),\n\t\thtml.put(`\" aria-label=\"`), html.putEncodedEntities(linkDescription),\n\t\thtml.put(`\" `);\n\t}\n\tif (size)\n\t{\n\t\tstring sizeStr = size ? text(size) : null;\n\t\tstring x2str = text(size * 2);\n\t\thtml.put(\n\t\t\t`width=\"`, sizeStr, `\" height=\"`, sizeStr, `\" ` ~\n\t\t\t`src=\"//www.gravatar.com/avatar/`, gravatarHash, `?d=identicon&amp;s=`, sizeStr, `\" ` ~\n\t\t\t`srcset=\"//www.gravatar.com/avatar/`, gravatarHash, `?d=identicon&amp;s=`, x2str, ` `, x2str, `w\"` ~\n\t\t\t`>`\n\t\t);\n\t}\n\telse\n\t\thtml.put(\n\t\t\t`src=\"//www.gravatar.com/avatar/`, gravatarHash, `?d=identicon\" ` ~\n\t\t\t`srcset=\"//www.gravatar.com/avatar/`, gravatarHash, `?d=identicon&amp;s=160 2x\"` ~\n\t\t\t`>`\n\t\t);\n\thtml.put(`</a>`);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/part/pager.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Paging.\nmodule dfeed.web.web.part.pager;\n\nimport std.algorithm.comparison;\nimport std.algorithm.searching;\nimport std.array;\nimport std.conv;\nimport std.exception;\n\nimport ae.utils.text.html : encodeHtmlEntities;\n\nimport dfeed.loc;\nimport dfeed.groups : GroupInfo;\nimport dfeed.web.web.cache;\nimport dfeed.web.web.page : html;\n\n/// pageCount==int.max indicates unknown number of pages\nvoid pager(string base, int page, int pageCount, int maxWidth = 50)\n{\n\tif (!pageCount)\n\t\treturn html.put(`<tr><th colspan=\"3\">-</th></tr>`);\n\n\tstring linkOrNot(string text, int page, bool cond)\n\t{\n\t\tif (cond)\n\t\t\treturn `<a href=\"` ~ encodeHtmlEntities(base) ~ (base.canFind('?') ? `&` : `?`) ~ `page=` ~ .text(page) ~ `\">` ~ text ~ `</a>`;\n\t\telse\n\t\t\treturn `<span class=\"disabled-link\">` ~ text ~ `</span>`;\n\t}\n\n\t// Try to make the pager as wide as it will fit in the alotted space\n\n\tint widthAt(int radius)\n\t{\n\t\timport std.math : log10;\n\n\t\tint pagerStart = max(1, page - radius);\n\t\tint pagerEnd = min(pageCount, page + radius);\n\t\tif (pageCount==int.max)\n\t\t\tpagerEnd = page + 1;\n\n\t\tint width = pagerEnd - pagerStart;\n\t\tforeach (n; pagerStart..pagerEnd+1)\n\t\t\twidth += 1 + cast(int)log10(n);\n\t\tif (pagerStart > 1)\n\t\t\twidth += 3;\n\t\tif (pagerEnd < pageCount)\n\t\t\twidth += 3;\n\t\treturn width;\n\t}\n\n\tint radius = 0;\n\tfor (; radius < 10 && widthAt(radius+1) < maxWidth; radius++) {}\n\n\tint pagerStart = max(1, page - radius);\n\tint pagerEnd = min(pageCount, page + radius);\n\tif (pageCount==int.max)\n\t\tpagerEnd = page + 1;\n\n\tstring[] pager;\n\tif (pagerStart > 1)\n\t\tpager ~= \"&hellip;\";\n\tforeach (pagerPage; pagerStart..pagerEnd+1)\n\t\tif (pagerPage == page)\n\t\t\tpager ~= `<b>` ~ text(pagerPage) ~ `</b>`;\n\t\telse\n\t\t\tpager ~= linkOrNot(text(pagerPage), pagerPage, true);\n\tif (pagerEnd < pageCount)\n\t\tpager ~= \"&hellip;\";\n\n\thtml.put(\n\t\t`<tr><th class=\"pager-row\" colspan=\"3\"><div class=\"pager\">` ~\n\t\t\t`<div class=\"pager-left\">`,\n\t\t\t\tlinkOrNot(\"&laquo; \" ~ _!\"First\", 1, page!=1),\n\t\t\t\t`&nbsp;&nbsp;&nbsp;`,\n\t\t\t\tlinkOrNot(\"&lsaquo; \" ~ _!\"Prev\", page-1, page>1),\n\t\t\t`</div>` ~\n\t\t\t`<div class=\"pager-numbers\">`, pager.join(` `), `</div>` ~\n\t\t\t`<div class=\"pager-right\">`,\n\t\t\t\tlinkOrNot(_!\"Next\" ~ \" &rsaquo;\", page+1, page<pageCount),\n\t\t\t\t`&nbsp;&nbsp;&nbsp;`,\n\t\t\t\tlinkOrNot(_!\"Last\" ~ \" &raquo; \", pageCount, page!=pageCount && pageCount!=int.max),\n\t\t\t`</div></div>` ~\n\t\t`</th></tr>`);\n}\n\nenum THREADS_PER_PAGE = 15;\nenum POSTS_PER_PAGE = 10;\n\nstatic int indexToPage(int index, int perPage)  { return index / perPage + 1; } // Return value is 1-based, index is 0-based\nstatic int getPageCount(int count, int perPage) { return count ? indexToPage(count-1, perPage) : 0; }\nstatic int getPageOffset(int page, int perPage) { return (page-1) * perPage; }\n\nvoid threadPager(GroupInfo groupInfo, int page, int maxWidth = 40)\n{\n\tauto threadCounts = threadCountCache(getThreadCounts());\n\tauto threadCount = threadCounts.get(groupInfo.internalName, 0);\n\tauto pageCount = getPageCount(threadCount, THREADS_PER_PAGE);\n\n\tpager(`/group/` ~ groupInfo.urlName, page, pageCount, maxWidth);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/part/post.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Formatting posts.\nmodule dfeed.web.web.part.post;\n\nimport std.algorithm.iteration : map;\nimport std.array : array, join, replicate;\nimport std.conv : text;\nimport std.format;\n\nimport ae.net.ietf.message : Rfc850Message;\nimport ae.utils.text.html : encodeHtmlEntities;\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.loc;\nimport dfeed.message : idToUrl, Rfc850Post, idToFragment;\nimport dfeed.web.user : User;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.part.gravatar : getGravatarHash, putGravatar;\nimport dfeed.web.web.part.postbody : formatBody;\nimport dfeed.web.web.part.profile : profileUrl;\nimport dfeed.web.web.part.strings : formatShortTime, summarizeTime;\nimport dfeed.web.web.postinfo : PostInfo, getPostInfo, idToThreadUrl;\nimport dfeed.web.web.statics : staticPath;\nimport dfeed.web.web.user : user, userSettings;\n\n// ***********************************************************************\n\nstring postLink(int rowid, string id, string author)\n{\n\treturn\n\t\t`<a class=\"postlink ` ~ (user.isRead(rowid) ? \"forum-read\" : \"forum-unread\") ~ `\" ` ~\n\t\t\t`href=\"`~ encodeHtmlEntities(idToUrl(id)) ~ `\">` ~ encodeHtmlEntities(author) ~ `</a>`;\n}\n\nstring postLink(PostInfo* info)\n{\n\treturn postLink(info.rowid, info.id, info.author);\n}\n\n// ***********************************************************************\n\nstruct PostAction { string className, text, title, url, icon; }\n\nPostAction[] getPostActions(Rfc850Message msg)\n{\n\tPostAction[] actions;\n\tauto id = msg.id;\n\tif (userSettings.groupViewMode == \"basic\")\n\t\tactions ~= PostAction(\"permalink\", _!\"Permalink\",\n\t\t\t_!\"Canonical link to this post. See \\\"Canonical links\\\" on the Help page for more information.\",\n\t\t\tidToUrl(id), \"link\");\n\tif (true)\n\t\tactions ~= PostAction(\"replylink\", _!\"Reply\",\n\t\t\t_!\"Reply to this post\",\n\t\t\tidToUrl(id, \"reply\"), \"reply\");\n/*\n\tif (mailHide)\n\t\tactions ~= PostAction(\"emaillink\", _!\"Email\",\n\t\t\t_!\"Solve a CAPTCHA to obtain this poster's email address.\",\n\t\t\tmailHide.getUrl(msg.authorEmail), \"email\");\n*/\n\tif (user.isLoggedIn() && msg.references.length == 0)\n\t\tactions ~= PostAction(\"subscribelink\", _!\"Subscribe\",\n\t\t\t_!\"Subscribe to this thread\",\n\t\t\tidToUrl(id, \"subscribe\"), \"star\");\n\tif (user.getLevel() >= User.Level.canFlag && user.createdAt() < msg.time)\n\t\tactions ~= PostAction(\"flaglink\", _!\"Flag\",\n\t\t\t_!\"Flag this post for moderator intervention\",\n\t\t\tidToUrl(id, \"flag\"), \"flag\");\n\tif (user.getLevel() >= User.Level.hasRawLink)\n\t\tactions ~= PostAction(\"sourcelink\", _!\"Source\",\n\t\t\t_!\"View this message's source code\",\n\t\t\tidToUrl(id, \"source\"), \"source\");\n\tif (user.getLevel() >= User.Level.canModerate)\n\t\tactions ~= PostAction(\"modlink\", _!\"Moderate\",\n\t\t\t_!\"Perform moderation actions on this post\",\n\t\t\tidToUrl(id, \"moderate\"), \"delete\");\n\treturn actions;\n}\n\nvoid postActions(PostAction[] actions)\n{\n\tforeach (action; actions)\n\t\thtml.put(\n\t\t\t`<a class=\"actionlink `, action.className, `\" href=\"`), html.putEncodedEntities(action.url), html.put(`\" ` ~\n\t\t\t\t`title=\"`), html.putEncodedEntities(action.title), html.put(`\">` ~\n\t\t\t\t`<img src=\"`, staticPath(\"/images/\" ~ action.icon~ \".png\"), `\">`), html.putEncodedEntities(action.text), html.put(\n\t\t\t`</a>`);\n}\n\n// ***********************************************************************\n\nstring getParentLink(Rfc850Post post, Rfc850Post[string] knownPosts)\n{\n\tif (post.parentID)\n\t{\n\t\tstring author, link;\n\t\tif (post.parentID in knownPosts)\n\t\t{\n\t\t\tauto parent = knownPosts[post.parentID];\n\t\t\tauthor = parent.author;\n\t\t\tlink = '#' ~ idToFragment(parent.id);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tauto parent = getPostInfo(post.parentID);\n\t\t\tif (parent)\n\t\t\t{\n\t\t\t\tauthor = parent.author;\n\t\t\t\tlink = idToUrl(parent.id);\n\t\t\t}\n\t\t}\n\n\t\tif (author && link)\n\t\t\treturn `<a href=\"` ~ encodeHtmlEntities(link) ~ `\">` ~ encodeHtmlEntities(author) ~ `</a>`;\n\t}\n\n\treturn null;\n}\n\nvoid miniPostInfo(Rfc850Post post, Rfc850Post[string] knownPosts, bool showActions = true)\n{\n\tstring horizontalInfo;\n\tstring gravatarHash = getGravatarHash(post.authorEmail);\n\tauto parentLink = getParentLink(post, knownPosts);\n\twith (post.msg)\n\t{\n\t\thtml.put(\n\t\t\t`<table class=\"mini-post-info\"><tr>` ~\n\t\t\t\t`<td class=\"mini-post-info-avatar\">`);\n\t\tputGravatar(gravatarHash, author, profileUrl(author, authorEmail), _!`%s's profile`.format(author), null, 32);\n\t\thtml.put(\n\t\t\t\t`</td>` ~\n\t\t\t\t`<td>` ~\n\t\t\t\t\t_!`Posted by %s`.format(`<b>` ~ encodeHtmlEntities(author) ~ `</b>`),\n\t\t\t\t\tparentLink ? `<br>` ~ _!`in reply to` ~ ` ` ~ parentLink : null,\n\t\t\t\t`</td>`\n\t\t);\n\t\tif (showActions)\n\t\t\thtml.put(\n\t\t\t\t`<td class=\"post-info-actions\">`), postActions(getPostActions(post.msg)), html.put(`</td>`\n\t\t\t);\n\t\thtml.put(\n\t\t\t`</tr></table>`\n\t\t);\n\t}\n}\n\n// ***********************************************************************\n\nstring[] formatPostParts(Rfc850Post post)\n{\n\tstring[] partList;\n\tvoid visitParts(Rfc850Message[] parts, int[] path)\n\t{\n\t\tforeach (i, part; parts)\n\t\t{\n\t\t\tif (part.parts.length)\n\t\t\t\tvisitParts(part.parts, path~cast(int)i);\n\t\t\telse\n\t\t\tif (part.content !is post.content)\n\t\t\t{\n\t\t\t\tstring partUrl = ([idToUrl(post.id, \"raw\")] ~ array(map!text(path~cast(int)i))).join(\"/\");\n\t\t\t\twith (part)\n\t\t\t\t\tpartList ~=\n\t\t\t\t\t\t(name || fileName) ?\n\t\t\t\t\t\t\t`<a href=\"` ~ encodeHtmlEntities(partUrl) ~ `\" title=\"` ~ encodeHtmlEntities(mimeType) ~ `\">` ~\n\t\t\t\t\t\t\tencodeHtmlEntities(name) ~\n\t\t\t\t\t\t\t(name && fileName ? \" - \" : \"\") ~\n\t\t\t\t\t\t\tencodeHtmlEntities(fileName) ~\n\t\t\t\t\t\t\t`</a>` ~\n\t\t\t\t\t\t\t(description ? ` (` ~ encodeHtmlEntities(description) ~ `)` : \"\")\n\t\t\t\t\t\t:\n\t\t\t\t\t\t\t`<a href=\"` ~ encodeHtmlEntities(partUrl) ~ `\">` ~\n\t\t\t\t\t\t\tencodeHtmlEntities(mimeType) ~\n\t\t\t\t\t\t\t`</a> ` ~ _!`part` ~\n\t\t\t\t\t\t\t(description ? ` (` ~ encodeHtmlEntities(description) ~ `)` : \"\");\n\t\t\t}\n\t\t}\n\t}\n\tvisitParts(post.parts, null);\n\treturn partList;\n}\n\nvoid formatPost(Rfc850Post post, Rfc850Post[string] knownPosts, bool markAsRead = true)\n{\n\tstring gravatarHash = getGravatarHash(post.authorEmail);\n\n\tstring[] infoBits;\n\n\tauto parentLink = getParentLink(post, knownPosts);\n\tif (parentLink)\n\t\tinfoBits ~= _!`Posted in reply to` ~ ` ` ~ parentLink;\n\n\tauto partList = formatPostParts(post);\n\tif (partList.length)\n\t\tinfoBits ~=\n\t\t\t_!`Attachments:` ~ `<ul class=\"post-info-parts\"><li>` ~ partList.join(`</li><li>`) ~ `</li></ul>`;\n\n\tif (knownPosts is null && post.cachedThreadID)\n\t\tinfoBits ~=\n\t\t\t`<a href=\"` ~ encodeHtmlEntities(idToThreadUrl(post.id, post.cachedThreadID)) ~ `\">` ~ _!`View in thread` ~ `</a>`;\n\n\tstring repliesTitle = encodeHtmlEntities(_!`Replies to %s's post from %s`.format(post.author, formatShortTime(post.time, false)));\n\n\twith (post.msg)\n\t{\n\t\thtml.put(\n\t\t\t`<div class=\"post-wrapper\">` ~\n\t\t\t`<table class=\"post forum-table`, (post.children ? ` with-children` : ``), `\" id=\"`), html.putEncodedEntities(idToFragment(id)), html.put(`\">` ~\n\t\t\t`<tr class=\"table-fixed-dummy\">`, `<td></td>`.replicate(2), `</tr>` ~ // Fixed layout dummies\n\t\t\t`<tr class=\"post-header\"><th colspan=\"2\">` ~\n\t\t\t\t`<div class=\"post-time\">`, summarizeTime(time), `</div>` ~\n\t\t\t\t`<a title=\"`, _!`Permanent link to this post`, `\" href=\"`), html.putEncodedEntities(idToUrl(id)), html.put(`\" class=\"permalink `, (user.isRead(post.rowid) ? \"forum-read\" : \"forum-unread\"), `\">`,\n\t\t\t\t\tencodeHtmlEntities(rawSubject),\n\t\t\t\t`</a>` ~\n\t\t\t`</th></tr>` ~\n\t\t\t`<tr class=\"mini-post-info-cell\">` ~\n\t\t\t\t`<td colspan=\"2\">`\n\t\t); miniPostInfo(post, knownPosts); html.put(\n\t\t\t\t`</td>` ~\n\t\t\t`</tr>` ~\n\t\t\t`<tr>` ~\n\t\t\t\t`<td class=\"post-info\">` ~\n\t\t\t\t\t`<div class=\"post-author\">`), html.putEncodedEntities(author), html.put(`</div>`);\n\t\tputGravatar(gravatarHash, author, profileUrl(author, authorEmail), _!`%s's profile`.format(author), null, 80);\n\t\tif (infoBits.length)\n\t\t{\n\t\t\thtml.put(`<hr>`);\n\t\t\tforeach (b; infoBits)\n\t\t\t\thtml.put(`<div class=\"post-info-bit\">`, b, `</div>`);\n\t\t}\n\t\telse\n\t\t\thtml.put(`<br>`);\n\t\tauto actions = getPostActions(post.msg);\n\t\tforeach (n; 0..actions.length)\n\t\t\thtml.put(`<br>`); // guarantee space for the \"toolbar\"\n\n\t\thtml.put(\n\t\t\t\t\t`<div class=\"post-actions\">`), postActions(actions), html.put(`</div>` ~\n\t\t\t\t`</td>` ~\n\t\t\t\t`<td class=\"post-body\">`,\n//\t\t); miniPostInfo(post, knownPosts); html.put(\n\t\t\t\t\t), formatBody(post), html.put(\n\t\t\t\t\t(error ? `<span class=\"post-error\">` ~ encodeHtmlEntities(error) ~ `</span>` : ``),\n\t\t\t\t`</td>` ~\n\t\t\t`</tr>` ~\n\t\t\t`</table>` ~\n\t\t\t`</div>`);\n\n\t\tif (post.children)\n\t\t{\n\t\t\thtml.put(\n\t\t\t\t`<table class=\"post-nester\"><tr>` ~\n\t\t\t\t`<td class=\"post-nester-bar\" title=\"`, /* for IE */ repliesTitle, `\">` ~\n\t\t\t\t\t`<a href=\"#`), html.putEncodedEntities(idToFragment(id)), html.put(`\" ` ~\n\t\t\t\t\t\t`title=\"`, repliesTitle, `\"></a>` ~\n\t\t\t\t`</td>` ~\n\t\t\t\t`<td>`);\n\t\t\tforeach (child; post.children)\n\t\t\t\tformatPost(child, knownPosts);\n\t\t\thtml.put(`</td>` ~\n\t\t\t\t`</tr></table>`);\n\t\t}\n\t}\n\n\tif (post.rowid && markAsRead)\n\t\tuser.setRead(post.rowid, true);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/part/postbody.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Formatting post bodies.\nmodule dfeed.web.web.part.postbody;\n\nimport std.algorithm.comparison : max;\nimport std.algorithm.iteration : splitter, map, reduce;\nimport std.array : join;\nimport std.range : iota, radial;\nimport std.regex : matchAll;\n\nimport ae.net.ietf.message : Rfc850Message;\nimport ae.net.ietf.wrap : unwrapText;\nimport ae.utils.meta : I;\nimport ae.utils.regex : re;\nimport ae.utils.text : contains, segmentByWhitespace;\nimport ae.utils.text.html : encodeHtmlEntities;\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.loc;\nimport dfeed.message : isMarkdown;\nimport dfeed.web.markdown : haveMarkdown, renderMarkdownCached;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.user : userSettings;\n\nenum reURL = `\\w+://[^<>\\s]+[\\w/\\-+=]`;\n\nvoid formatBody(Rfc850Message post)\n{\n\tauto paragraphs = unwrapText(post.content, post.wrapFormat);\n\n\tif (post.isMarkdown() && userSettings.renderMarkdown == \"true\" && haveMarkdown())\n\t{\n\t\tauto content = paragraphs.map!((ref p) => p.quotePrefix ~ p.text ~ \"\\n\").join();\n\t\tauto result = renderMarkdownCached(content);\n\t\tif (result.error)\n\t\t{\n\t\t\thtml.put(\n\t\t\t\t`<div class=\"forum-notice\">`,\n\t\t\t\t\t_!`Failed to render Markdown:`), html.putEncodedEntities(result.error), html.put(\n\t\t\t\t`</div>`);\n\t\t\t// continue on to plain text\n\t\t}\n\t\telse\n\t\t{\n\t\t\thtml.put(\n\t\t\t\t`<div class=\"post-text markdown\">`,\n\t\t\t\tresult.html,\n\t\t\t\t`</div>`\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\t}\n\n\thtml.put(`<pre class=\"post-text\">`);\n\tbool inSignature = false;\n\tint quoteLevel = 0;\n\tforeach (paragraph; paragraphs)\n\t{\n\t\tint paragraphQuoteLevel;\n\t\tforeach (c; paragraph.quotePrefix)\n\t\t\tif (c == '>')\n\t\t\t\tparagraphQuoteLevel++;\n\n\t\tfor (; quoteLevel > paragraphQuoteLevel; quoteLevel--)\n\t\t\thtml ~= `</span>`;\n\t\tfor (; quoteLevel < paragraphQuoteLevel; quoteLevel++)\n\t\t\thtml ~= `<span class=\"forum-quote\">`;\n\n\t\tif (!quoteLevel && (paragraph.text == \"-- \" || paragraph.text == \"_______________________________________________\"))\n\t\t{\n\t\t\thtml ~= `<span class=\"forum-signature\">`;\n\t\t\tinSignature = true;\n\t\t}\n\n\t\tenum forceWrapThreshold = 30;\n\t\tenum forceWrapMinChunkSize =  5;\n\t\tenum forceWrapMaxChunkSize = 15;\n\t\tstatic assert(forceWrapMaxChunkSize > forceWrapMinChunkSize * 2);\n\n\t\timport std.utf : byChar;\n\t\tbool needsWrap = paragraph.text.byChar.splitter(' ').map!(s => s.length).I!(r => reduce!max(size_t.init, r)) > forceWrapThreshold;\n\n\t\tauto hasURL = paragraph.text.contains(\"://\");\n\t\tauto hasHashTags = paragraph.text.contains('#');\n\n\t\tvoid processText(string s)\n\t\t{\n\t\t\thtml.put(encodeHtmlEntities(s));\n\t\t}\n\n\t\tvoid processWrap(string s)\n\t\t{\n\t\t\talias next = processText;\n\n\t\t\tif (!needsWrap)\n\t\t\t\treturn next(s);\n\n\t\t\tauto segments = s.segmentByWhitespace();\n\t\t\tforeach (ref segment; segments)\n\t\t\t{\n\t\t\t\tif (segment.length > forceWrapThreshold)\n\t\t\t\t{\n\t\t\t\t\tvoid chunkify(string s, string delimiters)\n\t\t\t\t\t{\n\t\t\t\t\t\tif (s.length < forceWrapMaxChunkSize)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\thtml.put(`<span class=\"forcewrap\">`);\n\t\t\t\t\t\t\tnext(s);\n\t\t\t\t\t\t\thtml.put(`</span>`);\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\tif (!delimiters.length)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\t// Don't cut UTF-8 sequences in half\n\t\t\t\t\t\t\tstatic bool canCutAt(char c) { return (c & 0x80) == 0 || (c & 0x40) != 0; }\n\t\t\t\t\t\t\tforeach (i; s.length.iota.radial)\n\t\t\t\t\t\t\t\tif (canCutAt(s[i]))\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tchunkify(s[0..i], null);\n\t\t\t\t\t\t\t\t\tchunkify(s[i..$], null);\n\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tchunkify(s[0..$/2], null);\n\t\t\t\t\t\t\tchunkify(s[$/2..$], null);\n\t\t\t\t\t\t}\n\t\t\t\t\t\telse\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tforeach (i; iota(forceWrapMinChunkSize, s.length-forceWrapMinChunkSize).radial)\n\t\t\t\t\t\t\t\tif (s[i] == delimiters[0])\n\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\tchunkify(s[0..i+1], delimiters);\n\t\t\t\t\t\t\t\t\tchunkify(s[i+1..$], delimiters);\n\t\t\t\t\t\t\t\t\treturn;\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\tchunkify(s, delimiters[1..$]);\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\n\t\t\t\t\tchunkify(segment, \"/&=.-+,;:_\\\\|`'\\\"~!@#$%^*()[]{}\");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t\tnext(segment);\n\t\t\t}\n\t\t}\n\n\t\tvoid processURLs(string s)\n\t\t{\n\t\t\talias next = processWrap;\n\n\t\t\tif (!hasURL)\n\t\t\t\treturn next(s);\n\n\t\t\tsize_t pos = 0;\n\t\t\tforeach (m; matchAll(s, re!reURL))\n\t\t\t{\n\t\t\t\tnext(s[pos..m.pre().length]);\n\t\t\t\thtml.put(`<a rel=\"nofollow\" href=\"`, m.hit(), `\">`);\n\t\t\t\tnext(m.hit());\n\t\t\t\thtml.put(`</a>`);\n\t\t\t\tpos = m.pre().length + m.hit().length;\n\t\t\t}\n\t\t\tnext(s[pos..$]);\n\t\t}\n\n\t\tvoid processHashTags(string s)\n\t\t{\n\t\t\talias next = processURLs;\n\n\t\t\tif (!hasHashTags)\n\t\t\t\treturn next(s);\n\n\t\t\tsize_t pos = 0;\n\t\t\tenum reHashTag = `(^| )(#([a-zA-Z][a-zA-Z0-9_-]+))`;\n\t\t\tforeach (m; matchAll(s, re!reHashTag))\n\t\t\t{\n\t\t\t\tnext(s[pos .. m.pre().length + m[1].length]);\n\t\t\t\thtml.put(`<a href=\"/search?q=`, m[3], `\">`);\n\t\t\t\tnext(m[2]);\n\t\t\t\thtml.put(`</a>`);\n\t\t\t\tpos = m.pre().length + m.hit().length;\n\t\t\t}\n\t\t\tnext(s[pos..$]);\n\t\t}\n\n\t\talias first = processHashTags;\n\n\t\tif (paragraph.quotePrefix.length)\n\t\t\thtml.put(`<span class=\"forum-quote-prefix\">`), html.putEncodedEntities(paragraph.quotePrefix), html.put(`</span>`);\n\t\tfirst(paragraph.text);\n\t\thtml.put('\\n');\n\t}\n\tfor (; quoteLevel; quoteLevel--)\n\t\thtml ~= `</span>`;\n\tif (inSignature)\n\t\thtml ~= `</span>`;\n\thtml.put(`</pre>`);\n}\n\n// https://github.com/CyberShadow/DFeed/issues/121\nunittest\n{\n\timport std.string : strip;\n\tauto msg = new Rfc850Message(q\"EOF\nSubject: test\n\nhttp://a/b+\nEOF\");\n\tscope(exit) html.clear();\n\tformatBody(msg);\n\tassert(html.get.strip == `<pre class=\"post-text\"><a rel=\"nofollow\" href=\"http://a/b+\">http://a/b+</a>\n\n</pre>`, html.get.strip);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/part/profile.d",
    "content": "/*  Copyright (C) 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// User profile utilities.\nmodule dfeed.web.web.part.profile;\n\nimport std.ascii : LetterCase;\nimport std.digest.sha;\n\nimport ae.utils.text.html : encodeHtmlEntities;\n\nimport dfeed.web.web.page : html;\n\n/// Generate a URL-safe hash for a (name, email) identity tuple.\n/// Uses first 32 hex chars of SHA256 of name + null byte + email.\nstring getProfileHash(string name, string email)\n{\n\tauto hash = sha256Of(name ~ \"\\0\" ~ email);\n\treturn hash.toHexString!(LetterCase.lower)()[0..32].idup;\n}\n\n/// Generate the URL path for a user profile.\nstring profileUrl(string name, string email)\n{\n\treturn \"/user/\" ~ getProfileHash(name, email);\n}\n\n/// Output a link to the user's profile page.\nvoid putAuthorLink(string author, string authorEmail)\n{\n\thtml.put(`<a href=\"`, profileUrl(author, authorEmail), `\">`);\n\thtml.put(encodeHtmlEntities(author));\n\thtml.put(`</a>`);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/part/strings.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Various string formatting.\nmodule dfeed.web.web.part.strings;\n\nimport core.time;\n\nimport std.algorithm.comparison : min, max;\nimport std.conv : text;\nimport std.datetime.systime : SysTime, Clock;\nimport std.datetime.timezone : UTC;\nimport std.format : format;\n\nimport ae.utils.text.html : encodeHtmlEntities;\n\nimport dfeed.loc;\n\nstring summarizeTime(SysTime time, bool colorize = false)\n{\n\tif (!time.stdTime)\n\t\treturn \"-\";\n\n\tstring style;\n\tif (colorize)\n\t{\n\t\timport std.math;\n\t\tauto diff = Clock.currTime() - time;\n\t\tauto diffLog = log2(diff.total!\"seconds\");\n\t\tenum LOG_MIN = 10; // 1 hour-ish\n\t\tenum LOG_MAX = 18; // 3 days-ish\n\t\tenum COLOR_MAX = 0xA0;\n\t\tauto f = (diffLog - LOG_MIN) / (LOG_MAX - LOG_MIN);\n\t\tf = min(1, max(0, f));\n\t\tauto c = cast(int)(f * COLOR_MAX);\n\n\t\tstyle ~= format(\"color: #%02X%02X%02X;\", c, c, c);\n\t}\n\n\tbool shorter = colorize; // hack\n\treturn `<span style=\"` ~ style ~ `\" title=\"` ~ encodeHtmlEntities(formatAbsoluteTime(time)) ~ `\">` ~ encodeHtmlEntities(formatShortTime(time, shorter)) ~ `</span>`;\n}\n\nstring formatTinyTime(SysTime time)\n{\n\tif (!time.stdTime)\n\t\treturn \"-\";\n\n\tSysTime now = Clock.currTime(UTC());\n\tDuration duration = now - time;\n\t\n\tif (duration < 1.seconds)\n\t\treturn \"0s\";\n\telse\n\tif (duration < 1.minutes)\n\t\treturn text(duration.total!\"seconds\", _!\"s\");\n\telse\n\tif (duration < 1.hours)\n\t\treturn text(duration.total!\"minutes\", _!\"m\");\n\telse\n\tif (duration < 1.days)\n\t\treturn text(duration.total!\"hours\", _!\"h\");\n\telse\n\tif (duration < 7.days)\n\t\treturn text(duration.total!\"days\", _!\"d\");\n\telse\n\tif (duration < 300.days)\n\t\treturn time.formatTimeLoc!\"M j\"();\n\telse\n\t\treturn time.formatTimeLoc!\"M 'y\"();\n}\n\nstring formatShortTime(SysTime time, bool shorter)\n{\n\tif (!time.stdTime)\n\t\treturn \"-\";\n\n\tauto now = Clock.currTime(UTC());\n\tauto duration = now - time;\n\n\tif (duration < 7.days)\n\t\treturn formatDuration(duration);\n\telse\n\tif (duration < 300.days)\n\t\tif (shorter)\n\t\t\treturn time.formatTimeLoc!\"M d\"();\n\t\telse\n\t\t\treturn time.formatTimeLoc!\"F d\"();\n\telse\n\t\tif (shorter)\n\t\t\treturn time.formatTimeLoc!\"M d, Y\"();\n\t\telse\n\t\t\treturn time.formatTimeLoc!\"F d, Y\"();\n}\n\nstring formatDuration(Duration duration)\n{\n\tstring ago(string unit)(long amount)\n\t{\n\t\tassert(amount > 0);\n\t\treturn _!\"%d %s ago\".format(amount, plural!unit(amount));\n\t}\n\n\tif (duration < 0.seconds)\n\t\treturn _!\"from the future\";\n\telse\n\tif (duration < 1.seconds)\n\t\treturn _!\"just now\";\n\telse\n\tif (duration < 1.minutes)\n\t\treturn ago!\"second\"(duration.total!\"seconds\");\n\telse\n\tif (duration < 1.hours)\n\t\treturn ago!\"minute\"(duration.total!\"minutes\");\n\telse\n\tif (duration < 1.days)\n\t\treturn ago!\"hour\"(duration.total!\"hours\");\n\telse\n\t/*if (duration < dur!\"days\"(2))\n\t\treturn \"yesterday\";\n\telse\n\tif (duration < dur!\"days\"(6))\n\t\treturn formatTimeLoc!\"l\"(time);\n\telse*/\n\tif (duration < 7.days)\n\t\treturn ago!\"day\"(duration.total!\"days\");\n\telse\n\tif (duration < 31.days)\n\t\treturn ago!\"week\"(duration.total!\"weeks\");\n\telse\n\tif (duration < 365.days)\n\t\treturn ago!\"month\"(duration.total!\"days\" / 30);\n\telse\n\t\treturn ago!\"year\"(duration.total!\"days\" / 365);\n}\n\nstring formatLongTime(SysTime time)\n{\n\tif (!time.stdTime)\n\t\treturn \"-\";\n\n\tSysTime now = Clock.currTime(UTC());\n\tDuration duration = now - time;\n\t\n\tif (duration < 7.days)\n\t\treturn formatDuration(duration);\n\telse\n\t\treturn formatAbsoluteTime(time);\n}\n\nstring formatAbsoluteTime(SysTime time)\n{\n\treturn time.formatTimeLoc!\"l, d F Y, H:i:s e\"();\n}\n\n/// Add thousand-separators\nstring formatNumber(long n)\n{\n\tstring s = text(n);\n\tint digits = 0;\n\tauto separator = digitGroupingSeparators[currentLanguage];\n\tforeach_reverse(p; 1..s.length)\n\t\tif (++digits % 3 == 0)\n\t\t\ts = s[0..p] ~ separator ~ s[p..$];\n\treturn s;\n}\n\nstatic string truncateString(string s8, int maxLength = 30)\n{\n\tauto encoded = encodeHtmlEntities(s8);\n\treturn `<span class=\"truncated\" style=\"max-width: ` ~ text(maxLength * 0.6) ~ `em\" title=\"`~encoded~`\">` ~ encoded ~ `</span>`;\n}\n\n/+\n/// Generate a link to set a user preference\nstring setOptionLink(string name, string value)\n{\n\treturn \"/set?\" ~ encodeUrlParameters(UrlParameters([name : value, \"url\" : \"__URL__\", \"secret\" : userSettings.secret]));\n}\n+/\n"
  },
  {
    "path": "src/dfeed/web/web/part/thread.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Formatting threads.\nmodule dfeed.web.web.part.thread;\n\nimport std.algorithm.comparison : min;\nimport std.algorithm.sorting : sort;\nimport std.datetime.systime : SysTime;\nimport std.datetime.timezone : UTC;\nimport std.format : format;\n\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.loc;\nimport dfeed.database : query;\nimport dfeed.message : idToUrl;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.perf;\nimport dfeed.web.web.postinfo : PostInfo, getPost;\nimport dfeed.web.web.user : user, userSettings;\nimport dfeed.web.web.part.profile : profileUrl;\nimport dfeed.web.web.part.strings : summarizeTime, truncateString;\n\nstring[][string] referenceCache; // invariant\n\nvoid formatThreadedPosts(PostInfo*[] postInfos, bool narrow, string selectedID = null)\n{\n\tenum OFFSET_INIT = 1f;\n\tenum OFFSET_MAX = 2f;\n\tenum OFFSET_WIDTH = 25f;\n\tenum OFFSET_UNITS = \"%\";\n\n\tclass Post\n\t{\n\t\tPostInfo* info;\n\t\tPost parent;\n\n\t\tSysTime maxTime;\n\t\tPost[] children;\n\t\tint maxDepth;\n\n\t\tbool ghost; // dummy parent for orphans\n\t\tstring ghostSubject;\n\n\t\t@property string subject() { return ghostSubject ? ghostSubject : info.subject; }\n\n\t\tthis(PostInfo* info = null) { this.info = info; }\n\n\t\tvoid calcStats()\n\t\t{\n\t\t\tforeach (child; children)\n\t\t\t\tchild.calcStats();\n\n\t\t\tif (info)\n\t\t\t\tmaxTime = info.time;\n\t\t\tforeach (child; children)\n\t\t\t\tif (maxTime < child.maxTime)\n\t\t\t\t\tmaxTime = child.maxTime;\n\t\t\t//maxTime = reduce!max(time, map!\"a.maxTime\"(children));\n\n\t\t\tmaxDepth = 1;\n\t\t\tforeach (child; children)\n\t\t\t\tif (maxDepth < 1 + child.maxDepth)\n\t\t\t\t\tmaxDepth = 1 + child.maxDepth;\n\t\t}\n\t}\n\n\tPost[string] posts;\n\tforeach (info; postInfos)\n\t\tposts[info.id] = new Post(info);\n\n\t// Check if linking child under parent would create a cycle\n\t// by walking up parent's ancestor chain\n\tbool wouldCreateCycle(Post child, Post parent)\n\t{\n\t\tfor (Post p = parent; p !is null; p = p.parent)\n\t\t\tif (p is child)\n\t\t\t\treturn true;\n\t\treturn false;\n\t}\n\n\tposts[null] = new Post();\n\tforeach (post; posts.values)\n\t\tif (post.info)\n\t\t{\n\t\t\tauto parent = post.info.parentID;\n\t\t\t// Parent missing or would create cycle - find alternate parent\n\t\t\tif (parent !in posts || wouldCreateCycle(post, posts[parent]))\n\t\t\t{\n\t\t\t\tstring[] references;\n\t\t\t\tif (post.info.id in referenceCache)\n\t\t\t\t\treferences = referenceCache[post.info.id];\n\t\t\t\telse\n\t\t\t\t\treferences = referenceCache[post.info.id] = getPost(post.info.id).references;\n\n\t\t\t\t// Search References header for any ancestor in this thread\n\t\t\t\tparent = null;\n\t\t\t\tforeach_reverse (reference; references)\n\t\t\t\t\tif (reference in posts && !wouldCreateCycle(post, posts[reference]))\n\t\t\t\t\t{\n\t\t\t\t\t\tparent = reference;\n\t\t\t\t\t\tbreak;\n\t\t\t\t\t}\n\n\t\t\t\t// No valid parent found - create ghost post for missing parent\n\t\t\t\tif (!parent && references.length)\n\t\t\t\t{\n\t\t\t\t\tauto dummy = new Post;\n\t\t\t\t\tdummy.ghost = true;\n\t\t\t\t\tdummy.ghostSubject = post.info.subject; // HACK\n\t\t\t\t\tparent = references[0];\n\t\t\t\t\tposts[parent] = dummy;\n\t\t\t\t\tdummy.parent = posts[null];\n\t\t\t\t\tposts[null].children ~= dummy;\n\t\t\t\t}\n\t\t\t}\n\t\t\t// Link post to its parent (or root if none was found)\n\t\t\tpost.parent = posts[parent];\n\t\t\tposts[parent].children ~= post;\n\t\t}\n\n\tbool reversed = userSettings.groupViewMode == \"threaded\";\n\tposts[null].calcStats();\n\tforeach (post; posts)\n\t{\n\t\tif (post.info || post.ghost)\n\t\t\tsort!\"a.info.time < b.info.time\"(post.children);\n\t\telse // sort threads by last-update\n\t\tif (reversed)\n\t\t\tsort!\"a.maxTime > b.maxTime\"(post.children);\n\t\telse\n\t\t\tsort!\"a.maxTime < b.maxTime\"(post.children);\n\t}\n\n\tfloat offsetIncrement; // = max(1f, min(OFFSET_MAX, OFFSET_WIDTH / posts[null].maxDepth));\n\n\tstring normalizeSubject(string s)\n\t{\n\t\timport std.array : replace;\n\t\timport std.algorithm.searching : skipOver;\n\n\t\ts.skipOver(\"Re: \");\n\t\treturn s\n\t\t\t.replace(\"New: \", \"\") // Bugzilla hack\n\t\t\t.replace(\"\\t\", \" \")   // Apple Mail hack\n\t\t\t.replace(\" \", \"\")     // Outlook Express hack\n\t\t;\n\t}\n\n\t// Group replies under a ghost post when multiple replies have the same subject,\n\t// but different from their parent (Bugzilla hack)\n\tforeach (thread; posts[null].children)\n\t{\n\t\tfor (int i=1; i<thread.children.length; )\n\t\t{\n\t\t\tauto child = thread.children[i];\n\t\t\tauto prevChild = thread.children[i-1];\n\t\t\tif (normalizeSubject(child.subject) != normalizeSubject(thread.subject) &&\n\t\t\t\tnormalizeSubject(child.subject) == normalizeSubject(prevChild.subject))\n\t\t\t{\n\t\t\t\tif (prevChild.ghost) // add to the existing ghost\n\t\t\t\t{\n\t\t\t\t\tchild.parent = prevChild;\n\t\t\t\t\tprevChild.children ~= child;\n\t\t\t\t\tthread.children = thread.children[0..i] ~ thread.children[i+1..$];\n\t\t\t\t}\n\t\t\t\telse // new ghost\n\t\t\t\t{\n\t\t\t\t\tauto dummy = new Post;\n\t\t\t\t\tdummy.ghost = true;\n\t\t\t\t\tdummy.ghostSubject = child.subject;\n\t\t\t\t\tprevChild.parent = child.parent = dummy;\n\t\t\t\t\tdummy.children = [prevChild, child];\n\t\t\t\t\tdummy.parent = thread;\n\t\t\t\t\tthread.children = thread.children[0..i-1] ~ dummy ~ thread.children[i+1..$];\n\t\t\t\t}\n\t\t\t}\n\t\t\telse\n\t\t\t\ti++;\n\t\t}\n\t}\n\n\tvoid formatPosts(Post[] posts, int level, string parentSubject, bool topLevel)\n\t{\n\t\tvoid formatPost(Post post, int level)\n\t\t{\n\t\t\timport std.format : format;\n\n\t\t\tif (post.ghost)\n\t\t\t\treturn formatPosts(post.children, level, post.subject, false);\n\t\t\thtml.put(\n\t\t\t\t`<tr class=\"thread-post-row`, (post.info && post.info.id==selectedID ? ` focused selected` : ``), `\">` ~\n\t\t\t\t\t`<td>` ~\n\t\t\t\t\t\t`<div style=\"padding-left: `, format(\"%1.1f\", OFFSET_INIT + level * offsetIncrement), OFFSET_UNITS, `\">` ~\n\t\t\t\t\t\t\t`<div class=\"thread-post-time\">`, summarizeTime(post.info.time, true), `</div>`,\n\t\t\t\t\t\t\t`<a class=\"postlink `, (user.isRead(post.info.rowid) ? \"forum-read\" : \"forum-unread\" ), `\" href=\"`), html.putEncodedEntities(idToUrl(post.info.id)), html.put(`\">`, truncateString(post.info.author, narrow ? 17 : 50), `</a>` ~\n\t\t\t\t\t\t`</div>` ~\n\t\t\t\t\t`</td>` ~\n\t\t\t\t`</tr>`);\n\t\t\tformatPosts(post.children, level+1, post.subject, false);\n\t\t}\n\n\t\tforeach (post; posts)\n\t\t{\n\t\t\tif (topLevel)\n\t\t\t\toffsetIncrement = min(OFFSET_MAX, OFFSET_WIDTH / post.maxDepth);\n\n\t\t\tif (topLevel || normalizeSubject(post.subject) != normalizeSubject(parentSubject))\n\t\t\t{\n\t\t\t\tauto offsetStr = format(\"%1.1f\", OFFSET_INIT + level * offsetIncrement) ~ OFFSET_UNITS;\n\t\t\t\thtml.put(\n\t\t\t\t\t`<tr><td style=\"padding-left: `, offsetStr, `\">` ~\n\t\t\t\t\t`<table class=\"thread-start\">` ~\n\t\t\t\t\t\t`<tr><th>`), html.putEncodedEntities(post.subject), html.put(`</th></tr>`);\n                formatPost(post, 0);\n\t\t\t\thtml.put(\n\t\t\t\t\t`</table>` ~\n\t\t\t\t\t`</td></tr>`);\n\t\t\t}\n\t\t\telse\n\t\t\t\tformatPost(post, level);\n\t\t}\n\t}\n\n\tformatPosts(posts[null].children, 0, null, true);\n}\n\n// ***********************************************************************\n\nPostInfo*[] getThreadPosts(string threadID)\n{\n\tPostInfo*[] posts;\n\tenum ViewSQL = \"SELECT `ROWID`, `ID`, `ParentID`, `Author`, `AuthorEmail`, `Subject`, `Time` FROM `Posts` WHERE `ThreadID` = ?\";\n\tforeach (int rowid, string id, string parent, string author, string authorEmail, string subject, long stdTime; query!ViewSQL.iterate(threadID))\n\t\tposts ~= [PostInfo(rowid, id, null, parent, author, authorEmail, subject, SysTime(stdTime, UTC()))].ptr;\n\treturn posts;\n}\n\nvoid discussionThreadOverview(string threadID, string selectedID)\n{\n\tenum PERF_SCOPE = \"discussionThreadOverview\"; mixin(MeasurePerformanceMixin);\n\thtml.put(\n\t\t`<table id=\"thread-index\" class=\"forum-table group-wrapper viewmode-`), html.putEncodedEntities(userSettings.groupViewMode), html.put(`\">` ~\n\t\t`<tr class=\"group-index-header\"><th><div>` ~ _!`Thread overview` ~ `</div></th></tr>`,\n\t\t`<tr><td class=\"group-threads-cell\"><div class=\"group-threads\"><table>`);\n\tformatThreadedPosts(getThreadPosts(threadID), false, selectedID);\n\thtml.put(`</table></div></td></tr></table>`);\n}\n\n"
  },
  {
    "path": "src/dfeed/web/web/perf.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Performance logging.\nmodule dfeed.web.web.perf;\n\nimport ae.sys.log;\n\nenum measurePerformance = true;\n\nstatic if (is(typeof({import std.datetime.stopwatch;})))\n{\n\timport std.datetime.stopwatch;\n\talias StopWatch = std.datetime.stopwatch.StopWatch;\n\tDuration readStopwatch(ref StopWatch sw) { return sw.peek(); }\n}\nelse\n\tDuration readStopwatch(ref StopWatch sw) { return sw.peek().msecs.msecs; }\n\nstatic if (measurePerformance) Logger perfLog;\n\nenum MeasurePerformanceMixin =\nq{\n\tstatic if (measurePerformance)\n\t{\n\t\tStopWatch performanceSW;\n\t\tperformanceSW.start();\n\t\tscope(success)\n\t\t{\n\t\t\tperformanceSW.stop();\n\t\t\timport std.conv : text;\n\t\t\tperfLog(PERF_SCOPE ~ \": \" ~ text(performanceSW.readStopwatch));\n\t\t}\n\t}\n};\n"
  },
  {
    "path": "src/dfeed/web/web/postinfo.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Post data and lookup.\nmodule dfeed.web.web.postinfo;\n\nimport ae.net.ietf.message : Rfc850Message;\n\nimport std.algorithm.searching : startsWith, endsWith;\nimport std.datetime.systime : SysTime;\nimport std.datetime.timezone : UTC;\nimport std.exception : enforce;\n\nimport dfeed.loc;\nimport dfeed.database : query, selectValue;\nimport dfeed.message : Rfc850Post, idToUrl, idToFragment;\nimport dfeed.sinks.cache : CachedSet;\nimport dfeed.web.web.page : NotFoundException;\nimport dfeed.web.web.part.pager : indexToPage, POSTS_PER_PAGE;\nimport dfeed.web.web.view.thread : getPostThreadIndex;\n\nstring resolvePostUrl(string id)\n{\n\tforeach (string threadID; query!\"SELECT `ThreadID` FROM `Posts` WHERE `ID` = ?\".iterate(id))\n\t\treturn idToThreadUrl(id, threadID);\n\n\tthrow new NotFoundException(_!\"Post not found\");\n}\n\nstring idToThreadUrl(string id, string threadID)\n{\n\treturn idToUrl(threadID, \"thread\", indexToPage(getPostThreadIndex(id), POSTS_PER_PAGE)) ~ \"#\" ~ idToFragment(id);\n}\n\nstatic Rfc850Post getPost(string id)\n{\n\tforeach (int rowid, string message, string threadID; query!\"SELECT `ROWID`, `Message`, `ThreadID` FROM `Posts` WHERE `ID` = ?\".iterate(id))\n\t\treturn new Rfc850Post(message, id, rowid, threadID);\n\treturn null;\n}\n\nstatic Rfc850Message getPostPart(string id, uint[] partPath = null)\n{\n\tforeach (string message; query!\"SELECT `Message` FROM `Posts` WHERE `ID` = ?\".iterate(id))\n\t{\n\t\tauto post = new Rfc850Message(message);\n\t\twhile (partPath.length)\n\t\t{\n\t\t\tenforce(partPath[0] < post.parts.length, _!\"Invalid attachment\");\n\t\t\tpost = post.parts[partPath[0]];\n\t\t\tpartPath = partPath[1..$];\n\t\t}\n\t\treturn post;\n\t}\n\treturn null;\n}\n\nstatic string getPostSource(string id)\n{\n\tforeach (string message; query!\"SELECT `Message` FROM `Posts` WHERE `ID` = ?\".iterate(id))\n\t\treturn message;\n\treturn null;\n}\n\nstruct PostInfo { int rowid; string id, threadID, parentID, author, authorEmail, subject; SysTime time; }\nCachedSet!(string, PostInfo*) postInfoCache;\n\nPostInfo* getPostInfo(string id)\n{\n\treturn postInfoCache(id, retrievePostInfo(id));\n}\n\nPostInfo* retrievePostInfo(string id)\n{\n\tif (id.startsWith('<') && id.endsWith('>'))\n\t\tforeach (int rowid, string threadID, string parentID, string author, string authorEmail, string subject, long stdTime; query!\"SELECT `ROWID`, `ThreadID`, `ParentID`, `Author`, `AuthorEmail`, `Subject`, `Time` FROM `Posts` WHERE `ID` = ?\".iterate(id))\n\t\t{\n\t\t\tif (authorEmail is null)\n\t\t\t{\n\t\t\t\tauthorEmail = new Rfc850Message(query!\"SELECT [Message] FROM [Posts] WHERE [ROWID]=?\".iterate(rowid).selectValue!string).authorEmail;\n\t\t\t\tif (authorEmail is null)\n\t\t\t\t\tauthorEmail = \"\";\n\t\t\t\tassert(authorEmail !is null);\n\t\t\t\tquery!\"UPDATE [Posts] SET [AuthorEmail]=? WHERE [ROWID]=?\".exec(authorEmail, rowid);\n\t\t\t}\n\t\t\treturn [PostInfo(rowid, id, threadID, parentID, author, authorEmail, subject, SysTime(stdTime, UTC()))].ptr;\n\t\t}\n\treturn null;\n}\n"
  },
  {
    "path": "src/dfeed/web/web/posting.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021, 2022, 2024, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Authoring new posts.\nmodule dfeed.web.web.posting;\n\nimport core.time : seconds, minutes, weeks;\n\nimport std.algorithm.iteration : map;\nimport std.algorithm.searching : canFind, findSplit;\nimport std.conv : to;\nimport std.datetime.systime : SysTime, Clock;\nimport std.datetime.timezone : UTC;\nimport std.exception : enforce;\nimport std.format : format;\nimport std.string : strip, splitLines;\nimport std.typecons : Yes;\n\nimport ae.net.ietf.headers : Headers;\nimport ae.net.ietf.url : UrlParameters, encodeUrlParameter;\nimport ae.utils.aa : aaGet;\nimport ae.utils.json : toJson, jsonParse;\nimport ae.utils.meta : I, isDebug;\nimport ae.utils.sini : loadIni;\nimport ae.utils.text : splitAsciiLines;\nimport ae.utils.text.html : encodeHtmlEntities;\nimport ae.utils.xmllite;\n\nimport dfeed.loc;\nimport dfeed.paths : resolveSiteFile;\nimport dfeed.database : query;\nimport dfeed.groups : getGroupInfo;\nimport dfeed.mail : sendMail;\nimport dfeed.message : idToUrl;\nimport dfeed.sinks.subscriptions : createReplySubscription;\nimport dfeed.site : site;\nimport dfeed.web.captcha;\nimport dfeed.web.lint : getLintRule, lintRules;\nimport dfeed.web.markdown : haveMarkdown;\nimport dfeed.web.posting : PostDraft, PostProcess, PostError, SmtpConfig, PostingStatus;\nimport dfeed.web.web.draft : getDraft, saveDraft, draftToPost;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.part.post : formatPost, postLink;\nimport dfeed.web.web.part.strings : formatShortTime, formatDuration;\nimport dfeed.web.web.postinfo : getPostInfo, getPost;\nimport dfeed.web.web.postmod : shouldModerate, learnModeratedMessage, ModerationReason;\nimport dfeed.web.web.request : ip;\nimport dfeed.web.web.user : user, userSettings;\n\nvoid draftNotices(string except = null)\n{\n\tforeach (string id, long time; query!\"SELECT [ID], [Time] FROM [Drafts] WHERE [UserID]==? AND [Status]==?\".iterate(userSettings.id, PostDraft.Status.edited))\n\t{\n\t\tif (id == except)\n\t\t\tcontinue;\n\t\tauto t = SysTime(time, UTC());\n\t\thtml.put(`<div class=\"forum-notice\">`,\n\t\t\t_!`You have an %sunsent draft message from %s%s.`.format(\n\t\t\t\t`<a href=\"/posting/` ~ id ~ `\">`, formatShortTime(t, false), `</a>`\n\t\t\t),\n\t\t\t`</div>`);\n\t}\n}\n\nbool discussionPostForm(PostDraft draft, Captcha captcha=null, PostError error=PostError.init)\n{\n\tauto draftID = draft.clientVars.get(\"did\", null);\n\tdraftNotices(draftID);\n\n\tif (draft.status == PostDraft.Status.moderation)\n\t\tthrow new Exception(_!\"This message is awaiting moderation.\");\n\n\t// Only happens if visiting a posting page when it's not in\n\t// postProcesses, i.e., from a previous DFeed process instance.\n\tif (draft.status == PostDraft.Status.sent)\n\t\tthrow new Exception(_!\"This message has already been posted.\");\n\n\t// Immediately resurrect discarded posts when user clicks \"Undo\" or \"Back\"\n\tif (draft.status == PostDraft.Status.discarded)\n\t\tquery!\"UPDATE [Drafts] SET [Status]=? WHERE [ID]=?\".exec(PostDraft.Status.edited, draftID);\n\n\tauto where = draft.serverVars.get(\"where\", null);\n\tauto info = getGroupInfo(where);\n\tif (!info)\n\t\tthrow new Exception(_!\"Unknown group:\" ~ \" \" ~ where);\n\tif (info.postMessage)\n\t{\n\t\thtml.put(\n\t\t\t`<table class=\"forum-table forum-error\">` ~\n\t\t\t\t`<tr><th>`, _!`Can't post to archive`, `</th></tr>` ~\n\t\t\t\t`<tr><td class=\"forum-table-message\">`\n\t\t\t\t\t, info.postMessage,\n\t\t\t\t`</td></tr>` ~\n\t\t\t`</table>`);\n\t\treturn false;\n\t}\n\tif (info.notice)\n\t\thtml.put(`<div class=\"forum-notice\">`, info.notice, `</div>`);\n\n\tif (info.sinkType == \"smtp\" && info.subscriptionRequired)\n\t{\n\t\tauto config = loadIni!SmtpConfig(resolveSiteFile(\"config/sources/smtp/\" ~ info.sinkName ~ \".ini\"));\n\t\thtml.put(`<div class=\"forum-notice\">`, _!`Note: you are posting to a mailing list.`, `<br>`,\n\t\t\t_!`Your message will not go through unless you %ssubscribe to the mailing list%s first.`.format(\n\t\t\t\t`<a href=\"` ~ encodeHtmlEntities(config.listInfo ~ info.internalName) ~`\">`,\n\t\t\t\t`</a>`,\n\t\t\t), `<br>`,\n\t\t\t_!`You must then use the same email address when posting here as the one you used to subscribe to the list.`, `<br>`,\n\t\t\t_!`If you do not want to receive mailing list mail, you can disable mail delivery at the above link.`, `</div>`);\n\t}\n\n\tauto parent = draft.serverVars.get(\"parent\", null);\n\tauto parentInfo\t= parent ? getPostInfo(parent) : null;\n\tif (parentInfo && Clock.currTime - parentInfo.time > 2.weeks)\n\t\thtml.put(`<div class=\"forum-notice\">`, _!`Warning: the post you are replying to is from`, ` `,\n\t\t\tformatDuration(Clock.currTime - parentInfo.time), ` (`, formatShortTime(parentInfo.time, false), `).</div>`);\n\n\thtml.put(`<form action=\"/send\" method=\"post\" class=\"forum-form post-form\" id=\"postform\">`);\n\n\tif (error.message)\n\t\thtml.put(`<div class=\"form-error\">`), html.putEncodedEntities(error.message), html.put(error.extraHTML, `</div>`);\n\thtml.put(draft.clientVars.get(\"html-top\", null));\n\n\tif (parent)\n\t\thtml.put(`<input type=\"hidden\" name=\"parent\" value=\"`), html.putEncodedEntities(parent), html.put(`\">`);\n\telse\n\t\thtml.put(`<input type=\"hidden\" name=\"where\" value=\"`), html.putEncodedEntities(where), html.put(`\">`);\n\n\tauto subject = draft.clientVars.get(\"subject\", null);\n\n\thtml.put(\n\t\t`<div id=\"postform-info\">`,\n\t\t\t_!`Posting to`, ` <b>`), html.putEncodedEntities(info.publicName), html.put(`</b>`,\n\t\t\t(parent\n\t\t\t\t? parentInfo\n\t\t\t\t\t? ` ` ~ _!`in reply to` ~ ` ` ~ postLink(parentInfo)\n\t\t\t\t\t: ` ` ~ _!`in reply to` ~ ` (` ~ _!`unknown post` ~ `)`\n\t\t\t\t: info\n\t\t\t\t\t? `:<br>(<b>` ~ encodeHtmlEntities(info.description) ~ `</b>)`\n\t\t\t\t\t: ``),\n\t\t`</div>` ~\n\t\t`<input type=\"hidden\" name=\"secret\" value=\"`, userSettings.secret, `\">` ~\n\t\t`<input type=\"hidden\" name=\"did\" value=\"`), html.putEncodedEntities(draftID), html.put(`\">` ~\n\t\t`<label for=\"postform-name\">`, _!`Your name:` ~ `</label>`,\n\t\t`<input id=\"postform-name\" name=\"name\" size=\"40\" value=\"`), html.putEncodedEntities(draft.clientVars.get(\"name\", null)), html.put(`\">` ~\n\t\t`<label for=\"postform-email\">`, _!`Your email address`, ` (<a href=\"/help#email\">?</a>):</label>` ~\n\t\t`<input id=\"postform-email\" type=\"email\" name=\"email\" size=\"40\" value=\"`), html.putEncodedEntities(draft.clientVars.get(\"email\", null)), html.put(`\">` ~\n\t\t`<label for=\"postform-subject\">`, _!`Subject:`, `</label>` ~\n\t\t`<input id=\"postform-subject\" name=\"subject\" size=\"80\"`, subject.length ? `` : ` autofocus`, ` value=\"`), html.putEncodedEntities(subject), html.put(`\">` ~\n\t\t`<label for=\"postform-text\">`, _!`Message:`, `</label>` ~\n\t\t`<textarea id=\"postform-text\" name=\"text\" rows=\"25\" cols=\"80\"`, subject.length ? ` autofocus` : ``, `>`), html.putEncodedEntities(draft.clientVars.get(\"text\", null)), html.put(`</textarea>`);\n\n\tif (captcha)\n\t\thtml.put(`<div id=\"postform-captcha\">`, captcha.getChallengeHtml(error.captchaError), `</div>`);\n\n\thtml.put(\n\t\t`<div>` ~\n\t\t\t`<div class=\"postform-action-left\">` ~\n\t\t\t\t`<input name=\"action-save\" type=\"submit\" value=\"`, _!`Save and preview`, `\">` ~\n\t\t\t\t`<input name=\"action-send\" type=\"submit\" value=\"`, _!`Send`, `\">`);\n\t\t\t\tif (haveMarkdown && userSettings.renderMarkdown == \"true\") html.put(\n\t\t\t\t\t`<label for=\"postform-markdown\"><input name=\"markdown\" id=\"postform-markdown\" type=\"checkbox\" `,\n\t\t\t\t\t\t(\"markdown\" in draft.clientVars ? `checked=\"checked\"` : \"\"),\n\t\t\t\t\t\t`> `, _!\"Enable %sMarkdown%s\".format(`<a href=\"/help#markdown\">`, `</a>`), `</label>`);\n\t\t\thtml.put(`</div>` ~\n\t\t\t`<div class=\"postform-action-right\">` ~\n\t\t\t\t`<input name=\"action-discard\" type=\"submit\" value=\"`, _!`Discard draft`, `\">` ~\n\t\t\t`</div>` ~\n\t\t\t`<div style=\"clear:right\"></div>` ~\n\t\t`</div>` ~\n\t`</form>`);\n\treturn true;\n}\n\n/// Calculate a secret string from a key.\n/// Can be used in URLs in emails to authenticate an action on a\n/// public/guessable identifier.\nversion(none)\nstring authHash(string s)\n{\n\timport dfeed.web.user : userConfig = config;\n\tauto digest = sha256Of(s ~ userConfig.salt);\n\treturn Base64.encode(digest)[0..10];\n}\n\nSysTime[][string] lastPostAttempts;\n\nstring discussionSend(UrlParameters clientVars, Headers headers)\n{\n\timport std.algorithm.iteration : filter;\n\timport std.algorithm.searching : startsWith;\n\timport std.range : chain, only;\n\n\tauto draftID = clientVars.get(\"did\", null);\n\tauto draft = getDraft(draftID);\n\n\ttry\n\t{\n\t\tif (draft.status == PostDraft.Status.sent)\n\t\t{\n\t\t\t// Redirect if we know where to\n\t\t\tif (\"pid\" in draft.serverVars)\n\t\t\t\treturn idToUrl(PostProcess.pidToMessageID(draft.serverVars[\"pid\"]));\n\t\t\telse\n\t\t\t\tthrow new Exception(_!\"This message has already been sent.\");\n\t\t}\n\n\t\tif (clientVars.get(\"secret\", \"\") != userSettings.secret)\n\t\t\tthrow new Exception(_!\"XSRF secret verification failed. Are your cookies enabled?\");\n\n\t\tif (draft.status == PostDraft.Status.moderation)\n\t\t\tthrow new Exception(_!\"This message is awaiting moderation.\");\n\t\t\n\t\tdraft.clientVars = clientVars;\n\t\tdraft.status = PostDraft.Status.edited;\n\t\tscope(exit) saveDraft(draft);\n\n\t\tauto action = clientVars.byKey.filter!(key => key.startsWith(\"action-\")).chain(\"action-none\".only).front[7..$];\n\n\t\tstatic struct UndoInfo { UrlParameters clientVars; string[string] serverVars; }\n\t\tbool lintDetails;\n\t\tif (action.startsWith(\"lint-ignore-\"))\n\t\t{\n\t\t\tdraft.serverVars[action] = null;\n\t\t\taction = \"send\";\n\t\t}\n\t\telse\n\t\tif (action.startsWith(\"lint-fix-\"))\n\t\t{\n\t\t\tauto ruleID = action[9..$];\n\t\t\ttry\n\t\t\t{\n\t\t\t\tdraft.serverVars[\"lint-undo\"] = UndoInfo(draft.clientVars, draft.serverVars).toJson;\n\t\t\t\tgetLintRule(ruleID).fix(draft);\n\t\t\t\tdraft.clientVars[\"html-top\"] = `<div class=\"forum-notice\">` ~ _!`Automatic fix applied.` ~ ` ` ~\n\t\t\t\t\t`<input name=\"action-lint-undo\" type=\"submit\" value=\"` ~ _!`Undo` ~ `\"></div>`;\n\t\t\t}\n\t\t\tcatch (Exception e)\n\t\t\t{\n\t\t\t\tdraft.serverVars[\"lint-ignore-\" ~ ruleID] = null;\n\t\t\t\thtml.put(`<div class=\"forum-notice\">` ~ _!`Sorry, a problem occurred while attempting to fix your post` ~ ` ` ~\n\t\t\t\t\t`(`), html.putEncodedEntities(e.msg), html.put(`).</div>`);\n\t\t\t}\n\t\t\tdiscussionPostForm(draft);\n\t\t\treturn null;\n\t\t}\n\t\telse\n\t\tif (action == \"lint-undo\")\n\t\t{\n\t\t\tenforce(\"lint-undo\" in draft.serverVars, _!\"Undo information not found.\");\n\t\t\tauto undoInfo = draft.serverVars[\"lint-undo\"].jsonParse!UndoInfo;\n\t\t\tdraft.clientVars = undoInfo.clientVars;\n\t\t\tdraft.serverVars = undoInfo.serverVars;\n\t\t\thtml.put(`<div class=\"forum-notice\">` ~ _!`Automatic fix undone.` ~ `</div>`);\n\t\t\tdiscussionPostForm(draft);\n\t\t\treturn null;\n\t\t}\n\t\telse\n\t\tif (action == \"lint-explain\")\n\t\t{\n\t\t\tlintDetails = true;\n\t\t\taction = \"send\";\n\t\t}\n\n\t\tswitch (action)\n\t\t{\n\t\t\tcase \"save\":\n\t\t\t{\n\t\t\t\tdiscussionPostForm(draft);\n\t\t\t\t// Show preview\n\t\t\t\tauto post = draftToPost(draft, headers, ip);\n\t\t\t\tpost.compile();\n\t\t\t\tformatPost(post, null);\n\t\t\t\treturn null;\n\t\t\t}\n\t\t\tcase \"send\":\n\t\t\t{\n\t\t\t\tuserSettings.name  = aaGet(clientVars, \"name\");\n\t\t\t\tuserSettings.email = aaGet(clientVars, \"email\");\n\n\t\t\t\tforeach (rule; lintRules)\n\t\t\t\t\tif (\"lint-ignore-\" ~ rule.id !in draft.serverVars && rule.check(draft))\n\t\t\t\t\t{\n\t\t\t\t\t\tPostError error;\n\t\t\t\t\t\terror.message = _!\"Warning:\" ~ \" \" ~ rule.shortDescription();\n\t\t\t\t\t\terror.extraHTML ~= ` <input name=\"action-lint-ignore-` ~ rule.id ~ `\" type=\"submit\" value=\"` ~ _!`Ignore` ~ `\">`;\n\t\t\t\t\t\tif (!lintDetails)\n\t\t\t\t\t\t\terror.extraHTML ~= ` <input name=\"action-lint-explain\" type=\"submit\" value=\"` ~ _!`Explain` ~ `\">`;\n\t\t\t\t\t\tif (rule.canFix(draft))\n\t\t\t\t\t\t\terror.extraHTML ~= ` <input name=\"action-lint-fix-` ~ rule.id ~ `\" type=\"submit\" value=\"` ~ _!`Fix it for me` ~ `\">`;\n\t\t\t\t\t\tif (lintDetails)\n\t\t\t\t\t\t\terror.extraHTML ~= `<div class=\"lint-description\">` ~ rule.longDescription() ~ `</div>`;\n\t\t\t\t\t\tdiscussionPostForm(draft, null, error);\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\n\t\t\t\tauto now = Clock.currTime();\n\n\t\t\t\t// Get per-group rate limit settings\n\t\t\t\tauto groupInfo = getGroupInfo(draft.serverVars.get(\"where\", draft.clientVars.get(\"where\", null)));\n\t\t\t\tauto postThrottleRejectTime = groupInfo ? groupInfo.postThrottleRejectTime.seconds : 30.seconds;\n\t\t\t\tauto postThrottleRejectCount = groupInfo ? groupInfo.postThrottleRejectCount : 3;\n\t\t\t\tauto postThrottleCaptchaTime = groupInfo ? groupInfo.postThrottleCaptchaTime.seconds : 180.seconds;\n\t\t\t\tauto postThrottleCaptchaCount = groupInfo ? groupInfo.postThrottleCaptchaCount : 3;\n\n\t\t\t\tauto ipPostAttempts = lastPostAttempts.get(ip, null);\n\t\t\t\tif (postThrottleRejectCount > 0 && ipPostAttempts.length >= postThrottleRejectCount && now - ipPostAttempts[$-postThrottleRejectCount+1] < postThrottleRejectTime)\n\t\t\t\t{\n\t\t\t\t\tdiscussionPostForm(draft, null,\n\t\t\t\t\t\tPostError(_!\"You've attempted to post %d times in the past %s. Please wait a little bit before trying again.\"\n\t\t\t\t\t\t\t.format(postThrottleRejectCount, postThrottleRejectTime)));\n\t\t\t\t\treturn null;\n\t\t\t\t}\n\n\t\t\t\tif (postThrottleCaptchaCount > 0 && ipPostAttempts.length >= postThrottleCaptchaCount && now - ipPostAttempts[$-postThrottleCaptchaCount+1] < postThrottleCaptchaTime)\n\t\t\t\t{\n\t\t\t\t\tauto captcha = getCaptcha(draftToPost(draft).captcha);\n\t\t\t\t\tif (captcha)\n\t\t\t\t\t{\n\t\t\t\t\t\tbool captchaPresent = captcha.isPresent(clientVars);\n\t\t\t\t\t\tif (!captchaPresent)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tdiscussionPostForm(draft, captcha,\n\t\t\t\t\t\t\t\tPostError(_!\"You've attempted to post %d times in the past %s. Please solve a CAPTCHA to continue.\"\n\t\t\t\t\t\t\t\t\t.format(postThrottleCaptchaCount, postThrottleCaptchaTime)));\n\t\t\t\t\t\t\treturn null;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t{\n\t\t\t\t\t\tdiscussionPostForm(draft, null,\n\t\t\t\t\t\t\tPostError(_!\"You've attempted to post %d times in the past %s. Please wait a little bit before trying again.\"\n\t\t\t\t\t\t\t\t.format(postThrottleCaptchaCount, postThrottleCaptchaTime)));\n\t\t\t\t\t\treturn null;\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tauto pid = postDraft(draft, headers);\n\n\t\t\t\tlastPostAttempts[ip] ~= Clock.currTime();\n\t\t\t\tif (user.isLoggedIn())\n\t\t\t\t\tcreateReplySubscription(user.getName());\n\n\t\t\t\treturn \"/posting/\" ~ pid;\n\t\t\t}\n\t\t\tcase \"discard\":\n\t\t\t{\n\t\t\t\t// Show undo notice\n\t\t\t\tuserSettings.pendingNotice = \"draft-deleted:\" ~ draftID;\n\t\t\t\t// Mark as deleted\n\t\t\t\tdraft.status = PostDraft.Status.discarded;\n\t\t\t\t// Redirect to relevant page\n\t\t\t\tif (\"parent\" in draft.serverVars)\n\t\t\t\t\treturn idToUrl(draft.serverVars[\"parent\"]);\n\t\t\t\telse\n\t\t\t\tif (\"where\" in draft.serverVars)\n\t\t\t\t\treturn \"/group/\" ~ draft.serverVars[\"where\"];\n\t\t\t\telse\n\t\t\t\t\treturn \"/\";\n\t\t\t}\n\t\t\tdefault:\n\t\t\t\tthrow new Exception(_!\"Unknown action\");\n\t\t}\n\t}\n\tcatch (Exception e)\n\t{\n\t\tauto error = isDebug ? e.toString() : e.msg;\n\t\tdiscussionPostForm(draft, null, PostError(error));\n\t\treturn null;\n\t}\n}\n\nstring postDraft(ref PostDraft draft, Headers headers)\n{\n\tauto parent = \"parent\" in draft.serverVars ? getPost(draft.serverVars[\"parent\"]) : null;\n\tauto process = new PostProcess(draft, user, userSettings.id, ip, headers, parent);\n\tif (process.status == PostingStatus.redirect)\n\t\treturn process.pid;\n\tprocess.run();\n\tdraft.serverVars[\"pid\"] = process.pid;\n\n\treturn process.pid;\n}\n\nvoid moderateMessage(ref PostDraft draft, Headers headers, ModerationReason reason)\n{\n\timport std.range : chain, only;\n\n\tlearnModeratedMessage(draft, true, 1);\n\tdraft.serverVars[\"headers\"] = headers.to!(string[][string]).toJson;\n\tdraft.status = PostDraft.Status.moderation;\n\tsaveDraft(draft, Yes.force);\n\n\tstring sanitize(string s) { return \"%(%s%)\".format(s.only)[1..$-1]; }\n\n\ttry\n\t{\n\t\timport std.file : readText;\n\t\tauto badStrings = \"config/known-spammers.txt\".readText().splitLines;\n\t\tforeach (badString; badStrings)\n\t\t\tif (badString.length && draft.clientVars.get(\"text\", null).canFind(badString))\n\t\t\t{\n\t\t\t\timport ae.sys.log : fileLogger;\n\t\t\t\tauto moderationLog = fileLogger(\"Deleted\");\n\t\t\t\tscope(exit) moderationLog.close();\n\n\t\t\t\tmoderationLog(\"Silently ignoring known spammer: \" ~ draft.clientVars.get(\"did\", \"\").I!sanitize);\n\t\t\t\treturn;\n\t\t\t}\n\t}\n\tcatch (Exception e) {}\n\n\tstring context;\n\t{\n\t\tcontext = `The message was submitted`;\n\t\tstring contextURL = null;\n\t\tauto urlPrefix = site.proto ~ \"://\" ~ site.host;\n\t\tif (auto parentID = \"parent\" in draft.serverVars)\n\t\t{\n\t\t\tcontext ~= ` in reply to `;\n\t\t\tauto parent = getPostInfo(*parentID);\n\t\t\tif (parent)\n\t\t\t\tcontext ~= parent.author.I!sanitize ~ \"'s post\";\n\t\t\telse\n\t\t\t\tcontext ~= \"a post\";\n\t\t\tcontextURL = urlPrefix ~ idToUrl(*parentID);\n\t\t}\n\t\tif (\"where\" in draft.serverVars)\n\t\t{\n\t\t\tcontext ~= ` on the ` ~ draft.serverVars[\"where\"] ~ ` group`;\n\t\t\tif (!contextURL)\n\t\t\t\tcontextURL = urlPrefix ~  `/group/` ~ draft.serverVars[\"where\"];\n\t\t}\n\t\telse\n\t\t\tcontext ~= ` on an unknown group`;\n\n\t\tcontext ~= contextURL ? \":\\n\" ~ contextURL : \".\";\n\t}\n\n\t// Check if moderation is due to ban\n\tstring unbanSection = reason.kind == ModerationReason.Kind.bannedUser && reason.bannedKey.length\n\t\t? `\nIf this user was previously banned and you would like to unban them, you can do so here:\n%s://%s/unban/%s\n`\n\t\t.format(\n\t\t\tsite.proto,\n\t\t\tsite.host,\n\t\t\tencodeUrlParameter(reason.bannedKey),\n\t\t)\n\t\t: \"\";\n\n\tforeach (mod; site.moderators)\n\t\tsendMail((q\"EOF\nFrom: %1$s <no-reply@%2$s>\nTo: %3$s\nSubject: Please moderate: post by %5$s with subject \"%7$s\"\nContent-Type: text/plain; charset=utf-8\n\nHowdy %4$s,\n\nUser %5$s <%6$s> attempted to post a message with the subject \"%7$s\".\nThis post was held for moderation for the following reason: %8$s\n\nHere is the message:\n----------------------------------------------\n%9$-(%s\n%)\n----------------------------------------------\n\n%13$s\n\nIP address this message was posted from: %12$s\n\nYou can preview and approve this message here:\n%10$s://%2$s/approve-moderated-draft/%11$s\n%14$s\nOtherwise, no action is necessary.\n\nAll the best,\n%1$s\n\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\nYou are receiving this message because you are configured as a site moderator on %2$s.\n\nTo stop receiving messages like this, please ask the administrator of %1$s to remove you from the list of moderators.\n.\nEOF\")\n\t\t.format(\n\t\t\t/* 1*/ site.name.length ? site.name : site.host,\n\t\t\t/* 2*/ site.host,\n\t\t\t/* 3*/ mod,\n\t\t\t/* 4*/ mod.canFind(\"<\") ? mod.findSplit(\"<\")[0].findSplit(\" \")[0] : mod.findSplit(\"@\")[0],\n\t\t\t/* 5*/ draft.clientVars.get(\"name\", \"\").I!sanitize,\n\t\t\t/* 6*/ draft.clientVars.get(\"email\", \"\").I!sanitize,\n\t\t\t/* 7*/ draft.clientVars.get(\"subject\", \"\").I!sanitize,\n\t\t\t/* 8*/ reason.toString(),\n\t\t\t/* 9*/ draft.clientVars.get(\"text\", \"\").strip.splitAsciiLines.map!(line => line.length ? \"> \" ~ line : \">\"),\n\t\t\t/*10*/ site.proto,\n\t\t\t/*11*/ draft.clientVars.get(\"did\", \"\").I!sanitize,\n\t\t\t/*12*/ ip,\n\t\t\t/*13*/ context,\n\t\t\t/*14*/ unbanSection,\n\t\t));\n}\n\nvoid discussionPostStatusMessage(string messageHtml)\n{\n\thtml.put(\n\t\t`<table class=\"forum-table\">` ~\n\t\t\t`<tr><th>`, _!`Posting status`, `</th></tr>` ~\n\t\t\t`<tr><td class=\"forum-table-message\">`, messageHtml, `</th></tr>` ~\n\t\t`</table>`);\n}\n\nvoid discussionPostStatus(PostProcess process, out bool refresh, out string redirectTo, out bool form)\n{\n\trefresh = form = false;\n\tPostError error = process.error;\n\tswitch (process.status)\n\t{\n\t\tcase PostingStatus.spamCheck:\n\t\t\t// discussionPostStatusMessage(_!\"Checking for spam...\");\n\t\t\tdiscussionPostStatusMessage(_!\"Validating...\");\n\t\t\trefresh = true;\n\t\t\treturn;\n\t\tcase PostingStatus.captcha:\n\t\t\tdiscussionPostStatusMessage(_!\"Verifying reCAPTCHA...\");\n\t\t\trefresh = true;\n\t\t\treturn;\n\t\tcase PostingStatus.connecting:\n\t\t\tdiscussionPostStatusMessage(_!\"Connecting to server...\");\n\t\t\trefresh = true;\n\t\t\treturn;\n\t\tcase PostingStatus.posting:\n\t\t\tdiscussionPostStatusMessage(_!\"Sending message to server...\");\n\t\t\trefresh = true;\n\t\t\treturn;\n\t\tcase PostingStatus.waiting:\n\t\t\tdiscussionPostStatusMessage(_!\"Message sent.\" ~ \"<br>\" ~ _!\"Waiting for message announcement...\");\n\t\t\trefresh = true;\n\t\t\treturn;\n\n\t\tcase PostingStatus.posted:\n\t\t\tredirectTo = idToUrl(process.post.id);\n\t\t\tdiscussionPostStatusMessage(_!`Message posted! Redirecting...`);\n\t\t\trefresh = true;\n\t\t\treturn;\n\n\t\tcase PostingStatus.moderated:\n\t\t\tdiscussionPostStatusMessage(_!`Your message has been saved, and will be posted after being approved by a moderator.`);\n\t\t\treturn;\n\n\t\tcase PostingStatus.captchaFailed:\n\t\t\tdiscussionPostForm(process.draft, getCaptcha(process.post.captcha), error);\n\t\t\tform = true;\n\t\t\treturn;\n\t\tcase PostingStatus.spamCheckFailed:\n\t\t\t// CAPTCHA is available (checked in onSpamResult) - show form with challenge\n\t\t\tauto captcha = getCaptcha(process.post.captcha);\n\t\t\terror.message = format(_!\"%s. Please solve a CAPTCHA to continue.\", error.message);\n\t\t\tdiscussionPostForm(process.draft, captcha, error);\n\t\t\tform = true;\n\t\t\treturn;\n\t\tcase PostingStatus.serverError:\n\t\t\tdiscussionPostForm(process.draft, null, error);\n\t\t\tform = true;\n\t\t\treturn;\n\n\t\tdefault:\n\t\t\tdiscussionPostStatusMessage(\"???\");\n\t\t\trefresh = true;\n\t\t\treturn;\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/web/postmod.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Automatic moderation of new posts.\nmodule dfeed.web.web.postmod;\n\nimport std.array : array;\nimport std.format : format;\n\nimport dfeed.bayes : BayesModel, splitWords, splitWords, train, checkMessage;\nimport dfeed.web.posting : PostDraft;\nimport dfeed.web.spam : bayes, getSpamicity, certainlySpamThreshold;\nimport dfeed.web.web.moderation : banCheck;\nimport dfeed.web.web.request : ip, currentRequest;\n\nstruct ModerationReason\n{\n\tenum Kind\n\t{\n\t\tnone,\n\t\tspam,\n\t\tbannedUser,\n\t\tsimilarToModerated,\n\t}\n\n\tKind kind;\n\tstring details;  // Additional information (ban reason, percentage, etc.)\n\tstring bannedKey;  // The specific banned key that matched (only for bannedUser)\n\n\t/// Returns a human-readable description\n\tstring toString() const\n\t{\n\t\tfinal switch (kind)\n\t\t{\n\t\t\tcase Kind.none:\n\t\t\t\treturn null;\n\t\t\tcase Kind.spam:\n\t\t\t\treturn \"Very high Bayes spamicity (\" ~ details ~ \")\";\n\t\t\tcase Kind.bannedUser:\n\t\t\t\treturn \"Post from banned user (ban reason: \" ~ details ~ \")\";\n\t\t\tcase Kind.similarToModerated:\n\t\t\t\treturn \"Very similar to recently moderated messages (\" ~ details ~ \")\";\n\t\t}\n\t}\n}\n\n/// Bayes model trained to detect recently moderated messages. RAM only.\n/// The model is based off the spam model, but we throw away all spam data at first.\nBayesModel* getModerationModel()\n{\n\tstatic BayesModel* model;\n\tif (!model)\n\t{\n\t\tmodel = new BayesModel;\n\t\t*model = bayes.model;\n\t\tmodel.words = model.words.dup;\n\t\tforeach (word, ref counts; model.words)\n\t\t\tcounts.spamCount = 0;\n\t\tmodel.spamPosts =  0;\n\t}\n\treturn model;\n}\n\nvoid learnModeratedMessage(in ref PostDraft draft, bool isBad, int weight)\n{\n\tauto message = bayes.messageFromDraft(draft);\n\tauto model = getModerationModel();\n\tauto words = message.splitWords.array;\n\ttrain(*model, words, isBad, weight);\n}\n\ndouble checkModeratedMessage(in ref PostDraft draft)\n{\n\tauto message = bayes.messageFromDraft(draft);\n\tauto model = getModerationModel();\n\treturn checkMessage(*model, message);\n}\n\n/// Should this post be queued for moderation instead of being posted immediately?\n/// If yes, return a reason; if no, return ModerationReason with kind == none.\nModerationReason shouldModerate(in ref PostDraft draft)\n{\n\tauto spamicity = getSpamicity(draft);\n\tif (spamicity >= certainlySpamThreshold)\n\t\treturn ModerationReason(ModerationReason.Kind.spam, format(\"%s%%\", spamicity * 100), null);\n\n\tauto banResult = banCheck(ip, currentRequest);\n\tif (banResult)\n\t\treturn ModerationReason(ModerationReason.Kind.bannedUser, banResult.reason, banResult.key);\n\n\tauto modScore = checkModeratedMessage(draft);\n\tif (modScore >= 0.95)\n\t\treturn ModerationReason(ModerationReason.Kind.similarToModerated, format(\"%s%%\", modScore * 100), null);\n\n\treturn ModerationReason(ModerationReason.Kind.none, null, null);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/request.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021, 2023, 2024, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Handling/routing of HTTP requests and global HTML structure.\nmodule dfeed.web.web.request;\n\nimport std.algorithm.iteration : map, filter, map;\nimport std.algorithm.searching : startsWith, canFind, skipOver, endsWith, findSplit;\nimport std.array : split, join, array, replace;\nimport std.conv : to, text;\nimport std.exception : enforce;\nimport std.file : readText;\nimport std.format : format;\nimport std.functional : not;\nimport std.string : indexOf;\nimport std.uni : icmp, toLower;\n\nimport dfeed.common;\nimport dfeed.paths : resolveSiteFile, resolveStaticFileBase;\nimport dfeed.database : query;\nimport dfeed.groups : GroupInfo, groupHierarchy, getGroupInfoByUrl, getGroupInfo;\nimport dfeed.loc;\nimport dfeed.message : idToUrl, urlDecode, urlEncodeMessageUrl, getGroup;\nimport dfeed.sinks.messagedb : threadID, searchTerm;\nimport dfeed.sinks.subscriptions;\nimport dfeed.site : site;\nimport dfeed.sources.github;\nimport dfeed.web.list;\nimport dfeed.web.posting : postProcesses;\nimport dfeed.web.user : User, getUser;\nimport dfeed.web.web.config : config;\nimport dfeed.web.web.draft : getDraft, draftToPost, newPostDraft, newReplyDraft, autoSaveDraft;\nimport dfeed.web.web.page : html, NotFoundException, Redirect;\nimport dfeed.web.web.part.gravatar;\nimport dfeed.web.web.part.pager : getPageOffset, POSTS_PER_PAGE;\nimport dfeed.web.web.postinfo;\nimport dfeed.web.web.posting : discussionPostForm, discussionSend, discussionPostStatus;\nimport dfeed.web.web.site : putSiteNotice;\nimport dfeed.web.web.statics : optimizedPath, serveFile, makeBundle, staticPath, createBundles;\nimport dfeed.web.web.user;\nimport dfeed.web.web.view.feed : getFeed, getSubscriptionFeed, FEED_HOURS_DEFAULT, FEED_HOURS_MAX;\nimport dfeed.web.web.view.group : discussionGroup, discussionGroupNarrowIndex, discussionGroupThreaded, discussionGroupSplit, discussionGroupVSplit, discussionGroupSplitFromPost, discussionGroupVSplitFromPost;\nimport dfeed.web.web.view.index : discussionIndex;\nimport dfeed.web.web.view.login : discussionLoginForm, discussionRegisterForm, discussionLogin, discussionRegister;\nimport dfeed.web.web.view.moderation : discussionModeration, discussionModerationDeleted, deletePostApi, discussionFlagPage, discussionApprovePage, discussionUnbanByKeyPage;\nimport dfeed.web.web.view.post : discussionSplitPost, discussionVSplitPost, discussionSinglePost;\nimport dfeed.web.web.view.search : discussionSearch;\nimport dfeed.web.web.view.settings;\nimport dfeed.web.web.view.subscription : discussionSubscriptionPosts, discussionSubscriptionUnsubscribe;\nimport dfeed.web.web.view.thread : getPostAtThreadIndex, discussionThread, discussionFirstUnread;\nimport dfeed.web.web.view.userprofile : discussionUserProfile, lookupAuthorByHash;\nimport dfeed.web.web.view.widgets;\n\nimport ae.net.http.common : HttpRequest, HttpResponse, HttpStatusCode;\nimport ae.net.http.responseex : HttpResponseEx;\nimport ae.net.http.server : HttpServerConnection;\nimport ae.net.ietf.url : UrlParameters, decodeUrlParameters, encodeUrlParameter;\nimport ae.sys.data : Data;\nimport ae.utils.array;\nimport ae.utils.digest;\nimport ae.utils.exception;\nimport ae.utils.json : toJson;\nimport ae.utils.meta : I;\nimport ae.utils.regex : re, escapeRE;\nimport ae.utils.text.html : encodeHtmlEntities;\n\nHttpRequest currentRequest;\nstring ip;\n\nvoid onRequest(HttpRequest request, HttpServerConnection conn)\n{\n\tconn.sendResponse(handleRequest(request, conn));\n}\n\nHttpResponse handleRequest(HttpRequest request, HttpServerConnection conn)\n{\n\tcurrentRequest = request;\n\tauto response = new HttpResponseEx();\n\n\tip = conn.remoteAddressStr(request);\n\tuser = getUser(request.headers.get(\"Cookie\", null));\n\tstring[] cookies;\n\tscope(success)\n\t{\n\t\tif (!cookies)\n\t\t\tcookies = user.save();\n\t\tforeach (cookie; cookies)\n\t\t\tresponse.headers.add(\"Set-Cookie\", cookie ~ \"; SameSite=Lax\");\n\t}\n\n\tstring title;\n\tstring[] breadcrumbs;\n\tstring bodyClass = \"narrowdoc\";\n\tstring returnPage = request.resource;\n\thtml.clear();\n\tstring[] tools, extraHeaders;\n\tstring[string] jsVars;\n\tauto status = HttpStatusCode.OK;\n\tGroupInfo currentGroup; string currentThread; // for search\n\n\tLanguage userLanguage;\n\ttry\n\t\tuserLanguage = userSettings.language.to!Language;\n\tcatch (Exception e)\n\t{\n\t\tuserLanguage = detectLanguage(request.headers.get(\"Accept-Language\", null));\n\t\tuserSettings.language = userLanguage.to!string;\n\t}\n\tauto oldLanguage = withLanguage(userLanguage);\n\n\t// Redirect to canonical domain name\n\tauto host = request.headers.get(\"Host\", \"\");\n\thost = request.headers.get(\"X-Forwarded-Host\", host);\n\tif (host != site.host && host != \"localhost\" && site.host != \"localhost\" && ip != \"127.0.0.1\" && !request.resource.startsWith(\"/.well-known/acme-challenge/\"))\n\t\treturn response.redirect(site.proto ~ \"://\" ~ site.host ~ request.resource, HttpStatusCode.MovedPermanently);\n\n\t// Opt-in HTTPS redirect\n\tif (site.proto == \"https\"\n\t\t&& request.headers.get(\"X-Scheme\", \"\") == \"http\"\n\t\t&& request.headers.get(\"Upgrade-Insecure-Requests\", \"0\") == \"1\")\n\t{\n\t\tresponse.redirect(\"https://\" ~ site.host ~ request.resource);\n\t\tresponse.headers.add(\"Vary\", \"Upgrade-Insecure-Requests\");\n\t\treturn response;\n\t}\n\n\tauto canonicalHeader =\n\t\t`<link rel=\"canonical\" href=\"`~site.proto~`://`~site.host~request.resource~`\"/>`;\n\tenum horizontalSplitHeaders =\n\t\t`<link rel=\"stylesheet\" href=\"//fonts.googleapis.com/css?family=Open+Sans:400,600\">`;\n\n\tvoid addMetadata(string description, string canonicalLocation, string image)\n\t{\n\t\tassert(title, \"No title for metadata\");\n\n\t\tif (!description)\n\t\t\tdescription = site.name;\n\n\t\tif (!image)\n\t\t\timage = site.ogImage;\n\n\t\tauto canonicalURL = site.proto ~ \"://\" ~ site.host ~ canonicalLocation;\n\n\t\textraHeaders ~= [\n\t\t\t`<meta property=\"og:title\" content=\"` ~ encodeHtmlEntities(title) ~ `\" />`,\n\t\t\t`<meta property=\"og:type\" content=\"website\" />`,\n\t\t\t`<meta property=\"og:url\" content=\"` ~ encodeHtmlEntities(canonicalURL) ~ `\" />`,\n\t\t\t`<meta property=\"og:image\" content=\"` ~ encodeHtmlEntities(image) ~ `\" />`,\n\t\t\t`<meta property=\"og:description\" content=\"` ~ encodeHtmlEntities(description) ~ `\" />`,\n\t\t];\n\n\t\t// Maybe emit <meta name=\"description\" ...> here as well one day\n\t\t// Needs changes to forum-template.dd\n\t}\n\n\ttry\n\t{\n\t\tauto pathStr = request.resource;\n\t\tenforce(pathStr.startsWith('/'), _!\"Invalid path\");\n\t\tUrlParameters parameters;\n\t\tif (pathStr.indexOf('?') >= 0)\n\t\t{\n\t\t\tauto p = pathStr.indexOf('?');\n\t\t\tparameters = decodeUrlParameters(pathStr[p+1..$]);\n\t\t\tpathStr = pathStr[0..p];\n\t\t}\n\t\tauto path = pathStr[1..$].split(\"/\");\n\t\tif (!path.length) path = [\"\"];\n\t\tauto pathX = path[1..$].join(\"%2F\"); // work around Apache bug\n\n\t\tswitch (path[0])\n\t\t{\n\t\t\t// Obsolete \"/discussion/\" prefix\n\t\t\tcase \"discussion\":\n\t\t\t\treturn response.redirect(request.resource[\"/discussion\".length..$], HttpStatusCode.MovedPermanently);\n\n\t\t\tcase \"\":\n\t\t\t\t// Handle redirects from pnews\n\n\t\t\t\t// Abort on redirect from URLs with unsupported features.\n\t\t\t\t// Only search engines would be likely to hit these.\n\t\t\t\tif (\"path\" in parameters || \"mid\" in parameters)\n\t\t\t\t\tthrow new NotFoundException(_!\"Legacy redirect - unsupported feature\");\n\n\t\t\t\t// Redirect to our URL scheme\n\t\t\t\tstring redirectGroup, redirectNum;\n\t\t\t\tif (\"group\" in parameters)\n\t\t\t\t\tredirectGroup = parameters[\"group\"];\n\t\t\t\tif (\"art_group\" in parameters)\n\t\t\t\t\tredirectGroup = parameters[\"art_group\"];\n\t\t\t\tif (\"artnum\" in parameters)\n\t\t\t\t\tredirectNum = parameters[\"artnum\"];\n\t\t\t\tif (\"article_id\" in parameters)\n\t\t\t\t\tredirectNum = parameters[\"article_id\"];\n\t\t\t\tif (redirectGroup && redirectNum)\n\t\t\t\t{\n\t\t\t\t\tforeach (string id; query!\"SELECT `ID` FROM `Groups` WHERE `Group`=? AND `ArtNum`=?\".iterate(redirectGroup, redirectNum))\n\t\t\t\t\t\treturn response.redirect(idToUrl(id), HttpStatusCode.MovedPermanently);\n\t\t\t\t\tthrow new NotFoundException(_!\"Legacy redirect - article not found\");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\tif (redirectNum)\n\t\t\t\t{\n\t\t\t\t\tstring[] ids;\n\t\t\t\t\tforeach (string id; query!\"SELECT `ID` FROM `Groups` WHERE `ArtNum`=?\".iterate(redirectNum))\n\t\t\t\t\t\tids ~= id;\n\t\t\t\t\tif (ids.length == 1)\n\t\t\t\t\t\treturn response.redirect(idToUrl(ids[0]), HttpStatusCode.MovedPermanently);\n\t\t\t\t\telse\n\t\t\t\t\tif (ids.length > 1)\n\t\t\t\t\t\tthrow new NotFoundException(_!\"Legacy redirect - ambiguous artnum (group parameter missing)\");\n\t\t\t\t\telse\n\t\t\t\t\t\tthrow new NotFoundException(_!\"Legacy redirect - article not found\");\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\tif (redirectGroup)\n\t\t\t\t\treturn response.redirect(\"/group/\" ~ redirectGroup, HttpStatusCode.MovedPermanently);\n\n\t\t\t\tif (request.resource != \"/\")\n\t\t\t\t\treturn response.redirect(\"/\");\n\n\t\t\t\ttitle = \"Index\";\n\t\t\t\t//breadcrumbs ~= `<a href=\"/\">Forum Index</a>`;\n\t\t\t\textraHeaders ~= `<link rel=\"alternate\" type=\"application/atom+xml\" title=\"` ~ _!`New posts` ~ `\" href=\"/feed/posts\" />`;\n\t\t\t\textraHeaders ~= `<link rel=\"alternate\" type=\"application/atom+xml\" title=\"` ~ _!`New threads` ~ `\" href=\"/feed/threads\" />`;\n\t\t\t\taddMetadata(null, \"/\", null);\n\t\t\t\tputSiteNotice();\n\t\t\t\tdiscussionIndex();\n\t\t\t\tbreak;\n\t\t\tcase \"group\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No group specified\");\n\t\t\t\tstring groupUrlName = path[1];\n\n\t\t\t\tforeach (groupInfo; groupHierarchy.map!(set => set.groups).join)\n\t\t\t\t\tif (groupInfo.urlAliases.canFind(groupUrlName))\n\t\t\t\t\t\tthrow new Redirect(\"/group/\" ~ groupInfo.urlName);\n\t\t\t\tforeach (groupInfo; groupHierarchy.map!(set => set.groups).join)\n\t\t\t\t\tif (groupInfo.urlAliases.canFind!(not!icmp)(groupUrlName))\n\t\t\t\t\t\tthrow new Redirect(\"/group/\" ~ groupInfo.urlName);\n\n\t\t\t\tint page = to!int(parameters.get(\"page\", \"1\"));\n\t\t\t\tstring pageStr = page==1 ? \"\" : \" \" ~ _!\"(page %d)\".format(page);\n\t\t\t\tauto groupInfo = currentGroup = getGroupInfoByUrl(groupUrlName);\n\t\t\t\tenforce(groupInfo, _!\"Unknown group\");\n\t\t\t\ttitle = _!\"%s group index\".format(groupInfo.publicName) ~ pageStr;\n\t\t\t\tbreadcrumbs ~= `<a href=\"/group/`~encodeHtmlEntities(groupUrlName)~`\">` ~ encodeHtmlEntities(groupInfo.publicName) ~ `</a>` ~ pageStr;\n\t\t\t\tputSiteNotice();\n\t\t\t\tif (groupInfo.notice)\n\t\t\t\t\thtml.put(`<div class=\"forum-notice\">`, groupInfo.notice, `</div>`);\n\t\t\t\tauto viewMode = userSettings.groupViewMode;\n\t\t\t\tif (viewMode == \"basic\")\n\t\t\t\t\tdiscussionGroup(groupInfo, page);\n\t\t\t\telse\n\t\t\t\tif (viewMode == \"narrow-index\")\n\t\t\t\t\tdiscussionGroupNarrowIndex(groupInfo, page);\n\t\t\t\telse\n\t\t\t\tif (viewMode == \"threaded\")\n\t\t\t\t\tdiscussionGroupThreaded(groupInfo, page);\n\t\t\t\telse\n\t\t\t\tif (viewMode == \"horizontal-split\")\n\t\t\t\t{\n\t\t\t\t\tdiscussionGroupSplit(groupInfo, page);\n\t\t\t\t\textraHeaders ~= horizontalSplitHeaders;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t\tdiscussionGroupVSplit(groupInfo, page);\n\t\t\t\textraHeaders ~= `<link rel=\"alternate\" type=\"application/atom+xml\" title=\"` ~ _!`New posts on` ~ ` `~encodeHtmlEntities(groupInfo.publicName)~`\" href=\"/feed/posts/`~encodeHtmlEntities(groupInfo.urlName)~`\" />`;\n\t\t\t\textraHeaders ~= `<link rel=\"alternate\" type=\"application/atom+xml\" title=\"` ~ _!`New threads on` ~ ` `~encodeHtmlEntities(groupInfo.publicName)~`\" href=\"/feed/threads/`~encodeHtmlEntities(groupInfo.urlName)~`\" />`;\n\t\t\t\taddMetadata(groupInfo.description, \"/group/\" ~ groupInfo.urlName, null);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"thread\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No thread specified\");\n\t\t\t\tint page = to!int(parameters.get(\"page\", \"1\"));\n\t\t\t\tstring threadID = '<' ~ urlDecode(pathX) ~ '>';\n\n\t\t\t\tauto firstPostUrl = idToUrl(getPostAtThreadIndex(threadID, getPageOffset(page, POSTS_PER_PAGE)));\n\t\t\t\tauto viewMode = userSettings.groupViewMode;\n\t\t\t\tif (viewMode != \"basic\" && viewMode != \"narrow-index\")\n\t\t\t\t\thtml.put(`<div class=\"forum-notice\">` ~ _!\"Viewing thread in basic view mode \\&ndash; click a post's title to open it in %s view mode\".format(viewModeName(viewMode)).encodeHtmlEntities() ~ `</div>`);\n\t\t\t\treturnPage = firstPostUrl;\n\n\t\t\t\tstring pageStr = page==1 ? \"\" : \" \" ~ format(_!\"(page %d)\", page);\n\t\t\t\tGroupInfo groupInfo;\n\t\t\t\tstring subject, authorEmail;\n\t\t\t\tdiscussionThread(threadID, page, groupInfo, subject, authorEmail, viewMode == \"basic\" || viewMode == \"narrow-index\");\n\t\t\t\tenforce(groupInfo, _!\"Unknown group\");\n\t\t\t\ttitle = subject ~ pageStr;\n\t\t\t\tcurrentGroup = groupInfo;\n\t\t\t\tcurrentThread = threadID;\n\t\t\t\tbreadcrumbs ~= `<a href=\"/group/` ~encodeHtmlEntities(groupInfo.urlName)~`\">` ~ encodeHtmlEntities(groupInfo.publicName) ~ `</a>`;\n\t\t\t\tbreadcrumbs ~= `<a href=\"/thread/`~encodeHtmlEntities(pathX)~`\">` ~ encodeHtmlEntities(subject) ~ `</a>` ~ pageStr;\n\t\t\t\textraHeaders ~= canonicalHeader; // Google confuses /post/ URLs with threads\n\t\t\t\taddMetadata(null, idToUrl(threadID, \"thread\"), gravatar(authorEmail, gravatarMetaSize));\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"post\":\n\t\t\t\tenforce(path.length > 1, _!\"No post specified\");\n\t\t\t\tstring id = '<' ~ urlDecode(pathX) ~ '>';\n\n\t\t\t\t// Normalize URL encoding to allow JS to find message by URL\n\t\t\t\tif (urlEncodeMessageUrl(urlDecode(pathX)) != pathX)\n\t\t\t\t\treturn response.redirect(idToUrl(id));\n\n\t\t\t\tauto viewMode = userSettings.groupViewMode;\n\t\t\t\tif (viewMode == \"basic\" || viewMode == \"narrow-index\")\n\t\t\t\t\treturn response.redirect(resolvePostUrl(id));\n\t\t\t\telse\n\t\t\t\tif (viewMode == \"threaded\")\n\t\t\t\t{\n\t\t\t\t\tstring subject, authorEmail;\n\t\t\t\t\tdiscussionSinglePost(id, currentGroup, subject, authorEmail, currentThread);\n\t\t\t\t\ttitle = subject;\n\t\t\t\t\tbreadcrumbs ~= `<a href=\"/group/` ~encodeHtmlEntities(currentGroup.urlName)~`\">` ~ encodeHtmlEntities(currentGroup.publicName) ~ `</a>`;\n\t\t\t\t\tbreadcrumbs ~= `<a href=\"/post/`~encodeHtmlEntities(pathX)~`\">` ~ encodeHtmlEntities(subject) ~ `</a> ` ~ _!`(view single post)`;\n\t\t\t\t\taddMetadata(null, idToUrl(id), gravatar(authorEmail, gravatarMetaSize));\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tint page;\n\t\t\t\t\tif (viewMode == \"horizontal-split\")\n\t\t\t\t\t\tdiscussionGroupSplitFromPost(id, currentGroup, page, currentThread);\n\t\t\t\t\telse\n\t\t\t\t\t\tdiscussionGroupVSplitFromPost(id, currentGroup, page, currentThread);\n\n\t\t\t\t\tstring pageStr = page==1 ? \"\" : \" \" ~ format(_!\"(page %d)\", page);\n\t\t\t\t\ttitle = _!\"%s group index\".format(currentGroup.publicName) ~ pageStr;\n\t\t\t\t\tbreadcrumbs ~= `<a href=\"/group/`~encodeHtmlEntities(currentGroup.urlName)~`\">` ~ encodeHtmlEntities(currentGroup.publicName) ~ `</a>` ~ pageStr;\n\t\t\t\t\textraHeaders ~= horizontalSplitHeaders;\n\t\t\t\t\taddMetadata(null, idToUrl(id), null);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\tcase \"raw\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"Invalid URL\");\n\t\t\t\tauto post = getPostPart('<' ~ urlDecode(path[1]) ~ '>', array(map!(to!uint)(path[2..$])));\n\t\t\t\tenforce(post, _!\"Post not found\");\n\t\t\t\tif (!post.data && post.error)\n\t\t\t\t\tthrow new Exception(post.error);\n\t\t\t\tif (post.fileName)\n\t\t\t\t\t//response.headers[\"Content-Disposition\"] = `inline; filename=\"` ~ post.fileName ~ `\"`;\n\t\t\t\t\tresponse.headers[\"Content-Disposition\"] = `attachment; filename=\"` ~ post.fileName ~ `\"`; // \"\n\t\t\t\telse\n\t\t\t\t\t// TODO: separate subdomain for attachments\n\t\t\t\t\tresponse.headers[\"Content-Disposition\"] = `attachment; filename=\"raw\"`;\n\t\t\t\treturn response.serveData(Data(post.data), post.mimeType ? post.mimeType : \"application/octet-stream\");\n\t\t\t}\n\t\t\tcase \"source\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"Invalid URL\");\n\t\t\t\tauto message = getPostSource('<' ~ urlDecode(path[1]) ~ '>');\n\t\t\t\tif (message is null)\n\t\t\t\t{\n\t\t\t\t\tauto slug = urlDecode(path[1]);\n\t\t\t\t\tif (slug.skipOver(\"draft-\") && slug.endsWith(\"@\" ~ site.host))\n\t\t\t\t\t{\n\t\t\t\t\t\tauto did = slug.skipUntil(\"@\");\n\t\t\t\t\t\tauto draft = getDraft(did);\n\t\t\t\t\t\tauto post = draftToPost(draft);\n\t\t\t\t\t\tpost.compile();\n\t\t\t\t\t\tmessage = post.message;\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tenforce(message !is null, _!\"Post not found\");\n\t\t\t\treturn response.serveData(Data(message), \"text/plain\");\n\t\t\t}\n\t\t\tcase \"split-post\":\n\t\t\t\tenforce(path.length > 1, _!\"No post specified\");\n\t\t\t\tdiscussionSplitPost('<' ~ urlDecode(pathX) ~ '>');\n\t\t\t\treturn response.serveData(cast(string)html.get());\n\t\t\tcase \"vsplit-post\":\n\t\t\t\tenforce(path.length > 1, _!\"No post specified\");\n\t\t\t\tdiscussionVSplitPost('<' ~ urlDecode(pathX) ~ '>');\n\t\t\t\treturn response.serveData(cast(string)html.get());\n\t\t/+\n\t\t\tcase \"set\":\n\t\t\t{\n\t\t\t\tif (parameters.get(\"secret\", \"\") != userSettings.secret)\n\t\t\t\t\tthrow new Exception(\"XSRF secret verification failed. Are your cookies enabled?\");\n\n\t\t\t\tforeach (name, value; parameters)\n\t\t\t\t\tif (name != \"url\" && name != \"secret\")\n\t\t\t\t\t\tuser.set(name, value); // TODO: is this a good idea?\n\n\t\t\t\tif (\"url\" in parameters)\n\t\t\t\t\treturn response.redirect(parameters[\"url\"]);\n\t\t\t\telse\n\t\t\t\t\treturn response.serveText(\"OK\");\n\t\t\t}\n\t\t+/\n\t\t\tcase \"mark-unread\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No post specified\");\n\t\t\t\tauto post = getPostInfo('<' ~ urlDecode(pathX) ~ '>');\n\t\t\t\tenforce(post, _!\"Post not found\");\n\t\t\t\tuser.setRead(post.rowid, false);\n\t\t\t\treturn response.serveText(\"OK\");\n\t\t\t}\n\t\t\tcase \"first-unread\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No thread specified\");\n\t\t\t\treturn response.redirect(discussionFirstUnread('<' ~ urlDecode(pathX) ~ '>'));\n\t\t\t}\n\t\t\tcase \"newpost\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No group specified\");\n\t\t\t\tstring groupUrlName = path[1];\n\t\t\t\tcurrentGroup = getGroupInfoByUrl(groupUrlName).enforce(_!\"No such group\");\n\t\t\t\ttitle = _!\"Posting to %s\".format(currentGroup.publicName);\n\t\t\t\tbreadcrumbs ~= `<a href=\"/group/`~encodeHtmlEntities(currentGroup.urlName)~`\">` ~ encodeHtmlEntities(currentGroup.publicName) ~ `</a>`;\n\t\t\t\tbreadcrumbs ~= `<a href=\"/newpost/`~encodeHtmlEntities(currentGroup.urlName)~`\">` ~ _!`New thread` ~ `</a>`;\n\t\t\t\tif (discussionPostForm(newPostDraft(currentGroup, parameters)))\n\t\t\t\t\tbodyClass ~= \" formdoc\";\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"reply\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No post specified\");\n\t\t\t\tauto post = getPost('<' ~ urlDecode(pathX) ~ '>');\n\t\t\t\tenforce(post, _!\"Post not found\");\n\t\t\t\ttitle = _!`Replying to \"%s\"`.format(post.subject); // \"\n\t\t\t\tcurrentGroup = post.getGroup();\n\t\t\t\tcurrentThread = post.threadID;\n\t\t\t\tbreadcrumbs ~= `<a href=\"/group/`~encodeHtmlEntities(currentGroup.urlName)~`\">` ~ encodeHtmlEntities(currentGroup.publicName) ~ `</a>`;\n\t\t\t\tbreadcrumbs ~= `<a href=\"` ~ encodeHtmlEntities(idToUrl(post.id)) ~ `\">` ~ encodeHtmlEntities(post.subject) ~ `</a>`;\n\t\t\t\tbreadcrumbs ~= `<a href=\"/reply/`~pathX~`\">` ~ _!`Post reply` ~ `</a>`;\n\t\t\t\tif (discussionPostForm(newReplyDraft(post)))\n\t\t\t\t\tbodyClass = \"formdoc\";\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"send\":\n\t\t\t{\n\t\t\t\tenforce(request.method == \"POST\", _!\"No post form submitted. Please click \\\"Back\\\" in your web browser to navigate back to the posting form, and resubmit it.\");\n\t\t\t\tauto postVars = request.decodePostData();\n\t\t\t\tauto redirectTo = discussionSend(postVars, request.headers);\n\t\t\t\tif (redirectTo)\n\t\t\t\t\treturn response.redirect(redirectTo);\n\n\t\t\t\tbreadcrumbs ~= title = _!`Posting`;\n\t\t\t\tbodyClass ~= \" formdoc\";\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"posting\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No post ID specified\");\n\t\t\t\tauto pid = pathX;\n\t\t\t\tif (pid in postProcesses)\n\t\t\t\t{\n\t\t\t\t\tbool refresh, form;\n\t\t\t\t\tstring redirectTo;\n\t\t\t\t\tdiscussionPostStatus(postProcesses[pid], refresh, redirectTo, form);\n\t\t\t\t\tif (refresh)\n\t\t\t\t\t\tresponse.setRefresh(1, redirectTo);\n\t\t\t\t\tif (form)\n\t\t\t\t\t{\n\t\t\t\t\t\tbreadcrumbs ~= title = _!`Posting`;\n\t\t\t\t\t\tbodyClass ~= \" formdoc\";\n\t\t\t\t\t}\n\t\t\t\t\telse\n\t\t\t\t\t\tbreadcrumbs ~= title = _!`Posting status`;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tauto draftID = pid;\n\t\t\t\t\tforeach (string id; query!\"SELECT [ID] FROM [Drafts] WHERE [PostID]=?\".iterate(pid))\n\t\t\t\t\t\tdraftID = id;\n\t\t\t\t\tdiscussionPostForm(getDraft(draftID));\n\t\t\t\t\ttitle = _!\"Composing message\";\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"auto-save\":\n\t\t\t{\n\t\t\t\tauto postVars = request.decodePostData();\n\t\t\t\tif (postVars.get(\"secret\", \"\") != userSettings.secret)\n\t\t\t\t\tthrow new Exception(_!\"XSRF secret verification failed\");\n\t\t\t\tautoSaveDraft(postVars);\n\t\t\t\treturn response.serveText(\"OK\");\n\t\t\t}\n\t\t\tcase \"subscribe\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No post specified\");\n\t\t\t\tenforce(user.isLoggedIn(), _!\"Please log in to do that\");\n\t\t\t\tauto id = '<' ~ urlDecode(pathX) ~ '>';\n\t\t\t\tSubscription threadSubscription;\n\t\t\t\tforeach (subscription; getUserSubscriptions(user.getName()))\n\t\t\t\t\tif (auto threadTrigger = cast(ThreadTrigger)subscription.trigger)\n\t\t\t\t\t\tif (threadTrigger.threadID == id)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tthreadSubscription = subscription;\n\t\t\t\t\t\t\tbreak;\n\t\t\t\t\t\t}\n\t\t\t\tif (!threadSubscription.trigger)\n\t\t\t\t\tthreadSubscription = createSubscription(user.getName(), \"thread\", [\"trigger-thread-id\" : id]);\n\t\t\t\ttitle = _!\"Subscribe to thread\";\n\t\t\t\tdiscussionSubscriptionEdit(threadSubscription);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"subscription-posts\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No subscription specified\");\n\t\t\t\tint page = to!int(parameters.get(\"page\", \"1\"));\n\t\t\t\tbreadcrumbs ~= _!\"View subscription\";\n\t\t\t\tdiscussionSubscriptionPosts(urlDecode(pathX), page, title);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"subscription-feed\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No subscription specified\");\n\t\t\t\treturn getSubscriptionFeed(urlDecode(pathX)).getResponse(request);\n\t\t\t}\n\t\t\tcase \"subscription-unsubscribe\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No subscription specified\");\n\t\t\t\ttitle = _!\"Unsubscribe\";\n\t\t\t\tdiscussionSubscriptionUnsubscribe(urlDecode(pathX));\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"search\":\n\t\t\t{\n\t\t\t\tbreadcrumbs ~= title = _!\"Search\";\n\t\t\t\tdiscussionSearch(parameters);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"user\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No user specified\");\n\t\t\t\tstring profileHash = pathX;\n\t\t\t\tstring author;\n\t\t\t\tdiscussionUserProfile(profileHash, title, author);\n\t\t\t\tbreadcrumbs ~= _!\"Users\";\n\t\t\t\tbreadcrumbs ~= `<a href=\"/user/` ~ encodeHtmlEntities(profileHash) ~ `\">` ~ encodeHtmlEntities(author) ~ `</a>`;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"subscribe-user\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"No user specified\");\n\t\t\t\tenforce(user.isLoggedIn(), _!\"Please log in to do that\");\n\t\t\t\tstring profileHash = pathX;\n\t\t\t\tauto authorInfo = lookupAuthorByHash(profileHash);\n\t\t\t\tenforce(authorInfo[0] !is null, _!\"User not found\");\n\t\t\t\tstring authorName = authorInfo[0];\n\t\t\t\tstring authorEmail = authorInfo[1];\n\n\t\t\t\t// Create a content subscription with author name and email filters prefilled\n\t\t\t\t// Use regex mode with escaped strings for exact matching\n\t\t\t\tauto subscription = createSubscription(user.getName(), \"content\", [\n\t\t\t\t\t\"trigger-content-author-name-enabled\" : \"on\",\n\t\t\t\t\t\"trigger-content-author-name-match-type\" : \"regex\",\n\t\t\t\t\t\"trigger-content-author-name-case-sensitive\" : \"on\",\n\t\t\t\t\t\"trigger-content-author-name-str\" : \"^\" ~ authorName.escapeRE ~ \"$\",\n\t\t\t\t\t\"trigger-content-author-email-enabled\" : \"on\",\n\t\t\t\t\t\"trigger-content-author-email-match-type\" : \"regex\",\n\t\t\t\t\t\"trigger-content-author-email-case-sensitive\" : \"on\",\n\t\t\t\t\t\"trigger-content-author-email-str\" : \"^\" ~ authorEmail.escapeRE ~ \"$\",\n\t\t\t\t]);\n\t\t\t\ttitle = _!\"Subscribe to user\";\n\t\t\t\tdiscussionSubscriptionEdit(subscription);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"delete\":\n\t\t\tcase \"dodelete\":\n\t\t\t\treturn response.redirect(\"/moderate/\" ~ path[1..$].join(\"/\"));\n\t\t\tcase \"moderate\":\n\t\t\t{\n\t\t\t\tenforce(user.getLevel() >= User.Level.canModerate, _!\"You are not a moderator\");\n\t\t\t\tenforce(path.length > 1, _!\"No post specified\");\n\t\t\t\tauto messageID = '<' ~ urlDecode(pathX) ~ '>';\n\t\t\t\tauto post = getPost(messageID);\n\t\t\t\tif (post)\n\t\t\t\t{\n\t\t\t\t\ttitle = _!`Moderating post \"%s\"`.format(post.subject); // \"\n\t\t\t\t\tbreadcrumbs ~= `<a href=\"` ~ encodeHtmlEntities(idToUrl(post.id)) ~ `\">` ~ encodeHtmlEntities(post.subject) ~ `</a>`;\n\t\t\t\t\tbreadcrumbs ~= `<a href=\"/moderate/`~pathX~`\">` ~ _!`Moderate post` ~ `</a>`;\n\t\t\t\t\tdiscussionModeration(post, request.method == \"POST\" ? request.decodePostData() : UrlParameters.init);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\ttitle = _!`Post not found`;\n\t\t\t\t\tbreadcrumbs ~= `<a href=\"/moderate/`~pathX~`\">` ~ _!`Post not found` ~ `</a>`;\n\t\t\t\t\tdiscussionModerationDeleted(messageID);\n\t\t\t\t}\n\t\t\t\tbodyClass ~= \" formdoc\";\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"api-delete\":\n\t\t\t{\n\t\t\t\tenforce(config.apiSecret.length, \"No API secret configured\");\n\t\t\t\tenforce(parameters.get(\"secret\", null) == config.apiSecret, \"Incorrect secret\");\n\t\t\t\tenforce(path.length == 3, \"Invalid URL\");\n\t\t\t\tauto group = path[1];\n\t\t\t\tauto id = path[2].to!int;\n\t\t\t\tdeletePostApi(group, id);\n\t\t\t\treturn response.serveText(html.get().idup);\n\t\t\t}\n\t\t\tcase \"flag\":\n\t\t\tcase \"unflag\":\n\t\t\t{\n\t\t\t\tenforce(user.getLevel() >= User.Level.canFlag, _!\"You can't flag posts\");\n\t\t\t\tenforce(path.length > 1, _!\"No post specified\");\n\t\t\t\tauto post = getPost('<' ~ urlDecode(pathX) ~ '>');\n\t\t\t\tenforce(post, _!\"Post not found\");\n\t\t\t\ttitle = _!`Flag \"%s\" by %s`.format(post.subject, post.author); // \"\n\t\t\t\tbreadcrumbs ~= `<a href=\"` ~ encodeHtmlEntities(idToUrl(post.id)) ~ `\">` ~ encodeHtmlEntities(post.subject) ~ `</a>`;\n\t\t\t\tbreadcrumbs ~= `<a href=\"/`~path[0]~`/`~pathX~`\">` ~ _!`Flag post` ~ `</a>`;\n\t\t\t\tdiscussionFlagPage(post, path[0] == \"flag\", request.method == \"POST\" ? request.decodePostData() : UrlParameters.init);\n\t\t\t\tbodyClass ~= \" formdoc\";\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"approve-moderated-draft\":\n\t\t\t{\n\t\t\t\tenforce(user.getLevel() >= User.Level.canApproveDrafts, _!\"You can't approve moderated drafts\");\n\t\t\t\ttitle = _!\"Approving moderated draft\";\n\t\t\t\tenforce(path.length == 2 || path.length == 3, _!\"Invalid URL\"); // Backwards compatibility with old one-click URLs\n\t\t\t\tauto draftID = path[1];\n\t\t\t\tdiscussionApprovePage(draftID, request.method == \"POST\" ? request.decodePostData() : UrlParameters.init);\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"unban\":\n\t\t\t{\n\t\t\t\tenforce(user.getLevel() >= User.Level.canModerate, _!\"You are not a moderator\");\n\t\t\t\ttitle = _!\"Unban by key\";\n\t\t\t\tbreadcrumbs ~= `<a href=\"/unban\">` ~ _!`Unban by key` ~ `</a>`;\n\t\t\t\tauto key = path.length > 1 ? urlDecode(pathX) : parameters.get(\"key\", \"\");\n\t\t\t\tdiscussionUnbanByKeyPage(key, request.method == \"POST\" ? request.decodePostData() : UrlParameters.init);\n\t\t\t\tbodyClass ~= \" formdoc\";\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"loginform\":\n\t\t\t{\n\t\t\t\tdiscussionLoginForm(parameters);\n\t\t\t\tbreadcrumbs ~= title = _!`Log in`;\n\t\t\t\ttools ~= `<a href=\"/registerform?url=__URL__\">` ~ _!`Register` ~ `</a>`;\n\t\t\t\treturnPage = \"/\";\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"registerform\":\n\t\t\t{\n\t\t\t\tdiscussionRegisterForm(parameters);\n\t\t\t\tbreadcrumbs ~= title = _!`Registration`;\n\t\t\t\ttools ~= `<a href=\"/registerform?url=__URL__\">` ~ _!`Register` ~ `</a>`;\n\t\t\t\treturnPage = \"/\";\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"login\":\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tparameters = request.decodePostData();\n\t\t\t\t\tdiscussionLogin(parameters);\n\t\t\t\t\treturn response.redirect(parameters.get(\"url\", \"/\"));\n\t\t\t\t}\n\t\t\t\tcatch (Exception e)\n\t\t\t\t{\n\t\t\t\t\tdiscussionLoginForm(parameters, e.msg);\n\t\t\t\t\tbreadcrumbs ~= title = _!`Login error`;\n\t\t\t\t\ttools ~= `<a href=\"/registerform?url=__URL__\">` ~ _!`Register` ~ `</a>`;\n\t\t\t\t\treturnPage = \"/\";\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase \"register\":\n\t\t\t{\n\t\t\t\ttry\n\t\t\t\t{\n\t\t\t\t\tparameters = request.decodePostData();\n\t\t\t\t\tdiscussionRegister(parameters);\n\t\t\t\t\treturn response.redirect(parameters.get(\"url\", \"/\"));\n\t\t\t\t}\n\t\t\t\tcatch (Exception e)\n\t\t\t\t{\n\t\t\t\t\tdiscussionRegisterForm(parameters, e.msg);\n\t\t\t\t\tbreadcrumbs ~= title = _!`Registration error`;\n\t\t\t\t\ttools ~= `<a href=\"/registerform?url=__URL__\">` ~ _!`Register` ~ `</a>`;\n\t\t\t\t\treturnPage = \"/\";\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t\tcase \"logout\":\n\t\t\t{\n\t\t\t\tenforce(user.isLoggedIn(), _!\"Not logged in\");\n\t\t\t\tuser.logOut();\n\t\t\t\tif (\"url\" in parameters)\n\t\t\t\t\treturn response.redirect(parameters[\"url\"]);\n\t\t\t\telse\n\t\t\t\t\treturn response.serveText(\"OK\");\n\t\t\t}\n\t\t\tcase \"settings\":\n\t\t\t\tbreadcrumbs ~= title = _!\"Settings\";\n\t\t\t\tdiscussionSettings(parameters, request.method == \"POST\" ? request.decodePostData() : UrlParameters.init);\n\t\t\t\tbreak;\n\t\t\tcase \"change-password\":\n\t\t\t\tbreadcrumbs ~= _!\"Settings\";\n\t\t\t\tbreadcrumbs ~= _!\"Account\";\n\t\t\t\tbreadcrumbs ~= title = _!\"Change Password\";\n\t\t\t\tdiscussionChangePassword(request.method == \"POST\" ? request.decodePostData() : UrlParameters.init);\n\t\t\t\tbreak;\n\t\t\tcase \"export-account\":\n\t\t\t\tbreadcrumbs ~= _!\"Settings\";\n\t\t\t\tbreadcrumbs ~= _!\"Account\";\n\t\t\t\tbreadcrumbs ~= title = _!\"Export Data\";\n\t\t\t\tif (auto result = discussionExportAccount(request.method == \"POST\" ? request.decodePostData() : UrlParameters.init))\n\t\t\t\t{\n\t\t\t\t\tresponse.headers[\"Content-Disposition\"] = \"attachment; filename=%(%s%)\"\n\t\t\t\t\t\t.format([user.getName ~ \".json\"]);\n\t\t\t\t\treturn response.serveJson(result);\n\t\t\t\t}\n\t\t\t\tbreak;\n\t\t\tcase \"delete-account\":\n\t\t\t\tbreadcrumbs ~= _!\"Settings\";\n\t\t\t\tbreadcrumbs ~= _!\"Account\";\n\t\t\t\tbreadcrumbs ~= title = _!\"Delete Account\";\n\t\t\t\tdiscussionDeleteAccount(request.method == \"POST\" ? request.decodePostData() : UrlParameters.init);\n\t\t\t\tbreak;\n\t\t\tcase \"help\":\n\t\t\t\tbreadcrumbs ~= title = _!\"Help\";\n\t\t\t\thtml.put(readText(optimizedPath(\"web/help-%s.htt\".format(currentLanguage)))\n\t\t\t\t\t.parseTemplate(\n\t\t\t\t\t\t(string name)\n\t\t\t\t\t\t{\n\t\t\t\t\t\t\tswitch (name)\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tcase \"about\" : return site.about;\n\t\t\t\t\t\t\t\tdefault: throw new Exception(\"Unknown variable in help template: \" ~ name);\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}\n\t\t\t\t\t));\n\t\t\t\tbreak;\n\n\t\t\t// dlang.org front page iframes\n\t\t\tcase \"frame-discussions\":\n\t\t\t\tbodyClass = \"frame\";\n\t\t\t\tbreadcrumbs ~= title = _!\"Forum activity summary\";\n\t\t\t\tdiscussionFrameDiscussions();\n\t\t\t\tbreak;\n\t\t\tcase \"frame-announcements\":\n\t\t\t\tbodyClass = \"frame\";\n\t\t\t\tbreadcrumbs ~= title = _!\"Forum activity summary\";\n\t\t\t\tdiscussionFrameAnnouncements();\n\t\t\t\tbreak;\n\n\t\t\tcase \"feed\":\n\t\t\t{\n\t\t\t\tenforce(path.length > 1, _!\"Feed type not specified\");\n\t\t\t\tenforce(path[1]==\"posts\" || path[1]==\"threads\", _!\"Unknown feed type\");\n\t\t\t\tbool threadsOnly = path[1] == \"threads\";\n\t\t\t\tstring groupUrlName;\n\t\t\t\tif (path.length > 2)\n\t\t\t\t\tgroupUrlName = path[2];\n\t\t\t\tauto hours = to!int(parameters.get(\"hours\", text(FEED_HOURS_DEFAULT)));\n\t\t\t\tenforce(hours <= FEED_HOURS_MAX, _!\"hours parameter exceeds limit\");\n\t\t\t\tauto groupInfo = getGroupInfoByUrl(groupUrlName);\n\t\t\t\tif (groupUrlName && !groupInfo)\n\t\t\t\t\tgroupInfo = getGroupInfo(groupUrlName);\n\t\t\t\tif (groupUrlName && !groupInfo)\n\t\t\t\t\tthrow new NotFoundException(_!\"No such group\");\n\t\t\t\treturn getFeed(groupInfo, threadsOnly, hours).getResponse(request);\n\t\t\t}\n\t\t\tcase \"github-webhook\":\n\t\t\t\tforeach (service; services!GitHub)\n\t\t\t\t\tservice.handleWebHook(request);\n\t\t\t\treturn response.serveText(\"DFeed OK\\n\");\n\n\t\t\tcase \"js\":\n\t\t\tcase \"css\":\n\t\t\tcase \"images\":\n\t\t\tcase \"files\":\n\t\t\tcase \"ircstats\":\n\t\t\tcase \"favicon.ico\":\n\t\t\tcase \".well-known\":\n\t\t\t\treturn serveFile(response, pathStr[1..$]);\n\n\t\t\tcase \"robots.txt\":\n\t\t\t\treturn serveFile(response, config.indexable ? \"robots_public.txt\" : \"robots_private.txt\");\n\n\t\t\tcase \"static\":\n\t\t\t\tenforce(path.length > 2);\n\t\t\t\treturn serveFile(response, path[2..$].join(\"/\"));\n\t\t\tcase \"static-bundle\":\n\t\t\t\tenforce(path.length > 2);\n\t\t\t\treturn makeBundle(path[1], path[2..$].join(\"/\"));\n\n\t\t\tdefault:\n\t\t\t\treturn response.writeError(HttpStatusCode.NotFound);\n\t\t}\n\t}\n\tcatch (Redirect r)\n\t{\n\t\tcookies = user.save();\n\t\treturn response.redirect(r.url);\n\t}\n\tcatch (CaughtException e)\n\t{\n\t\t//return response.writeError(HttpStatusCode.InternalServerError, \"Unprocessed exception: \" ~ e.msg);\n\t\tif (cast(NotFoundException) e)\n\t\t{\n\t\t\tbreadcrumbs ~= title = _!\"Not Found\";\n\t\t\tstatus = HttpStatusCode.NotFound;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tbreadcrumbs ~= title = _!\"Error\";\n\t\t\tstatus = HttpStatusCode.InternalServerError;\n\t\t}\n\t\tauto text = encodeHtmlEntities(e.msg).replace(\"\\n\", \"<br>\");\n\t\tdebug text ~= `<pre>` ~ encodeHtmlEntities(e.toString()) ~ `</pre>`;\n\t\thtml.clear();\n\t\thtml.put(\n\t\t\t`<table class=\"forum-table forum-error\">` ~\n\t\t\t\t`<tr><th>`, encodeHtmlEntities(title), `</th></tr>` ~\n\t\t\t\t`<tr><td class=\"forum-table-message\">`, text, `</td></tr>` ~\n\t\t\t`</table>`);\n\t}\n\n\tassert(title, \"No title\");\n\tassert(html.length, \"No HTML\");\n\tif (breadcrumbs.length) breadcrumbs = [`<a href=\"/\">` ~ _!`Index` ~ `</a>`] ~ breadcrumbs;\n\n\tif (user.isLoggedIn())\n\t\ttools ~= `<a href=\"/logout?url=__URL__\">` ~ _!`Log out` ~ ` ` ~ encodeHtmlEntities(user.getName()) ~ `</a>`;\n\telse\n\t\ttools ~= `<a href=\"/loginform?url=__URL__\">` ~ _!`Log in` ~ `</a>`;\n\ttools ~= `<a href=\"/settings\">` ~ _!`Settings` ~ `</a>`;\n\ttools ~= `<a href=\"/help\">` ~ _!`Help` ~ `</a>`;\n\n\tstring toolStr = tools\n\t\t.map!(t => `<div class=\"tip\">` ~ t ~ `</div>`)\n\t\t.join(\" \");\n\tjsVars[\"toolsTemplate\"] = toJson(toolStr);\n\ttoolStr =\n\t\ttoolStr.replace(\"__URL__\",  encodeUrlParameter(returnPage));\n\ttoolStr =\n\t\t`<div id=\"forum-tools-right\">` ~ toolStr ~ `</div>` ~\n\t\t`<div id=\"forum-tools-left\">` ~\n\t\tbreadcrumbs.join(` &raquo; `) ~ `</div>`\n\t;\n\tstring htmlStr = cast(string) html.get(); // html contents will be overwritten on next request\n\n\tauto pendingNotice = userSettings.pendingNotice;\n\tif (pendingNotice)\n\t{\n\t\tuserSettings.pendingNotice = null;\n\t\tauto parts = pendingNotice.findSplit(\":\");\n\t\tauto kind = parts[0];\n\t\tswitch (kind)\n\t\t{\n\t\t\tcase \"draft-deleted\":\n\t\t\t{\n\t\t\t\tauto draftID = parts[2];\n\t\t\t\thtmlStr =\n\t\t\t\t\t`<div class=\"forum-notice\">` ~ _!`Draft discarded.` ~ ` <a href=\"/posting/` ~ encodeHtmlEntities(draftID) ~ `\">` ~ _!`Undo` ~ `</a></div>` ~ htmlStr;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tcase \"settings-saved\":\n\t\t\t\thtmlStr =\n\t\t\t\t\t`<div class=\"forum-notice\">` ~ _!`Settings saved.` ~ `</div>` ~ htmlStr;\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tthrow new Exception(\"Unknown kind of pending notice: \" ~ kind);\n\t\t}\n\t}\n\n\tjsVars[\"enableKeyNav\"] = userSettings.enableKeyNav;\n\tjsVars[\"autoOpen\"] = userSettings.autoOpen;\n\tjsVars[\"localization\"] = getJsStrings();\n\n\tstring[] extraJS;\n\n\t// Add jQuery fallback only if local copy exists\n\timport std.file : exists;\n\tstatic immutable jqueryPath = \"/js/jquery-1.7.2.min.js\";\n\tif (resolveStaticFileBase(jqueryPath))\n\t\textraJS ~= `window.jQuery || document.write('\\x3Cscript src=\"` ~ staticPath(jqueryPath) ~ `\">\\x3C/script>');`;\n\n\tif (jsVars.length)\n\t\textraJS ~= \"var %-(%s,%);\".format(jsVars.byKeyValue.map!(pair => pair.key ~ \"=\" ~ pair.value));\n\n\tcookies = user.save();\n\tforeach (cookie; cookies)\n\t\tif (cookie.length > 4096 * 15/16)\n\t\t{\n\t\t\thtmlStr =\n\t\t\t\t`<div class=\"forum-notice\">` ~ _!`Warning: cookie size approaching RFC 2109 limit.` ~\n\t\t\t\t_!`Please consider %screating an account%s to avoid losing your read post history.`.format(\n\t\t\t\t\t`<a href=\"/registerform\">`,\n\t\t\t\t\t`</a>`\n\t\t\t\t) ~ `</div>` ~ htmlStr;\n\t\t\tbreak;\n\t\t}\n\n\tstring searchOptionStr;\n\t{\n\t\tstruct SearchOption { string name, value; }\n\t\tSearchOption[] searchOptions;\n\n\t\tsearchOptions ~= SearchOption(_!\"Forums\", \"forum\");\n\t\tif (currentGroup)\n\t\t\tsearchOptions ~= SearchOption(_!\"%s group\".format(currentGroup.publicName), \"group:\" ~ currentGroup.internalName.searchTerm);\n\t\tif (currentThread)\n\t\t\tsearchOptions ~= SearchOption(_!\"This thread\", \"threadmd5:\" ~ currentThread.getDigestString!MD5().toLower());\n\n\t\tforeach (i, option; searchOptions)\n\t\t\tsearchOptionStr ~=\n\t\t\t\t`<option value=\"` ~ encodeHtmlEntities(option.value) ~ `\"` ~ (i==searchOptions.length-1 ? ` selected` : ``) ~ `>` ~\n\t\t\t\t\tencodeHtmlEntities(option.name) ~ `</option>`;\n\t}\n\n\t// Disable HTTP caching, as we're serving dynamic content\n\tresponse.disableCache();\n\n\t// Cache the template with resolved static resources and bundling\n\tstatic Cached!string pageTemplateCache;\n\tif (!pageTemplateCache.isValid)\n\t{\n\t\tCached!string newCache;\n\n\t\tauto skelPath = optimizedPath(\"web/skel.htt\");\n\t\tauto page = readText(skelPath);\n\t\tnewCache.addFile(skelPath);\n\n\t\t// Render navigation (now cacheable since it doesn't depend on currentGroup)\n\t\tpage = renderNav(page);\n\n\t\t// First pass: resolve only static: placeholders, tracking files\n\t\tpage = parseTemplate(page, (string name) {\n\t\t\tif (name.startsWith(\"static:\"))\n\t\t\t{\n\t\t\t\tauto resourcePath = name[7..$];\n\t\t\t\tauto resolvedBase = resolveStaticFileBase(resourcePath)\n\t\t\t\t\t.enforce(\"Static file not found: \" ~ resourcePath);\n\t\t\t\tauto resolvedFile = resolvedBase ~ resourcePath;\n\t\t\t\tnewCache.addFile(resolvedFile);\n\t\t\t\treturn staticPath(resourcePath);\n\t\t\t}\n\t\t\treturn null; // Don't touch other placeholders\n\t\t});\n\n\t\tdebug {} else\n\t\t{\n\t\t\t// Bundle the resolved static resources\n\t\t\tpage = createBundles(page, re!`<link rel=\"stylesheet\" href=\"(/[^/][^\"]*?)\" ?/?>`);\n\t\t\tpage = createBundles(page, re!`<script type=\"text/javascript\" src=\"(/[^/][^\"]*?\\.js)\"></script>`);\n\t\t}\n\n\t\tnewCache.value = page;\n\t\tpageTemplateCache = newCache;\n\t}\n\tauto page = pageTemplateCache.value;\n\n\t// Substitute remaining template variables with page-specific content\n\tstring getVar(string name)\n\t{\n\t\tswitch (name)\n\t\t{\n\t\t\tcase \"title\"          : return encodeHtmlEntities(title);\n\t\t\tcase \"content\"        : return htmlStr;\n\t\t\tcase \"extraheaders\"   : return extraHeaders.join();\n\t\t\tcase \"extrajs\"        : return extraJS.join();\n\t\t\tcase \"bodyclass\"      : return bodyClass;\n\t\t\tcase \"tools\"          : return toolStr;\n\t\t\tcase \"search-options\" : return searchOptionStr;\n\t\t\tdefault:\n\t\t\t\tif (name.skipOver(\"active-group:\"))\n\t\t\t\t{\n\t\t\t\t\treturn (currentGroup && name == currentGroup.urlName)\n\t\t\t\t\t\t? ` class=\"active\"`\n\t\t\t\t\t\t: ``;\n\t\t\t\t}\n\t\t\t\tif (name.skipOver(\"static:\"))\n\t\t\t\t\treturn staticPath(name);\n\t\t\t\tthrow new Exception(\"Unknown variable in template: \" ~ name);\n\t\t}\n\t}\n\tpage = parseTemplate(page, name => getVar(name).nonNull);\n\n\tresponse.serveData(page);\n\tresponse.setStatus(status);\n\treturn response;\n}\n\n// Generic file-based cache\nimport std.datetime.systime : SysTime;\nalias CacheKey = SysTime[string]; // fileName -> modificationTime\n\nstruct Cached(T)\n{\n\tCacheKey key;\n\tT value;\n\n\tbool isValid()\n\t{\n\t\timport std.file : FileException;\n\t\timport dfeed.web.web.statics : timeLastModified;\n\t\tif (this is typeof(this).init)\n\t\t\treturn false;\n\t\tforeach (fileName, mtime; key)\n\t\t\ttry\n\t\t\t\tif (timeLastModified(fileName) != mtime)\n\t\t\t\t\treturn false;\n\t\t\tcatch (FileException)\n\t\t\t\treturn false; // File no longer exists\n\t\treturn true;\n\t}\n\n\tvoid addFile(string fileName)\n\t{\n\t\timport dfeed.web.web.statics : timeLastModified;\n\t\tkey[fileName] = timeLastModified(fileName);\n\t}\n}\n\nstring renderNav(string html)\n{\n\tauto nav = inferList(html, [[\"<?category1?>\"], [\"<?category2?>\"]]);\n\tauto nav2 = inferList(nav.itemSuffix, [[\"<?class1?>\", \"<?url1?>\", \"<?title1?>\"], [\"<?class2?>\", \"<?url2?>\", \"<?title2?>\"]]);\n\tnav.itemSuffix = null; nav.varPrefix ~= null;\n\n\treturn nav.render(groupHierarchy.filter!(set => set.visible).map!(set =>\n\t\t[set.shortName, nav2.render(set.groups.map!(group => [\n\t\t\t`<?active-group:` ~ group.urlName ~ `?>`,\n\t\t\t\"/group/\" ~ group.urlName,\n\t\t\t group.navName ? group.navName : group.publicName,\n\t\t]).array)]\n\t).array);\n}\n\nstatic string parseTemplate(string data, scope string delegate(string) dictionary)\n{\n\timport ae.utils.textout;\n\tStringBuilder sb;\n\tsb.preallocate(data.length / 100 * 110);\n\twhile (true)\n\t{\n\t\tauto startpos = data.indexOf(\"<?\");\n\t\tif (startpos==-1)\n\t\t\tbreak;\n\t\tauto endpos = data.indexOf(\"?>\");\n\t\tif (endpos<startpos+2)\n\t\t\tthrow new Exception(\"Bad syntax in template\");\n\t\tstring token = data[startpos+2 .. endpos];\n\t\tauto value = dictionary(token);\n\t\tif (value is null)\n\t\t{\n\t\t\t// Don't substitute this placeholder, keep it as-is\n\t\t\tsb.put(data[0 .. endpos+2]);\n\t\t}\n\t\telse\n\t\t{\n\t\t\tsb.put(data[0 .. startpos], value);\n\t\t}\n\t\tdata = data[endpos+2 .. $];\n\t}\n\tsb.put(data);\n\treturn sb.get();\n}\n"
  },
  {
    "path": "src/dfeed/web/web/server.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Web server entry point.\nmodule dfeed.web.web.server;\n\nimport std.functional : toDelegate;\n\nimport dfeed.web.moderation : loadBanList;\nimport dfeed.web.web.config;\nimport dfeed.web.web.perf;\nimport dfeed.web.web.request : onRequest;\n\nimport ae.net.http.server : HttpServer;\nimport ae.net.shutdown;\nimport ae.sys.log : Logger, createLogger, asyncLogger;\n\nLogger log;\nHttpServer server;\n\nvoid startWebUI()\n{\n\tlog = createLogger(\"Web\").asyncLogger();\n\tstatic if (measurePerformance) perfLog = createLogger(\"Performance\");\n\n\tloadBanList();\n\n\tserver = new HttpServer();\n\tserver.log = log;\n\tserver.handleRequest = toDelegate(&onRequest);\n\tserver.remoteIPHeader = config.remoteIPHeader;\n\tserver.listen(config.listen.port, config.listen.addr);\n\n\taddShutdownHandler((scope const(char)[] reason){ server.close(); });\n}\n"
  },
  {
    "path": "src/dfeed/web/web/site.d",
    "content": "﻿/*  Copyright (C) 2023  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Site web stuff.\nmodule dfeed.web.web.site;\n\nimport dfeed.loc;\nimport dfeed.web.web.page;\n\nprivate string getSiteNotice()\n{\n\timport std.file : readText;\n\timport dfeed.paths : resolveSiteFile;\n\ttry\n\t\treturn readText(resolveSiteFile(\"config/site-notice.html\"));\n\tcatch (Exception e)\n\t\treturn null;\n}\n\nvoid putSiteNotice()\n{\n\tif (auto notice = getSiteNotice())\n\t\thtml.put(`<div class=\"forum-notice\">`, notice, `</div>`);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/statics.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2021, 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Handling of static files and resources.\nmodule dfeed.web.web.statics;\n\nimport core.time : MonoTime, seconds;\n\nimport std.algorithm.comparison : max;\nimport std.algorithm.iteration : map, reduce;\nimport std.algorithm.searching : endsWith, canFind;\nimport std.array : replace, split;\nimport std.conv : to, text;\nimport std.datetime.systime : SysTime;\nimport std.exception : enforce;\nimport std.file : exists;\nimport std.format : format;\nimport std.path : buildNormalizedPath, dirName, stripExtension, extension;\nimport std.regex : replaceAll, replaceFirst;\nstatic import std.file;\nimport std.regex : Regex, matchAll;\n\nimport std.ascii : LetterCase;\nimport std.digest.md : md5Of, toHexString;\n\nimport ae.net.http.responseex : HttpResponseEx;\nimport ae.sys.data : Data, DataVec;\nimport ae.utils.meta : isDebug;\nimport ae.utils.regex : re;\n\nimport dfeed.paths : resolveSiteFileBase, resolveSiteFile, resolveStaticFileBase;\nimport dfeed.web.web.config : config;\n\n/// Caching wrapper for file modification time.\n/// Caches stat calls for a few seconds to reduce filesystem overhead.\nSysTime timeLastModified(string path)\n{\n\tstatic if (is(MonoTimeImpl!(ClockType.coarse)))\n\t\talias CoarseTime = MonoTimeImpl!(ClockType.coarse);\n\telse\n\t\talias CoarseTime = MonoTime;\n\n\tstatic SysTime[string] cache;\n\tstatic CoarseTime cacheTime;\n\n\tenum expiration = isDebug ? 1.seconds : 5.seconds;\n\n\tauto now = CoarseTime.currTime();\n\tif (now - cacheTime > expiration)\n\t{\n\t\tcacheTime = now;\n\t\tcache = null;\n\t}\n\n\tauto pcache = path in cache;\n\tif (pcache)\n\t\treturn *pcache;\n\treturn cache[path] = std.file.timeLastModified(path);\n}\n\n/// Cached content hash for cache-busting static URLs.\n/// Only recomputes hash when file modification time changes.\n/// Uses the timeLastModified cache to minimize stat calls.\nstring fileContentHash(string path)\n{\n\tstatic struct CacheEntry\n\t{\n\t\tlong mtime;\n\t\tstring hash;\n\t}\n\tstatic CacheEntry[string] hashCache;\n\n\tauto mtime = timeLastModified(path).stdTime;\n\n\tif (auto entry = path in hashCache)\n\t\tif (entry.mtime == mtime)\n\t\t\treturn entry.hash;\n\n\t// Compute hash - use first 16 hex chars of MD5 (64 bits)\n\tauto content = cast(ubyte[]) std.file.read(path);\n\tauto hash = md5Of(content).toHexString!(LetterCase.lower)[0..16].idup;\n\n\thashCache[path] = CacheEntry(mtime, hash);\n\treturn hash;\n}\n\nstring staticPath(string path)\n{\n\tauto resolvedBase = resolveStaticFileBase(path)\n\t\t.enforce(\"Static file not found: \" ~ path);\n\tauto resolvedFile = resolvedBase ~ path;\n\tauto url = \"/static/\" ~ fileContentHash(resolvedFile) ~ path;\n\tif (config.staticDomain !is null)\n\t\turl = \"//\" ~ config.staticDomain ~ url;\n\treturn url;\n}\n\n/// Return `resource`, or `resource ~ \".min\"` if it\n/// exists in `base` and is not older than `resource`.\nstring optimizedPath(string base, string resource)\n{\n\tdebug\n\t\treturn resource;\n\telse\n\t{\n\t\tstring optimizedResource = resource.stripExtension ~ \".min\" ~ resource.extension;\n\t\tauto origPath = base ~ resource;\n\t\tauto optiPath = base ~ optimizedResource;\n\t\tif (exists(origPath) && exists(optiPath) && timeLastModified(optiPath) >= timeLastModified(origPath))\n\t\t\treturn optimizedResource;\n\t\telse\n\t\t\treturn resource;\n\t}\n}\n\n/// Resolve the site location of and return the optimized version of `path`.\nstring optimizedPath(string path)\n{\n\tauto resolvedBase = resolveSiteFileBase(path);\n\tauto relativePath = optimizedPath(resolvedBase, path);\n\treturn resolvedBase ~ relativePath;\n}\n\nHttpResponseEx serveFile(HttpResponseEx response, string path)\n{\n\tauto resolvedBase = resolveStaticFileBase(\"/\" ~ path);\n\tauto optimizedFile = optimizedPath(resolvedBase, path);\n\tresponse.cacheForever();\n\treturn response.serveFile(path, resolvedBase);\n}\n\n// ***********************************************************************\n\nstring createBundles(string page, Regex!char re)\n{\n\tstring[] paths;\n\tforeach (m; page.matchAll(re))\n\t\tpaths ~= m.captures[1];\n\tif (paths.length == 0)\n\t\treturn page;\n\n\t// Extract hashes and compute combined bundle hash\n\t// URL format: /static/<16-char-hash>/<path>\n\t// Positions: 0-7=\"/static/\", 8-23=hash, 24=\"/\", 25+=path\n\timport std.algorithm.iteration : joiner;\n\timport std.array : array;\n\tauto hashes = paths.map!(path => path[8..24]);\n\tauto combinedHash = md5Of(cast(ubyte[]) hashes.joiner.array)\n\t\t.toHexString!(LetterCase.lower)[0..16];\n\tstring bundleUrl = \"/static-bundle/%s/%-(%s+%)\".format(combinedHash, paths.map!(path => path[25..$]));\n\tint index;\n\tpage = page.replaceAll!(captures => index++ ? null : captures[0].replace(captures[1], bundleUrl))(re);\n\treturn page;\n}\n\nHttpResponseEx makeBundle(string time, string url)\n{\n\tstatic struct Bundle\n\t{\n\t\tstring time;\n\t\tHttpResponseEx response;\n\t}\n\tstatic Bundle[string] cache;\n\n\tif (url !in cache || cache[url].time != time || isDebug)\n\t{\n\t\tauto bundlePaths = url.split(\"+\");\n\t\tenforce(bundlePaths.length > 0, \"Empty bundle\");\n\t\tHttpResponseEx bundleResponse;\n\t\tforeach (n, bundlePath; bundlePaths)\n\t\t{\n\t\t\tauto pathResponse = new HttpResponseEx;\n\t\t\tserveFile(pathResponse, bundlePath);\n\t\t\tassert(pathResponse.data.length == 1);\n\t\t\tif (bundlePath.endsWith(\".css\"))\n\t\t\t{\n\t\t\t\tauto oldText = cast(string)pathResponse.data[0].contents;\n\t\t\t\tauto newText = fixCSS(oldText, bundlePath, n == 0);\n\t\t\t\tif (oldText !is newText)\n\t\t\t\t\tpathResponse.data = DataVec(Data(newText));\n\t\t\t}\n\t\t\tif (!bundleResponse)\n\t\t\t\tbundleResponse = pathResponse;\n\t\t\telse\n\t\t\t\tbundleResponse.data ~= pathResponse.data[];\n\t\t}\n\t\tcache[url] = Bundle(time, bundleResponse);\n\t}\n\treturn cache[url].response.dup;\n}\n\nstring fixCSS(string css, string path, bool first)\n{\n\tcss = css.replaceFirst(re!(`@charset \"utf-8\";`, \"i\"), ``);\n\tif (first)\n\t\tcss = `@charset \"utf-8\";` ~ css;\n\tcss = css.replaceAll!(captures =>\n\t\tcaptures[2].canFind(\"//\")\n\t\t? captures[0]\n\t\t: captures[0].replace(captures[2], staticPath(buildNormalizedPath(dirName(\"/\" ~ path), captures[2]).replace(`\\`, `/`)))\n\t)(re!`\\burl\\(('?)(.*?)\\1\\)`);\n\treturn css;\n}\n\n"
  },
  {
    "path": "src/dfeed/web/web/user.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// User settings.\nmodule dfeed.web.web.user;\n\nimport ae.utils.aa : aaGet;\nimport ae.utils.text : randomString;\n\nimport dfeed.loc;\nimport dfeed.web.user : User, SettingType;\n\nUser user;\n\nstruct UserSettings\n{\n\tstatic SettingType[string] settingTypes;\n\n\ttemplate userSetting(string name, string defaultValue, SettingType settingType)\n\t{\n\t\t@property string userSetting() { return user.get(name, defaultValue, settingType); }\n\t\t@property string userSetting(string newValue) { user.set(name, newValue, settingType); return newValue; }\n\t\tstatic this() { settingTypes[name] = settingType; }\n\t}\n\n\ttemplate randomUserString(string name, SettingType settingType)\n\t{\n\t\t@property string randomUserString()\n\t\t{\n\t\t\tauto value = user.get(name, null, settingType);\n\t\t\tif (value is null)\n\t\t\t{\n\t\t\t\tvalue = randomString();\n\t\t\t\tuser.set(name, value, settingType);\n\t\t\t}\n\t\t\treturn value;\n\t\t}\n\t}\n\n\t/// Posting details. Remembered when posting messages.\n\talias name = userSetting!(\"name\", null, SettingType.server);\n\talias email = userSetting!(\"email\", null, SettingType.server); /// ditto\n\n\t/// View mode. Can be changed in the settings.\n\talias groupViewMode = userSetting!(\"groupviewmode\", \"basic\", SettingType.client);\n\n\t/// Enable or disable keyboard hotkeys. Can be changed in the settings.\n\talias enableKeyNav = userSetting!(\"enable-keynav\", \"true\", SettingType.client);\n\n\t/// Enable or disable rendering Markdown content.. Can be changed in the settings.\n\talias renderMarkdown = userSetting!(\"render-markdown\", \"true\", SettingType.server);\n\n\t/// Whether messages are opened automatically after being focused\n\t/// (message follows focus). Can be changed in the settings.\n\talias autoOpen = userSetting!(\"auto-open\", \"false\", SettingType.client);\n\n\t/// Any pending notices that should be shown on the next page shown.\n\talias pendingNotice = userSetting!(\"pending-notice\", null, SettingType.session);\n\n\t/// Session management\n\talias previousSession = userSetting!(\"previous-session\", \"0\", SettingType.server);\n\talias currentSession  = userSetting!(\"current-session\" , \"0\", SettingType.server);  /// ditto\n\talias sessionCanary   = userSetting!(\"session-canary\"  , \"0\", SettingType.session); /// ditto\n\n\t/// A unique ID used to recognize both logged-in and anonymous users.\n\talias id = randomUserString!(\"id\", SettingType.server);\n\n\t/// Secret token used for CSRF protection.\n\t/// Visible in URLs.\n\talias secret = randomUserString!(\"secret\", SettingType.server);\n\n\t/// UI language.\n\talias language = userSetting!(\"language\", null, SettingType.server);\n\n\tvoid set(string name, string value)\n\t{\n\t\tuser.set(name, value, settingTypes.aaGet(name));\n\t}\n}\nUserSettings userSettings;\n\nstatic immutable allViewModes = [\"basic\", \"narrow-index\", \"threaded\", \"horizontal-split\", \"vertical-split\"];\nstring viewModeName(string viewMode)\n{\n\tswitch (viewMode)\n\t{\n\t\tcase \"basic\"           : return _!\"basic\"           ;\n\t\tcase \"narrow-index\"    : return _!\"narrow-index\"    ;\n\t\tcase \"threaded\"        : return _!\"threaded\"        ;\n\t\tcase \"horizontal-split\": return _!\"horizontal-split\";\n\t\tcase \"vertical-split\"  : return _!\"vertical-split\"  ;\n\t\tdefault: throw new Exception(_!\"Unknown view mode\");\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/feed.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// ATOM feeds.\nmodule dfeed.web.web.view.feed;\n\nimport core.time : dur;\n\nimport std.array : join;\nimport std.conv : text;\nimport std.datetime.systime;\nimport std.format : format;\n\nimport ae.net.http.caching : CachedResource;\nimport ae.sys.data : Data, DataVec;\nimport ae.utils.feed : AtomFeedWriter;\n\nimport dfeed.loc;\nimport dfeed.database : query;\nimport dfeed.groups : GroupInfo;\nimport dfeed.message : Rfc850Post;\nimport dfeed.sinks.cache : CachedSet;\nimport dfeed.sinks.subscriptions : getSubscription;\nimport dfeed.site : site;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.part.postbody : formatBody;\nimport dfeed.web.web.perf;\nimport dfeed.web.web.postinfo : getPost;\n\nenum FEED_HOURS_DEFAULT = 24;\nenum FEED_HOURS_MAX = 72;\n\nCachedSet!(string, CachedResource) feedCache;\n\nCachedResource getFeed(GroupInfo groupInfo, bool threadsOnly, int hours)\n{\n\tstring feedUrl = site.proto ~ \"://\" ~ site.host ~ \"/feed\" ~\n\t\t(threadsOnly ? \"/threads\" : \"/posts\") ~\n\t\t(groupInfo ? \"/\" ~ groupInfo.urlName : \"\") ~\n\t\t(hours!=FEED_HOURS_DEFAULT ? \"?hours=\" ~ text(hours) : \"\");\n\n\tCachedResource getFeed()\n\t{\n\t\tauto title = groupInfo\n\t\t\t? threadsOnly\n\t\t\t\t? _!\"Latest threads on %s\".format(groupInfo.publicName)\n\t\t\t\t: _!\"Latest posts on %s\"  .format(groupInfo.publicName)\n\t\t\t: threadsOnly\n\t\t\t\t? _!\"Latest threads\"\n\t\t\t\t: _!\"Latest posts\"\n\t\t\t;\n\t\tauto posts = getFeedPosts(groupInfo, threadsOnly, hours);\n\t\tauto feed = makeFeed(posts, feedUrl, title, groupInfo is null);\n\t\treturn feed;\n\t}\n\treturn feedCache(feedUrl, getFeed());\n}\n\nRfc850Post[] getFeedPosts(GroupInfo groupInfo, bool threadsOnly, int hours)\n{\n\tstring PERF_SCOPE = \"getFeedPosts(%s,%s,%s)\".format(groupInfo ? groupInfo.internalName : \"null\", threadsOnly, hours); mixin(MeasurePerformanceMixin);\n\n\tauto since = (Clock.currTime() - dur!\"hours\"(hours)).stdTime;\n\tauto iterator =\n\t\tgroupInfo ?\n\t\t\tthreadsOnly ?\n\t\t\t\tquery!\"SELECT `Message` FROM `Posts` WHERE `ID` IN (SELECT `ID` FROM `Groups` WHERE `Time` > ? AND `Group` = ?) AND `ID` = `ThreadID`\".iterate(since, groupInfo.internalName)\n\t\t\t:\n\t\t\t\tquery!\"SELECT `Message` FROM `Posts` WHERE `ID` IN (SELECT `ID` FROM `Groups` WHERE `Time` > ? AND `Group` = ?)\".iterate(since, groupInfo.internalName)\n\t\t:\n\t\t\tthreadsOnly ?\n\t\t\t\tquery!\"SELECT `Message` FROM `Posts` WHERE `Time` > ? AND `ID` = `ThreadID`\".iterate(since)\n\t\t\t:\n\t\t\t\tquery!\"SELECT `Message` FROM `Posts` WHERE `Time` > ?\".iterate(since)\n\t\t;\n\n\tRfc850Post[] posts;\n\tforeach (string message; iterator)\n\t\tposts ~= new Rfc850Post(message);\n\treturn posts;\n}\n\nCachedResource makeFeed(Rfc850Post[] posts, string feedUrl, string feedTitle, bool addGroup)\n{\n\tAtomFeedWriter feed;\n\tfeed.startFeed(feedUrl, feedTitle, Clock.currTime());\n\n\tforeach (post; posts)\n\t{\n\t\thtml.clear();\n\t\tformatBody(post);\n\n\t\tauto postTitle = post.rawSubject;\n\t\tif (addGroup)\n\t\t\tpostTitle = \"[\" ~ post.publicGroupNames().join(\", \") ~ \"] \" ~ postTitle;\n\n\t\tfeed.putEntry(post.url, postTitle, post.author, post.time, cast(string)html.get(), post.url);\n\t}\n\tfeed.endFeed();\n\n\treturn new CachedResource(DataVec(Data(feed.xml.output.get())), \"application/atom+xml\");\n}\n\nCachedResource getSubscriptionFeed(string subscriptionID)\n{\n\tstring feedUrl = site.proto ~ \"://\" ~ site.host ~ \"/subscription-feed/\" ~ subscriptionID;\n\n\tCachedResource getFeed()\n\t{\n\t\tauto subscription = getSubscription(subscriptionID);\n\t\tauto title = _!\"%s subscription (%s)\".format(site.host, subscription.trigger.getTextDescription());\n\t\tRfc850Post[] posts;\n\t\tforeach (string messageID; query!\"SELECT [MessageID] FROM [SubscriptionPosts] WHERE [SubscriptionID] = ? ORDER BY [Time] DESC LIMIT 50\"\n\t\t\t\t\t\t\t.iterate(subscriptionID))\n\t\t{\n\t\t\tauto post = getPost(messageID);\n\t\t\tif (post)\n\t\t\t\tposts ~= post;\n\t\t}\n\n\t\treturn makeFeed(posts, feedUrl, title, true);\n\t}\n\treturn feedCache(feedUrl, getFeed());\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/group.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Group index.\nmodule dfeed.web.web.view.group;\n\nimport std.algorithm.comparison;\nimport std.algorithm.iteration;\nimport std.algorithm.mutation : reverse;\nimport std.algorithm.searching;\nimport std.algorithm.sorting;\nimport std.array : replicate, replace, array, join;\nimport std.conv;\nimport std.datetime.systime : SysTime;\nimport std.datetime.timezone : UTC;\nimport std.exception : enforce;\nimport std.format;\n\nimport ae.utils.text.html : encodeHtmlEntities;\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.loc;\nimport dfeed.database : query;\nimport dfeed.groups;\nimport dfeed.message;\nimport dfeed.sinks.cache;\nimport dfeed.web.user : User;\nimport dfeed.web.web.cache : postCountCache, getPostCounts;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.part.gravatar : getGravatarHash, putGravatar;\nimport dfeed.web.web.part.pager : THREADS_PER_PAGE, getPageOffset, threadPager, indexToPage, getPageCount, getPageCount, pager;\nimport dfeed.web.web.part.profile : profileUrl;\nimport dfeed.web.web.part.strings : formatTinyTime, formatShortTime, formatLongTime, formatAbsoluteTime, summarizeTime, formatNumber;\nimport dfeed.web.web.part.thread : formatThreadedPosts;\nimport dfeed.web.web.postinfo : PostInfo, getPostInfo, getPost;\nimport dfeed.web.web.statics : staticPath;\nimport dfeed.web.web.user : user, userSettings;\n\nint[] getThreadPostIndexes(string id)\n{\n\tint[] result;\n\tforeach (int rowid; query!\"SELECT `ROWID` FROM `Posts` WHERE `ThreadID` = ?\".iterate(id))\n\t\tresult ~= rowid;\n\treturn result;\n}\n\nCachedSet!(string, int[]) threadPostIndexCache;\n\nvoid newPostButton(GroupInfo groupInfo)\n{\n\thtml.put(\n\t\t`<form name=\"new-post-form\" method=\"get\" action=\"/newpost/`), html.putEncodedEntities(groupInfo.urlName), html.put(`\">` ~\n\t\t\t`<div class=\"header-tools\">` ~\n\t\t\t\t`<input class=\"btn\" type=\"submit\" value=\"`, _!`Create thread`, `\">` ~\n\t\t\t\t`<input class=\"img\" type=\"image\" src=\"`, staticPath(\"/images/newthread.png\"), `\" alt=\"`, _!`Create thread`, `\">` ~\n\t\t\t`</div>` ~\n\t\t`</form>`);\n}\n\nvoid discussionGroup(GroupInfo groupInfo, int page)\n{\n\tenforce(page >= 1, _!\"Invalid page\");\n\n\tstruct Thread\n\t{\n\t\tstring id;\n\t\tPostInfo* _firstPost, _lastPost;\n\t\tint postCount, unreadPostCount;\n\n\t\t/// Handle orphan posts\n\t\t@property PostInfo* firstPost() { return _firstPost ? _firstPost : _lastPost; }\n\t\t@property PostInfo* lastPost() { return _lastPost; }\n\n\t\t@property bool isRead() { return unreadPostCount==0; }\n\t}\n\n\tThread[] threads;\n\tthreads.reserve(THREADS_PER_PAGE);\n\n\tint getUnreadPostCount(string id)\n\t{\n\t\tauto posts = threadPostIndexCache(id, getThreadPostIndexes(id));\n\t\tint count = 0;\n\t\tforeach (post; posts)\n\t\t\tif (!user.isRead(post))\n\t\t\t\tcount++;\n\t\treturn count;\n\t}\n\n\tforeach (string firstPostID, string lastPostID; query!\"SELECT `ID`, `LastPost` FROM `Threads` WHERE `Group` = ? ORDER BY `LastUpdated` DESC LIMIT ? OFFSET ?\".iterate(groupInfo.internalName, THREADS_PER_PAGE, getPageOffset(page, THREADS_PER_PAGE)))\n\t\tforeach (int count; query!\"SELECT COUNT(*) FROM `Posts` WHERE `ThreadID` = ?\".iterate(firstPostID))\n\t\t{\n\t\t\tif (count == 0 && user.getLevel() < User.Level.sysadmin)\n\t\t\t{\n\t\t\t\t// 0-count threads can occur after deleting the last post in a thread, and that post's message ID did not match the thread's.\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tthreads ~= Thread(firstPostID, getPostInfo(firstPostID), getPostInfo(lastPostID), count, getUnreadPostCount(firstPostID));\n\t\t}\n\n\tvoid summarizeThread(string tid, PostInfo* info, bool isRead)\n\t{\n\t\tif (info)\n\t\t\twith (*info)\n\t\t\t{\n\t\t\t\tputGravatar(getGravatarHash(info.authorEmail), author, profileUrl(author, authorEmail), _!`%s's profile`.format(author), `class=\"forum-postsummary-gravatar\" `);\n\t\t\t\thtml.put(\n\t\t\t\t//\t`<!-- Thread ID: ` ~ encodeHtmlEntities(threadID) ~ ` | First Post ID: ` ~ encodeHtmlEntities(id) ~ `-->` ~\n\t\t\t\t\t`<div class=\"truncated\"><a class=\"forum-postsummary-subject `, (isRead ? \"forum-read\" : \"forum-unread\"), `\" href=\"`), html.putEncodedEntities(idToUrl(tid, \"thread\")), html.put(`\" title=\"`), html.putEncodedEntities(subject), html.put(`\">`), html.putEncodedEntities(subject), html.put(`</a></div>` ~\n\t\t\t\t\t`<div class=\"truncated\">`, _!`by`, ` <span class=\"forum-postsummary-author\" title=\"`), html.putEncodedEntities(author), html.put(`\">`), html.putEncodedEntities(author), html.put(`</span></div>`);\n\t\t\t\treturn;\n\t\t\t}\n\n\t\thtml.put(`<div class=\"forum-no-data\">-</div>`);\n\t}\n\n\tvoid summarizeLastPost(PostInfo* info)\n\t{\n\t\tif (info)\n\t\t\twith (*info)\n\t\t\t{\n\t\t\t\thtml.put(\n\t\t\t\t\t`<a class=\"forum-postsummary-time `, user.isRead(rowid) ? \"forum-read\" : \"forum-unread\", `\" href=\"`), html.putEncodedEntities(idToUrl(id)), html.put(`\">`, summarizeTime(time), `</a>` ~\n\t\t\t\t\t`<div class=\"truncated\">`, _!`by`, ` <a class=\"forum-postsummary-author\" href=\"`, profileUrl(author, authorEmail), `\" title=\"`), html.putEncodedEntities(author), html.put(`\">`), html.putEncodedEntities(author), html.put(`</a></div>`);\n\t\t\t\treturn;\n\t\t\t}\n\t\thtml.put(`<div class=\"forum-no-data\">-</div>`);\n\t}\n\n\tvoid summarizePostCount(ref Thread thread)\n\t{\n\t\thtml.put(`<a class=\"secretlink\" href=\"`), html.putEncodedEntities(idToUrl(thread.id, \"thread\")), html.put(`\">`);\n\t\tif (thread.unreadPostCount == 0)\n\t\t\thtml ~= formatNumber(thread.postCount-1);\n\t\telse\n\t\t\thtml.put(`<b>`, formatNumber(thread.postCount-1), `</b>`);\n\t\thtml.put(`</a>`);\n\n\t\tif (thread.unreadPostCount && thread.unreadPostCount != thread.postCount)\n\t\t\thtml.put(\n\t\t\t\t`<br>(<a href=\"`, idToUrl(thread.id, \"first-unread\"), `\">`, formatNumber(thread.unreadPostCount), ` new</a>)`);\n\t}\n\n\thtml.put(\n\t\t`<table id=\"group-index\" class=\"forum-table\">` ~\n\t\t`<tr class=\"table-fixed-dummy\">`, `<td></td>`.replicate(3), `</tr>` ~ // Fixed layout dummies\n\t\t`<tr class=\"group-index-header\"><th colspan=\"3\"><div class=\"header-with-tools\">`), newPostButton(groupInfo), html.putEncodedEntities(groupInfo.publicName), html.put(`</div></th></tr>` ~\n\t\t`<tr class=\"subheader\"><th>`, _!`Thread / Thread Starter`, `</th><th>`, _!`Last Post`, `</th><th>`, _!`Replies`, `</th>`);\n\tforeach (thread; threads)\n\t\thtml.put(\n\t\t\t`<tr class=\"thread-row\">` ~\n\t\t\t\t`<td class=\"group-index-col-first\">`), summarizeThread(thread.id, thread.firstPost, thread.isRead), html.put(`</td>` ~\n\t\t\t\t`<td class=\"group-index-col-last\">`), summarizeLastPost(thread.lastPost), html.put(`</td>` ~\n\t\t\t\t`<td class=\"number-column\">`), summarizePostCount(thread), html.put(`</td>` ~\n\t\t\t`</tr>`);\n\tthreadPager(groupInfo, page);\n\thtml.put(\n\t\t`</table>`\n\t);\n}\n\nvoid discussionGroupNarrowIndex(GroupInfo groupInfo, int page)\n{\n\tenforce(page >= 1, _!\"Invalid page\");\n\n\tstruct Thread\n\t{\n\t\tstring id;\n\t\tPostInfo* _firstPost, _lastPost;\n\t\tint postCount, unreadPostCount;\n\n\t\t/// Handle orphan posts\n\t\t@property PostInfo* firstPost() { return _firstPost ? _firstPost : _lastPost; }\n\t\t@property PostInfo* lastPost() { return _lastPost; }\n\n\t\t@property bool isRead() { return unreadPostCount == 0; }\n\t}\n\n\tThread[] threads;\n\tthreads.reserve(THREADS_PER_PAGE);\n\n\tint getUnreadPostCount(string id)\n\t{\n\t\tauto posts = threadPostIndexCache(id, getThreadPostIndexes(id));\n\t\tint count = 0;\n\t\tforeach (post; posts)\n\t\t\tif (!user.isRead(post))\n\t\t\t\tcount++;\n\t\treturn count;\n\t}\n\n\tforeach (string firstPostID, string lastPostID; query!\"SELECT `ID`, `LastPost` FROM `Threads` WHERE `Group` = ? ORDER BY `LastUpdated` DESC LIMIT ? OFFSET ?\".iterate(groupInfo.internalName, THREADS_PER_PAGE, getPageOffset(page, THREADS_PER_PAGE)))\n\t\tforeach (int count; query!\"SELECT COUNT(*) FROM `Posts` WHERE `ThreadID` = ?\".iterate(firstPostID))\n\t\t{\n\t\t\tif (count == 0 && user.getLevel() < User.Level.sysadmin)\n\t\t\t{\n\t\t\t\t// 0-count threads can occur after deleting the last post in a thread, and that post's message ID did not match the thread's.\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\tthreads ~= Thread(firstPostID, getPostInfo(firstPostID), getPostInfo(lastPostID), count, getUnreadPostCount(firstPostID));\n\t\t}\n\n\tvoid summarizeThread(ref Thread thread)\n\t{\n\t\thtml.put(`<div class=\"thread\">` ~\n\t\t\t`<div class=\"title\">` ~\n\t\t\t`<a ` ~\n\t\t\t`class=\"`, thread.isRead ? \"forum-read\" : \"forum-unread\", `\" ` ~\n\t\t\t`href=\"`);\n\t\thtml.putEncodedEntities(idToUrl(thread.id, \"thread\"));\n\t\thtml.put(`\" title=\"`);\n\t\thtml.putEncodedEntities(formatLongTime(thread.firstPost.time));\n\t\thtml.put(`\">`);\n\t\thtml.putEncodedEntities(thread.firstPost.subject);\n\t\thtml.put(`</a>` ~\n\t\t\t`</div>` ~\n\t\t\t`<img  class=\"firstpost-author-image\" src=\"//www.gravatar.com/avatar/`, getGravatarHash(thread.firstPost.authorEmail), `?d=identicon\">` ~\n\t\t\t`<div class=\"firstpost\">` ~\n\t\t\t`<time class=\"firstpost-time\" ` ~\n\t\t\t`datetime=\"`);\n\t\thtml.putEncodedEntities(thread.firstPost.time.formatTimeLoc!\"c\");\n\t\thtml.put(`\" ` ~\n\t\t\t`title=\"`);\n\t\thtml.putEncodedEntities(formatAbsoluteTime(thread.firstPost.time));\n\t\thtml.put(`\">` ~\n\t\t\t`<span class=\"short\">`);\n\t\thtml.putEncodedEntities(formatTinyTime(thread.firstPost.time));\n\t\thtml.put(`</span>` ~\n\t\t\t`<span class=\"long\">`);\n\t\thtml.putEncodedEntities(formatShortTime(thread.firstPost.time, false));\n\t\thtml.put(`</span>` ~\n\t\t\t`</time>` ~\n\t\t\t`<div class=\"firstpost-author-name\"><a href=\"`, profileUrl(thread.firstPost.author, thread.firstPost.authorEmail), `\">`);\n\t\thtml.putEncodedEntities(thread.firstPost.author);\n\t\thtml.put(`</a></div>` ~\n\t\t\t`</div>` ~\n\t\t\t`<div class=\"replies\">` ~\n\t\t\t`<div class=\"replies-total\">` ~\n\t\t\t`<span class=\"short\">`,\n\t\t\tformatNumber(thread.postCount - 1),\n\t\t\t`</span>` ~\n\t\t\t`<span class=\"long\">`,\n\t\t\tformatNumber(thread.postCount - 1), \" \", (thread.postCount - 1) == 1 ? _!\"reply\" : _!\"replies\");\n\t\thtml.put(`</span>` ~\n\t\t\t`</div>` ~\n\t\t\t`<div class=\"replies-new `, thread.isRead ? \"forum-read\" : \"forum-unread\", `\">` ~\n\t\t\t`<span class=\"short\">`);\n\t\tif (thread.unreadPostCount && thread.unreadPostCount != thread.postCount)\n\t\t\thtml.put(`(<a href=\"`, idToUrl(thread.id, \"first-unread\"), `\">`, formatNumber(thread.unreadPostCount), `</a>)`);\n\t\thtml.put(`</span>` ~\n\t\t\t`<span class=\"long\">`);\n\t\tif (thread.unreadPostCount && thread.unreadPostCount != thread.postCount)\n\t\t\thtml.put(`<a href=\"`, idToUrl(thread.id, \"first-unread\"), `\">`, formatNumber(thread.unreadPostCount), ` `, _!`new`, `</a>`);\n\t\thtml.put(`</span>` ~\n\t\t\t`</div>` ~\n\t\t\t`</div>`);\n\t\tif (thread.lastPost != null)\n\t\t{\n\t\t\thtml.put(`<div class=\"lastpost\">` ~\n\t\t\t\t`<div class=\"lastpost-time\">` ~\n\t\t\t\t`<a title=\"`);\n\t\t\thtml.putEncodedEntities(formatAbsoluteTime(thread.lastPost.time));\n\t\t\thtml.put(`\" ` ~\n\t\t\t\t`href=\"`);\n\t\t\thtml.putEncodedEntities(idToUrl(thread.lastPost.id));\n\t\t\thtml.put(`\">`,\n\t\t\t\t`<span class=\"last\">`,\n\t\t\t\t_!`Last`,\n\t\t\t\t`: ` ~\n\t\t\t\t`</span>` ~\n\t\t\t\t`<span class=\"last-post\">`,\n\t\t\t\t_!`Last Post`,\n\t\t\t\t`: ` ~\n\t\t\t\t`</span>` ~\n\t\t\t\t`<time ` ~\n\t\t\t\t`datetime=\"`);\n\t\t\thtml.putEncodedEntities(thread.lastPost.time.formatTimeLoc!\"c\");\n\t\t\thtml.put(`\">` ~\n\t\t\t\t`<span class=\"short\">`);\n\t\t\thtml.putEncodedEntities(formatTinyTime(thread.lastPost.time));\n\t\t\thtml.put(`</span>` ~\n\t\t\t\t`<span class=\"long\">`);\n\t\t\thtml.putEncodedEntities(formatShortTime(thread.lastPost.time, false));\n\t\t\thtml.put(`</span>` ~\n\t\t\t\t`</time>` ~\n\t\t\t\t`</a>` ~\n\t\t\t\t`</div>` ~\n\t\t\t\t`<div class=\"lastpost-author\">` ~\n\t\t\t\t`<img class=\"lastpost-author-image\" src=\"//www.gravatar.com/avatar/`, getGravatarHash(thread.lastPost.authorEmail), `?d=identicon\">`);\n\t\t\thtml.put(`<a class=\"lastpost-author-name\" href=\"`, profileUrl(thread.lastPost.author, thread.lastPost.authorEmail), `\">`);\n\t\t\thtml.putEncodedEntities(thread.lastPost.author);\n\t\t\thtml.put(`</a>` ~\n\t\t\t\t`</div>` ~\n\t\t\t\t`</div>`);\n\t\t}\n\t\telse\n\t\t{\n\t\t\thtml.put(`<div class=\"lastpost\">` ~\n\t\t\t\t`<div class=\"lastpost-time\">-</div>` ~\n\t\t\t\t`<div class=\"lastpost-author\">-</div>` ~\n\t\t\t\t`</div>`);\n\t\t}\n\t\thtml.put(`</div>`);\n\t}\n\t\n\thtml.put(`<div class=\"viewmode-narrow-index\">` ~\n\t\t`<div class=\"group-index-header\">` ~\n\t\t`<div class=\"title\">`);\n\thtml.putEncodedEntities(groupInfo.publicName);\n\thtml.put(`</div>` ~\n\t\t`<div class=\"create-thread\">`);\n\thtml.put(`<a class=\"button\" href=\"/newpost/`);\n\thtml.putEncodedEntities(groupInfo.urlName);\n\thtml.put(`\">` ~\n\t\t`<img src=\"`, staticPath(\"/images/newthread.png\"), `\"> `);\n\thtml.put(_!`Create thread`);\n\thtml.put(`</a>` ~\n\t\t`</div>` ~\n\t\t`</div>`);\n\t\n\thtml.put(`<div class=\"group-index\">`);\n\tforeach (thread; threads)\n\t\tif (thread.firstPost)\n\t\t\tsummarizeThread(thread);\n\thtml.put(`</div>`);\n\t\n\tthreadPager(groupInfo, page);\n\thtml.put(`</div>`);\n}\n\n// ***********************************************************************\n\nvoid discussionGroupThreaded(GroupInfo groupInfo, int page, bool narrow = false)\n{\n\tenforce(page >= 1, _!\"Invalid page\");\n\n\t//foreach (string threadID; query!\"SELECT `ID` FROM `Threads` WHERE `Group` = ? ORDER BY `LastUpdated` DESC LIMIT ? OFFSET ?\".iterate(group, THREADS_PER_PAGE, (page-1)*THREADS_PER_PAGE))\n\t//\tforeach (string id, string parent, string author, string subject, long stdTime; query!\"SELECT `ID`, `ParentID`, `Author`, `Subject`, `Time` FROM `Posts` WHERE `ThreadID` = ?\".iterate(threadID))\n\tPostInfo*[] posts;\n\tenum ViewSQL = \"SELECT `ROWID`, `ID`, `ParentID`, `Author`, `AuthorEmail`, `Subject`, `Time` FROM `Posts` WHERE `ThreadID` IN (SELECT `ID` FROM `Threads` WHERE `Group` = ? ORDER BY `LastUpdated` DESC LIMIT ? OFFSET ?)\";\n\tforeach (int rowid, string id, string parent, string author, string authorEmail, string subject, long stdTime; query!ViewSQL.iterate(groupInfo.internalName, THREADS_PER_PAGE, getPageOffset(page, THREADS_PER_PAGE)))\n\t\tposts ~= [PostInfo(rowid, id, null, parent, author, authorEmail, subject, SysTime(stdTime, UTC()))].ptr; // TODO: optimize?\n\n\thtml.put(\n\t\t`<table id=\"group-index-threaded\" class=\"forum-table group-wrapper viewmode-`), html.putEncodedEntities(userSettings.groupViewMode), html.put(`\">` ~\n\t\t`<tr class=\"group-index-header\"><th><div>`), newPostButton(groupInfo), html.putEncodedEntities(groupInfo.publicName), html.put(`</div></th></tr>`,\n\t//\t`<tr class=\"group-index-captions\"><th>Subject / Author</th><th>Time</th>`,\n\t\t`<tr><td class=\"group-threads-cell\"><div class=\"group-threads\"><table>`);\n\tformatThreadedPosts(posts, narrow);\n\thtml.put(`</table></div></td></tr>`);\n\tthreadPager(groupInfo, page, narrow ? 25 : 50);\n\thtml.put(`</table>`);\n}\n\nvoid discussionGroupSplit(GroupInfo groupInfo, int page)\n{\n\thtml.put(\n\t\t`<table id=\"group-split\"><tr>` ~\n\t\t`<td id=\"group-split-list\"><div>`);\n\tdiscussionGroupThreaded(groupInfo, page, true);\n\thtml.put(\n\t\t`</div></td>` ~\n\t\t`<td id=\"group-split-message\" class=\"group-split-message-none\"><span>`,\n\t\t\t_!`Loading...`,\n\t\t\t`<div class=\"nojs\">`, _!`Sorry, this view requires JavaScript.`, `</div>` ~\n\t\t`</span></td>` ~\n\t\t`</tr></table>`);\n}\n\nvoid discussionGroupSplitFromPost(string id, out GroupInfo groupInfo, out int page, out string threadID)\n{\n\tauto post = getPost(id);\n\tenforce(post, _!\"Post not found\");\n\n\tgroupInfo = post.getGroup();\n\tenforce(groupInfo, _!\"Unknown group:\" ~ \" \" ~ post.where);\n\tthreadID = post.cachedThreadID;\n\tpage = getThreadPage(groupInfo, threadID);\n\n\tdiscussionGroupSplit(groupInfo, page);\n}\n\nint getThreadPage(GroupInfo groupInfo, string thread)\n{\n\tint page = 0;\n\n\tforeach (long time; query!\"SELECT `LastUpdated` FROM `Threads` WHERE `ID` = ? LIMIT 1\".iterate(thread))\n\t\tforeach (int threadIndex; query!\"SELECT COUNT(*) FROM `Threads` WHERE `Group` = ? AND `LastUpdated` > ? ORDER BY `LastUpdated` DESC\".iterate(groupInfo.internalName, time))\n\t\t\tpage = indexToPage(threadIndex, THREADS_PER_PAGE);\n\n\tenforce(page > 0, _!\"Can't find thread's page\");\n\treturn page;\n}\n\n// ***********************************************************************\n\nvoid formatVSplitPosts(PostInfo*[] postInfos, string selectedID = null)\n{\n/*\n\thtml.put(\n\t\t`<tr class=\"thread-post-row\">` ~\n\t\t\t`<th>Subject</th>` ~\n\t\t\t`<th>From</th>` ~\n\t\t`</tr>`\n\t);\n*/\n\n\tforeach (postInfo; postInfos)\n\t{\n\t\thtml.put(\n\t\t\t`<tr class=\"thread-post-row`, (postInfo && postInfo.id==selectedID ? ` focused selected` : ``), `\">` ~\n\t\t\t\t`<td>` ~\n\t\t\t\t\t`<a class=\"postlink `, (user.isRead(postInfo.rowid) ? \"forum-read\" : \"forum-unread\" ), `\" ` ~\n\t\t\t\t\t\t`href=\"`), html.putEncodedEntities(idToUrl(postInfo.id)), html.put(`\">`\n\t\t\t\t\t\t), html.putEncodedEntities(postInfo.subject), html.put(\n\t\t\t\t\t`</a>` ~\n\t\t\t\t`</td>` ~\n\t\t\t\t`<td>` ~\n\t\t\t\t\t`<a href=\"`, profileUrl(postInfo.author, postInfo.authorEmail), `\">`\n\t\t\t\t\t), html.putEncodedEntities(postInfo.author), html.put(`</a>` ~\n\t\t\t\t`</td>` ~\n\t\t\t\t`<td>` ~\n\t\t\t\t\t`<div class=\"thread-post-time\">`, summarizeTime(postInfo.time, true), `</div>`,\n\t\t\t\t`</td>` ~\n\t\t\t`</tr>`\n\t\t);\n\t}\n}\n\nenum POSTS_PER_GROUP_PAGE = 100;\n\nvoid discussionGroupVSplitList(GroupInfo groupInfo, int page)\n{\n\tenum postsPerPage = POSTS_PER_GROUP_PAGE;\n\tenforce(page >= 1, _!\"Invalid page\");\n\n\t//foreach (string threadID; query!\"SELECT `ID` FROM `Threads` WHERE `Group` = ? ORDER BY `LastUpdated` DESC LIMIT ? OFFSET ?\".iterate(group, THREADS_PER_PAGE, (page-1)*THREADS_PER_PAGE))\n\t//\tforeach (string id, string parent, string author, string subject, long stdTime; query!\"SELECT `ID`, `ParentID`, `Author`, `Subject`, `Time` FROM `Posts` WHERE `ThreadID` = ?\".iterate(threadID))\n\tPostInfo*[] posts;\n\t//enum ViewSQL = \"SELECT `ROWID`, `ID`, `ParentID`, `Author`, `AuthorEmail`, `Subject`, `Time` FROM `Posts` WHERE `ThreadID` IN (SELECT `ID` FROM `Threads` WHERE `Group` = ?) ORDER BY `Time` DESC LIMIT ? OFFSET ?\";\n\t//enum ViewSQL = \"SELECT [Posts].[ROWID], [Posts].[ID], `ParentID`, `Author`, `AuthorEmail`, `Subject`, `Time` FROM `Posts` \"\n\t//\t\"INNER JOIN [Threads] ON `ThreadID`==[Threads].[ID] WHERE `Group` = ? ORDER BY `Time` DESC LIMIT ? OFFSET ?\";\n\tenum ViewSQL = \"SELECT [Posts].[ROWID], [Posts].[ID], [ParentID], [Author], [AuthorEmail], [Subject], [Posts].[Time] FROM [Groups] \" ~\n\t\t\"INNER JOIN [Posts] ON [Posts].[ID]==[Groups].[ID] WHERE [Group] = ? ORDER BY [Groups].[Time] DESC LIMIT ? OFFSET ?\";\n\tforeach (int rowid, string id, string parent, string author, string authorEmail, string subject, long stdTime; query!ViewSQL.iterate(groupInfo.internalName, postsPerPage, getPageOffset(page, postsPerPage)))\n\t\tposts ~= [PostInfo(rowid, id, null, parent, author, authorEmail, subject, SysTime(stdTime, UTC()))].ptr; // TODO: optimize?\n\tposts.reverse();\n\n\thtml.put(\n\t\t`<table id=\"group-index-vsplit\" class=\"forum-table group-wrapper viewmode-`), html.putEncodedEntities(userSettings.groupViewMode), html.put(`\">` ~\n\t\t`<tr class=\"group-index-header\"><th><div>`), newPostButton(groupInfo), html.putEncodedEntities(groupInfo.publicName), html.put(`</div></th></tr>`,\n\t//\t`<tr class=\"group-index-captions\"><th>Subject / Author</th><th>Time</th>`,\n\t\t`<tr><td class=\"group-threads-cell\"><div class=\"group-threads\"><table id=\"group-posts-vsplit\">` ~\n\t\t`<tr class=\"table-fixed-dummy\">`, `<td></td>`.replicate(3), `</tr>` // Fixed layout dummies\n\t);\n\tformatVSplitPosts(posts);\n\thtml.put(`</table></div></td></tr>`);\n\tgroupPostPager(groupInfo, page);\n\thtml.put(`</table>`);\n}\n\nvoid discussionGroupVSplit(GroupInfo groupInfo, int page)\n{\n\thtml.put(\n\t\t`<table id=\"group-vsplit\"><tr>` ~\n\t\t`<td id=\"group-vsplit-list\"><div>`);\n\tdiscussionGroupVSplitList(groupInfo, page);\n\thtml.put(\n\t\t`</div></td></tr>` ~\n\t\t`<tr><td id=\"group-split-message\" class=\"group-split-message-none\">`,\n\t\t\t_!`Loading...`,\n\t\t\t`<div class=\"nojs\">`, _!`Sorry, this view requires JavaScript.`, `</div>` ~\n\t\t`</td>` ~\n\t\t`</tr></table>`);\n}\n\nint getVSplitPostPage(GroupInfo groupInfo, string id)\n{\n\tint page = 0;\n\n\tforeach (long time; query!\"SELECT [Time] FROM [Groups] WHERE [ID] = ? LIMIT 1\".iterate(id))\n\t\tforeach (int threadIndex; query!\"SELECT COUNT(*) FROM [Groups] WHERE [Group] = ? AND [Time] > ? ORDER BY [Time] DESC\".iterate(groupInfo.internalName, time))\n\t\t\tpage = indexToPage(threadIndex, POSTS_PER_GROUP_PAGE);\n\n\tenforce(page > 0, _!\"Can't find post's page\");\n\treturn page;\n}\n\nvoid discussionGroupVSplitFromPost(string id, out GroupInfo groupInfo, out int page, out string threadID)\n{\n\tauto post = getPost(id);\n\tenforce(post, _!\"Post not found\");\n\n\tgroupInfo = post.getGroup();\n\tthreadID = post.cachedThreadID;\n\tpage = getVSplitPostPage(groupInfo, id);\n\n\tdiscussionGroupVSplit(groupInfo, page);\n}\n\nvoid groupPostPager(GroupInfo groupInfo, int page)\n{\n\tauto postCounts = postCountCache(getPostCounts());\n\tauto postCount = postCounts.get(groupInfo.internalName, 0);\n\tauto pageCount = getPageCount(postCount, POSTS_PER_GROUP_PAGE);\n\n\tpager(`/group/` ~ groupInfo.urlName, page, pageCount, 50);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/index.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Front page.\nmodule dfeed.web.web.view.index;\n\nimport core.time;\n\nimport std.array : split, replicate;\nimport std.conv : to, text;\nimport std.datetime.systime : Clock, SysTime;\nimport std.format : format;\nimport std.random : uniform;\n\nimport ae.net.ietf.url : encodeUrlParameter;\nimport ae.utils.meta;\nimport ae.utils.text.html : encodeHtmlEntities;\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.loc;\nimport dfeed.database;\nimport dfeed.message;\nimport dfeed.groups;\nimport dfeed.sinks.cache;\nimport dfeed.sinks.subscriptions;\nimport dfeed.site : site;\nimport dfeed.web.web.cache;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.part.strings : formatNumber, formatDuration, summarizeTime;\nimport dfeed.web.web.perf;\nimport dfeed.web.web.postinfo : getPostInfo;\nimport dfeed.web.web.user : user, userSettings;\n\nCached!int totalPostCountCache, totalThreadCountCache;\n\nvoid discussionIndexHeader()\n{\n\tauto now = Clock.currTime();\n\tif (now - SysTime(userSettings.sessionCanary.to!long) > 4.hours)\n\t{\n\t\tuserSettings.previousSession = userSettings.currentSession;\n\t\tuserSettings.currentSession = userSettings.sessionCanary = now.stdTime.text;\n\t}\n\tlong previousSession = userSettings.previousSession.to!long;\n\n\tstring name = user.isLoggedIn() ? user.getName() : userSettings.name.length ? userSettings.name.split(' ')[0] : _!`Guest`;\n\thtml.put(\n\t\t`<div id=\"forum-index-header\">` ~\n\t\t`<h1>`), html.putEncodedEntities(site.name), html.put(`</h1>` ~\n\t\t`<p>`, previousSession ? _!`Welcome back,` : _!`Welcome,`, ` `), html.putEncodedEntities(name), html.put(`.</p>` ~\n\n\t\t`<ul>`\n\t);\n\n\tstring[][3] bits;\n\n\tif (user.isLoggedIn())\n\t{\n\t\tauto subscriptions = getUserSubscriptions(user.getName());\n\t\tint numSubscriptions, numNewSubscriptions;\n\t\tforeach (subscription; subscriptions)\n\t\t{\n\t\t\tauto c = subscription.getUnreadCount();\n\t\t\tif (subscription.trigger.type == \"reply\")\n\t\t\t\tif (c)\n\t\t\t\t\tbits[0] ~= `<li><b>` ~\n\t\t\t\t\t\t_!`You have %d %s to %syour posts%s.`.format(\n\t\t\t\t\t\t\tc,\n\t\t\t\t\t\t\t`<a href=\"/subscription-posts/` ~ encodeHtmlEntities(subscription.id) ~ `\">` ~ plural!`new reply`(c) ~ `</a>`,\n\t\t\t\t\t\t\t`<a href=\"/search?q=authoremail:%s\">`.format(encodeHtmlEntities(encodeUrlParameter(userSettings.email))),\n\t\t\t\t\t\t\t`</a>`,\n\t\t\t\t\t\t) ~ `</b></li>`;\n\t\t\t\telse\n\t\t\t\t\tbits[2] ~= `<li>` ~\n\t\t\t\t\t\t_!`No new %sreplies%s to %syour posts%s.`\n\t\t\t\t\t\t.format(\n\t\t\t\t\t\t\t`<a href=\"/subscription-posts/%s\">`.format(encodeHtmlEntities(subscription.id)),\n\t\t\t\t\t\t\t`</a>`,\n\t\t\t\t\t\t\t`<a href=\"/search?q=authoremail:%s\">`.format(encodeHtmlEntities(encodeUrlParameter(userSettings.email))),\n\t\t\t\t\t\t\t`</a>`,\n\t\t\t\t\t\t) ~ `</li>`;\n\t\t\telse\n\t\t\t{\n\t\t\t\tnumSubscriptions++;\n\t\t\t\tif (c)\n\t\t\t\t{\n\t\t\t\t\tnumNewSubscriptions++;\n\t\t\t\t\tbits[1] ~= `<li><b>` ~\n\t\t\t\t\t\t_!`You have %d %s matching your %s%s subscription%s (%s).`.format(\n\t\t\t\t\t\t\tc,\n\t\t\t\t\t\t\t`<a href=\"/subscription-posts/` ~ encodeHtmlEntities(subscription.id) ~ `\">` ~ plural!`unread post`(c) ~ `</a>`,\n\t\t\t\t\t\t\t`<a href=\"/settings#subscriptions\">`,\n\t\t\t\t\t\t\tsubscription.trigger.typeName,\n\t\t\t\t\t\t\t`</a>`,\n\t\t\t\t\t\t\tsubscription.trigger.getDescription(),\n\t\t\t\t\t\t) ~ `</b></li>`;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif (numSubscriptions && !numNewSubscriptions)\n\t\t\tbits[2] ~= `<li>` ~\n\t\t\t\t_!`No new posts matching your %s%s%s.`.format(\n\t\t\t\t\t`<a href=\"/settings#subscriptions\">`,\n\t\t\t\t\tplural!\"subscription\"(numSubscriptions),\n\t\t\t\t\t`</a>`,\n\t\t\t\t) ~ `</li>`;\n\t}\n\telse\n\t{\n\t\tint hasPosts = 0;\n\t\tif (userSettings.email)\n\t\t\thasPosts = query!\"SELECT EXISTS(SELECT 1 FROM [Posts] WHERE [AuthorEmail] = ? LIMIT 1)\".iterate(userSettings.email).selectValue!int;\n\t\tif (hasPosts)\n\t\t\tbits[2] ~= `<li>` ~\n\t\t\t\t_!`If you %screate an account%s, you can track replies to %syour posts%s.`\n\t\t\t\t.format(\n\t\t\t\t\t`<a href=\"/register\">`,\n\t\t\t\t\t`</a>`,\n\t\t\t\t\t`<a href=\"/search?q=authoremail:%s\">`.format(encodeHtmlEntities(encodeUrlParameter(userSettings.email))),\n\t\t\t\t\t`</a>`,\n\t\t\t\t) ~ `</li>`;\n\t\telse\n\t\t\tbits[0] ~= `<li>` ~\n\t\t\t\t_!`You can read and post on this forum without %screating an account%s, but creating an account offers %sa few benefits%s.`\n\t\t\t\t.format(\n\t\t\t\t\t`<a href=\"/register\">`,\n\t\t\t\t\t`</a>`,\n\t\t\t\t\t`<a href=\"/help#accounts\">`,\n\t\t\t\t\t`</a>`,\n\t\t\t\t) ~ `</li>`;\n\t}\n\n\tSysTime cutOff = previousSession ? SysTime(previousSession) : now - 24.hours;\n\tint numThreads = query!\"SELECT COUNT(*)                      FROM [Threads] WHERE [Created] >= ?\".iterate(cutOff.stdTime).selectValue!int;\n\tint numPosts   = query!\"SELECT COUNT(*)                      FROM [Posts]   WHERE [Time]    >= ?\".iterate(cutOff.stdTime).selectValue!int;\n\tint numUsers   = query!\"SELECT COUNT(DISTINCT [AuthorEmail]) FROM [Posts] INDEXED BY [PostTimeAuthorEmail] WHERE [Time] >= ?\".iterate(cutOff.stdTime).selectValue!int;\n\n\tbits[(numThreads || numPosts) ? 1 : 2] ~=\n\t\t\"<li>\"\n\t\t~\n\t\t(\n\t\t\t(numThreads || numPosts)\n\t\t\t?\n\t\t\t\t_!\"%d %s %-(%s and %)\"\n\t\t\t\t.format(\n\t\t\t\t\tnumUsers,\n\t\t\t\t\tplural!`user has created`(numUsers),\n\t\t\t\t\t(numThreads ? [`<a href=\"/search?q=time:%d..+newthread:y\">%s %s</a>`.format(cutOff.stdTime, formatNumber(numThreads), plural!\"thread\"(numThreads))] : [])\n\t\t\t\t\t~\n\t\t\t\t\t(numPosts   ? [`<a href=\"/search?q=time:%d..\">%s %s</a>`            .format(cutOff.stdTime, formatNumber(numPosts  ), plural!\"post\"  (numPosts  ))] : [])\n\t\t\t\t)\n\t\t\t:\n\t\t\t\t_!\"No new forum activity\"\n\t\t)\n\t\t~ \" \" ~\n\t\t(\n\t\t\tpreviousSession\n\t\t\t?\n\t\t\t\t_!\"since your last visit (%s).\".format(formatDuration(now - cutOff))\n\t\t\t:\n\t\t\t\t_!\"in the last 24 hours.\"\n\t\t)\n\t\t~\n\t\t\"</li>\"\n\t;\n\n\tauto totalPosts   = totalPostCountCache  (query!\"SELECT Max([RowID]) FROM [Posts]\"  .iterate().selectValue!int);\n\tauto totalThreads = totalThreadCountCache(query!\"SELECT Max([RowID]) FROM [Threads]\".iterate().selectValue!int);\n\tauto totalUsers   =                       query!\"SELECT Max([RowID]) FROM [Users]\"  .iterate().selectValue!int ;\n\tbits[2] ~= \"<li>\" ~\n\t\t_!`There are %s %s, %s %s, and %s %s on this forum.`\n\t\t.format(\n\t\t\tformatNumber(totalPosts)  , plural!\"post\"           (totalPosts  ),\n\t\t\tformatNumber(totalThreads), plural!\"thread\"         (totalThreads),\n\t\t\tformatNumber(totalUsers)  , plural!\"registered user\"(totalUsers  ),\n\t\t)\n\t\t~ \"</li>\";\n\n\tauto numRead = user.countRead();\n\tif (numRead)\n\t\tbits[2] ~= \"<li>\" ~\n\t\t\t_!`You have read a total of %s %s during your %s.`.format(\n\t\t\t\tformatNumber(numRead), plural!\"forum post\"(numRead),\n\t\t\t\tplural!\"visit\"(previousSession ? pluralMany : 1),\n\t\t\t) ~ \"</li>\";\n\n\tbits[2] ~= \"<li>\" ~ _!\"Random tip:\" ~ \" \" ~ randomTip() ~ \"</li>\";\n\n\tforeach (bitGroup; bits[])\n\t\tforeach (bit; bitGroup)\n\t\t\thtml.put(bit);\n\thtml.put(\n\t\t`</ul>` ~\n\t\t`</div>`\n\t);\n\n\t//html.put(\"<p>Random tip: \" ~ tips[uniform(0, $)] ~ \"</p>\");\n}\n\nbool hasAlsoVia()\n{\n\timport std.algorithm.searching : any;\n\treturn groupHierarchy.any!(set => set.groups.any!(group => group.alsoVia.length > 0));\n}\n\nstring randomTip()\n{\n\tstatic immutable string[] defaultTips =\n\t[\n\t\t`This forum has several different <a href=\"/help#view-modes\">view modes</a>. Try them to find one you like best. You can change the view mode in the <a href=\"/settings\">settings</a>.`,\n\t\t`This forum supports <a href=\"/help#keynav\">keyboard shortcuts</a>. Press <kbd>?</kbd> to view them.`,\n\t\t`You can focus a message with <kbd>j</kbd>/<kbd>k</kbd> and press <kbd>u</kbd> to mark it as unread, to remind you to read it later.`,\n\t\t`The <a href=\"/help#avatars\">avatars on this forum</a> are provided by Gravatar, which allows associating a global avatar with an email address.`,\n\t\t`This forum remembers your read post history on a per-post basis. If you are logged in, the post history is saved on the server, and in a compressed cookie otherwise.`,\n\t\t`If you create a Gravatar profile with the email address you post with, it will be accessible when clicking your avatar.`,\n\t//\t`You don't need to create an account to post on this forum, but doing so <a href=\"/help#accounts\">offers a few benefits</a>.`,\n\t\t`To subscribe to a thread, click the \"Subscribe\" link on that thread's first post. You need to be logged in to create subscriptions.`,\n\t\t`To search the forum, use the search widget at the top, or you can visit <a href=\"/search\">the search page</a> directly.`,\n\t\t`This forum is open-source! Read or fork the code <a href=\"https://github.com/CyberShadow/DFeed\">on GitHub</a>.`,\n\t\t`If you encounter a bug or need a missing feature, you can <a href=\"https://github.com/CyberShadow/DFeed/issues\">create an issue on GitHub</a>.`,\n\t];\n\tstatic immutable alsoViaTip = `Much of this forum's content is also available via classic mailing lists or NNTP - see the \"Also via\" column on the forum index.`;\n\n\timmutable numTips = defaultTips.length + (hasAlsoVia() ? 1 : 0);\n\tauto index = uniform(0, numTips);\n\tif (index < defaultTips.length)\n\t{\n\t\tfinal switch (index)\n\t\t{\n\t\t\tforeach (n; RangeTuple!(defaultTips.length))\n\t\t\t\tcase n:\n\t\t\t\t\treturn _!(defaultTips[n]);\n\t\t}\n\t}\n\telse\n\t\treturn _!alsoViaTip;\n}\n\nstring[string] getLastPosts()\n{\n\tenum PERF_SCOPE = \"getLastPosts\"; mixin(MeasurePerformanceMixin);\n\tstring[string] lastPosts;\n\tforeach (set; groupHierarchy)\n\t\tforeach (group; set.groups)\n\t\t\tforeach (string id; query!\"SELECT `ID` FROM `Groups` WHERE `Group`=? ORDER BY `Time` DESC LIMIT 1\".iterate(group.internalName))\n\t\t\t\tlastPosts[group.internalName] = id;\n\treturn lastPosts;\n}\n\nCached!(string[string]) lastPostCache;\n\nvoid discussionIndex()\n{\n\tdiscussionIndexHeader();\n\n\tauto threadCounts = threadCountCache(getThreadCounts());\n\tauto postCounts = postCountCache(getPostCounts());\n\tauto lastPosts = lastPostCache(getLastPosts());\n\n\tstring summarizePost(string postID)\n\t{\n\t\tauto info = getPostInfo(postID);\n\t\tif (info)\n\t\t\twith (*info)\n\t\t\t\treturn\n\t\t\t\t\t`<div class=\"truncated\"><a class=\"forum-postsummary-subject ` ~ (user.isRead(rowid) ? \"forum-read\" : \"forum-unread\") ~ `\" href=\"` ~ encodeHtmlEntities(idToUrl(id)) ~ `\" title=\"` ~ encodeHtmlEntities(subject) ~ `\">` ~ encodeHtmlEntities(subject) ~ `</a></div>` ~\n\t\t\t\t\t`<div class=\"truncated\">` ~ _!`by` ~ ` <span class=\"forum-postsummary-author\" title=\"` ~ encodeHtmlEntities(author) ~ `\">` ~ encodeHtmlEntities(author) ~ `</span></div>` ~\n\t\t\t\t\t`<span class=\"forum-postsummary-time\">` ~ summarizeTime(time) ~ `</span>`;\n\n\t\treturn `<div class=\"forum-no-data\">-</div>`;\n\t}\n\thtml.put(\n\t\t`<table id=\"forum-index\" class=\"forum-table\">` ~\n\t\t`<tr class=\"table-fixed-dummy\">`, `<td></td>`.replicate(5), `</tr>` // Fixed layout dummies\n\t);\n\tforeach (set; groupHierarchy)\n\t{\n\t\tif (!set.visible)\n\t\t\tcontinue;\n\n\t\thtml.put(\n\t\t\t`<tr><th colspan=\"5\">`), html.putEncodedEntities(set.name), html.put(`</th></tr>` ~\n\t\t\t`<tr class=\"subheader\"><th>`, _!`Group`, `</th><th>`, _!`Last Post`, `</th><th>`, _!`Threads`, `</th><th>`, _!`Posts`, `</th><th>`, _!`Also via`, `</th></tr>`\n\t\t);\n\t\tforeach (group; set.groups)\n\t\t{\n\t\t\thtml.put(\n\t\t\t\t`<tr class=\"group-row\">` ~\n\t\t\t\t\t`<td class=\"forum-index-col-forum\">` ~\n\t\t\t\t\t\t`<a href=\"/group/`), html.putEncodedEntities(group.urlName), html.put(`\">`), html.putEncodedEntities(group.publicName), html.put(`</a>` ~\n\t\t\t\t\t\t`<div class=\"forum-index-description\" title=\"`), html.putEncodedEntities(group.description), html.put(`\">`), html.putEncodedEntities(group.description), html.put(`</div>` ~\n\t\t\t\t\t`</td>` ~\n\t\t\t\t\t`<td class=\"forum-index-col-lastpost\">`, group.internalName in lastPosts    ? summarizePost(   lastPosts[group.internalName]) : `<div class=\"forum-no-data\">-</div>`, `</td>` ~\n\t\t\t\t\t`<td class=\"number-column\">`,            group.internalName in threadCounts ? formatNumber (threadCounts[group.internalName]) : `-`, `</td>` ~\n\t\t\t\t\t`<td class=\"number-column\">`,            group.internalName in postCounts   ? formatNumber (  postCounts[group.internalName]) : `-`, `</td>` ~\n\t\t\t\t\t`<td class=\"number-column\">`\n\t\t\t);\n\t\t\tforeach (i, av; group.alsoVia.values)\n\t\t\t\thtml.put(i ? `<br>` : ``, `<a href=\"`, av.url, `\">`, av.name, `</a>`);\n\t\t\thtml.put(\n\t\t\t\t\t`</td>` ~\n\t\t\t\t`</tr>`,\n\t\t\t);\n\t\t}\n\t}\n\thtml.put(`</table>`);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/login.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Login and registration.\nmodule dfeed.web.web.view.login;\n\nimport std.exception : enforce;\n\nimport ae.net.ietf.url : UrlParameters, encodeUrlParameter;\nimport ae.utils.aa : aaGet;\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.loc;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.user : user;\n\nvoid discussionLoginForm(UrlParameters parameters, string errorMessage = null)\n{\n\n\thtml.put(`<form action=\"/login\" method=\"post\" id=\"loginform\" class=\"forum-form loginform\">` ~\n\t\t`<table class=\"forum-table\">` ~\n\t\t\t`<tr><th>`, _!`Log in`, `</th></tr>` ~\n\t\t\t`<tr><td class=\"loginform-cell\">`);\n\n\tif (\"url\" in parameters)\n\t\thtml.put(`<input type=\"hidden\" name=\"url\" value=\"`), html.putEncodedEntities(parameters[\"url\"]), html.put(`\">`);\n\n\thtml.put(\n\t\t\t`<label for=\"loginform-username\">`, _!`Username:`, `</label>` ~\n\t\t\t`<input id=\"loginform-username\" name=\"username\" value=\"`), html.putEncodedEntities(parameters.get(\"username\", \"\")), html.put(`\" autofocus>` ~\n\t\t\t`<label for=\"loginform-password\">`, _!`Password:`, `</label>` ~\n\t\t\t`<input id=\"loginform-password\" type=\"password\" name=\"password\" value=\"`), html.putEncodedEntities(parameters.get(\"password\", \"\")), html.put(`\">` ~\n\t\t\t`<input id=\"loginform-remember\" type=\"checkbox\" name=\"remember\" `, \"username\" !in  parameters || \"remember\" in parameters ? ` checked` : ``, `>` ~\n\t\t\t`<label for=\"loginform-remember\"> `, _!`Remember me`, `</label>` ~\n\t\t\t`<input type=\"submit\" value=\"`, _!`Log in`, `\">` ~\n\t\t`</td></tr>`);\n\tif (errorMessage)\n\t\thtml.put(`<tr><td class=\"loginform-info\"><div class=\"form-error loginform-error\">`), html.putEncodedEntities(errorMessage), html.put(`</div></td></tr>`);\n\telse\n\t\thtml.put(\n\t\t\t`<tr><td class=\"loginform-info\">` ~\n\t\t\t\t`<a href=\"/registerform`,\n\t\t\t\t\t(\"url\" in parameters ? `?url=` ~ encodeUrlParameter(parameters[\"url\"]) : ``),\n\t\t\t\t\t`\">`, _!`Register`, `</a> `, _!`to keep your preferences<br>and read post history on the server.` ~\n\t\t\t`</td></tr>`);\n\thtml.put(`</table></form>`);\n}\n\nvoid discussionLogin(UrlParameters parameters)\n{\n\tuser.logIn(aaGet(parameters, \"username\"), aaGet(parameters, \"password\"), !!(\"remember\" in parameters));\n}\n\nvoid discussionRegisterForm(UrlParameters parameters, string errorMessage = null)\n{\n\thtml.put(`<form action=\"/register\" method=\"post\" id=\"registerform\" class=\"forum-form loginform\">` ~\n\t\t`<table class=\"forum-table\">` ~\n\t\t\t`<tr><th>`, _!`Register`, `</th></tr>` ~\n\t\t\t`<tr><td class=\"loginform-cell\">`);\n\n\tif (\"url\" in parameters)\n\t\thtml.put(`<input type=\"hidden\" name=\"url\" value=\"`), html.putEncodedEntities(parameters[\"url\"]), html.put(`\">`);\n\n\thtml.put(\n\t\t`<label for=\"loginform-username\">`, _!`Username:`, `</label>` ~\n\t\t`<input id=\"loginform-username\" name=\"username\" value=\"`), html.putEncodedEntities(parameters.get(\"username\", \"\")), html.put(`\" autofocus>` ~\n\t\t`<label for=\"loginform-password\">`, _!`Password:`, `</label>` ~\n\t\t`<input id=\"loginform-password\" type=\"password\" name=\"password\" value=\"`), html.putEncodedEntities(parameters.get(\"password\", \"\")), html.put(`\">` ~\n\t\t`<label for=\"loginform-password2\">`, _!`Confirm:`, `</label>` ~\n\t\t`<input id=\"loginform-password2\" type=\"password\" name=\"password2\" value=\"`), html.putEncodedEntities(parameters.get(\"password2\", \"\")), html.put(`\">` ~\n\t\t`<input id=\"loginform-remember\" type=\"checkbox\" name=\"remember\" `, \"username\" !in  parameters || \"remember\" in parameters ? ` checked` : ``, `>` ~\n\t\t`<label for=\"loginform-remember\"> `, _!`Remember me`, `</label>` ~\n\t\t`<input type=\"submit\" value=\"`, _!`Register`, `\">` ~\n\t\t`</td></tr>`);\n\tif (errorMessage)\n\t\thtml.put(`<tr><td class=\"loginform-info\"><div class=\"form-error loginform-error\">`), html.putEncodedEntities(errorMessage), html.put(`</div></td></tr>`);\n\telse\n\t\thtml.put(\n\t\t\t`<tr><td class=\"loginform-info\">` ~\n\t\t\t\t_!`Please pick your password carefully.` ~ `<br>` ~ _!`There are no password recovery options.` ~\n\t\t\t`</td></tr>`);\n\thtml.put(`</table></form>`);\n}\n\nvoid discussionRegister(UrlParameters parameters)\n{\n\tenforce(aaGet(parameters, \"password\") == aaGet(parameters, \"password2\"), _!\"Passwords do not match\");\n\tuser.register(aaGet(parameters, \"username\"), aaGet(parameters, \"password\"), !!(\"remember\" in parameters));\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/moderation.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2025, 2026  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Moderation views.\nmodule dfeed.web.web.view.moderation;\n\nimport std.algorithm.iteration : map, filter, uniq;\nimport std.algorithm.searching : canFind, findSplit;\nimport std.algorithm.sorting : sort;\nimport std.array : array, join, empty;\nimport std.conv : text;\nimport std.datetime.systime : Clock;\nimport std.exception : enforce;\nimport std.format : format;\nimport std.string : capitalize, strip;\nimport std.typecons : Yes, No;\n\nimport ae.net.ietf.url : UrlParameters;\nimport ae.utils.json : jsonParse;\nimport ae.utils.meta : identity;\nimport ae.utils.sini : loadIni;\nimport ae.utils.text : splitAsciiLines;\nimport ae.utils.text.html : encodeHtmlEntities;\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.database : query, selectValue;\nimport dfeed.groups : getGroupInfo;\nimport dfeed.loc;\nimport dfeed.mail : sendMail;\nimport dfeed.message : Rfc850Post, idToUrl;\nimport dfeed.paths : resolveSiteFile;\nimport dfeed.site : site;\nimport dfeed.sources.newsgroups : NntpConfig;\nimport dfeed.web.captcha.common : getCaptchaResponseFromField;\nimport dfeed.web.posting : PostDraft, PostProcess;\nimport dfeed.web.user : User;\nimport dfeed.web.web.draft : getDraft, draftToPost;\nimport dfeed.web.web.moderation : findPostingLog, moderatePost, approvePost, getUnbanPreviewByKey, unbanPoster, UnbanTree, DeletedPostInfo, findDeletedPostInfo;\nimport dfeed.web.web.page : html, Redirect;\nimport dfeed.web.web.part.post : formatPost;\nimport dfeed.web.web.posting : postDraft;\nimport dfeed.web.web.user : user, userSettings;\n\nstruct JourneyEvent\n{\n\tstring timestamp;\n\tstring type;      // \"captcha\", \"spam_check\", \"moderation\", \"posted\", \"info\", \"log_file\", \"approval\", \"page_visit\", \"referrer\"\n\tstring message;\n\tbool success;     // true for success, false for failure\n\tstring details;   // additional details like spamicity value\n\tstring sourceFile; // log file name\n\tint lineNumber;    // line number in log file (1-based)\n\tstring url;       // for page_visit events, the URL to link to\n}\n\nJourneyEvent[] parsePostingJourney(string messageID)\n{\n\timport std.file : exists, read, dirEntries, SpanMode;\n\timport std.algorithm : filter, startsWith, endsWith, map;\n\timport std.algorithm.mutation : reverse;\n\timport std.array : array;\n\timport std.string : split, indexOf;\n\timport std.regex : matchFirst;\n\timport std.process : execute;\n\timport std.path : baseName;\n\n\tJourneyEvent[] events;\n\n\t// Extract the post ID from message ID\n\tif (!messageID.startsWith(\"<\") || !messageID.endsWith(\">\"))\n\t\treturn events;\n\n\tauto messageIDclean = messageID[1..$-1];\n\tauto atPos = messageIDclean.indexOf(\"@\");\n\tif (atPos < 0)\n\t\treturn events;\n\n\tauto postID = messageIDclean[0..atPos];\n\tif (postID.length != 20)\n\t\treturn events;\n\n\t// Helper function to extract previous pid from log content (from [ServerVar] pid: xxx)\n\tstring extractPreviousPid(string content)\n\t{\n\t\tforeach (line; content.split(\"\\n\"))\n\t\t{\n\t\t\tif (line.indexOf(\"[ServerVar] pid:\") >= 0)\n\t\t\t{\n\t\t\t\tauto pidMatch = line.matchFirst(`\\[ServerVar\\] pid: ([a-z]{20})`);\n\t\t\t\tif (pidMatch)\n\t\t\t\t\treturn pidMatch[1];\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\t// Helper function to find log file for a given pid\n\tstring findLogForPid(string pid)\n\t{\n\t\tversion (Windows)\n\t\t{\n\t\t\tauto matches = dirEntries(\"logs\", \"*PostProcess-\" ~ pid ~ \".log\", SpanMode.depth).array;\n\t\t\treturn matches.length > 0 ? matches[0].name : null;\n\t\t}\n\t\telse\n\t\t{\n\t\t\tauto result = execute([\"find\", \"logs/\", \"-name\", \"*PostProcess-\" ~ pid ~ \".log\"]);\n\t\t\tif (result.status == 0)\n\t\t\t{\n\t\t\t\tauto files = result.output.split(\"\\n\").filter!(f => f.length > 0).array;\n\t\t\t\treturn files.length > 0 ? files[0] : null;\n\t\t\t}\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t// Track which log files are related and why\n\tstruct RelatedLog\n\t{\n\t\tstring file;\n\t\tstring evidence; // Why this log was included\n\t}\n\tRelatedLog[] relatedLogs;\n\n\t// Find the primary log and follow the pid chain backwards\n\tauto primaryLog = findLogForPid(postID);\n\tif (primaryLog is null)\n\t\treturn events;\n\n\t// Follow the chain of previous pids\n\tstring currentPid = postID;\n\tstring currentLog = primaryLog;\n\tstring nextPid = postID; // For building the \"leads to\" evidence\n\n\twhile (currentLog !is null)\n\t{\n\t\tif (!exists(currentLog))\n\t\t\tbreak;\n\n\t\tstring evidence;\n\t\tif (currentPid == postID)\n\t\t\tevidence = \"Primary log (post ID: \" ~ postID ~ \")\";\n\t\telse\n\t\t\tevidence = \"Previous attempt (retry led to: \" ~ nextPid ~ \")\";\n\n\t\trelatedLogs ~= RelatedLog(currentLog, evidence);\n\n\t\t// Look for previous pid in this log\n\t\tauto content = cast(string)read(currentLog);\n\t\tauto prevPid = extractPreviousPid(content);\n\n\t\tif (prevPid is null || prevPid == currentPid)\n\t\t\tbreak;\n\n\t\t// Find the log for the previous pid\n\t\tnextPid = currentPid;\n\t\tcurrentPid = prevPid;\n\t\tcurrentLog = findLogForPid(prevPid);\n\t}\n\n\t// Reverse so oldest attempt is first\n\trelatedLogs.reverse();\n\n\t// Search for approval events in the Banned log file (same date as the PostProcess log)\n\tvoid searchBannedLog(string pid, string postProcessLogFile)\n\t{\n\t\t// Replace \"PostProcess-xxx.log\" with \"Banned.log\" to get the Banned log for the same date\n\t\tauto bannedLogFile = postProcessLogFile.matchFirst(`^(.* - )PostProcess-[a-z]+\\.log$`);\n\t\tif (!bannedLogFile)\n\t\t\treturn;\n\n\t\tauto logPath = bannedLogFile[1] ~ \"Banned.log\";\n\t\tif (!exists(logPath))\n\t\t\treturn;\n\n\t\tauto content = cast(string)read(logPath);\n\t\tauto logFileName = baseName(logPath);\n\t\tint lineNum = 0;\n\n\t\tforeach (line; content.split(\"\\n\"))\n\t\t{\n\t\t\tlineNum++;\n\t\t\tif (line.length < 30 || line[0] != '[')\n\t\t\t\tcontinue;\n\n\t\t\t// Look for approval entries containing our post ID\n\t\t\tauto approvalMatch = line.matchFirst(`\\[([^\\]]+)\\] User ([^ ]+) is approving draft ([^ ]+) \\(post ` ~ pid ~ `\\) titled \"(.*)\" by \"(.*)\"`);\n\t\t\tif (approvalMatch)\n\t\t\t{\n\t\t\t\tauto timestamp = approvalMatch[1];\n\t\t\t\tauto moderator = approvalMatch[2];\n\t\t\t\tevents ~= JourneyEvent(timestamp, \"approval\", \"Approved by moderator\", true,\n\t\t\t\t\t\"Moderator: \" ~ moderator, logFileName, lineNum);\n\t\t\t\treturn; // Found it\n\t\t\t}\n\t\t}\n\t}\n\n\t// Parse Web.log for page visits matching IP and User-Agent\n\t// Returns the web events separately (not added to main events array)\n\tJourneyEvent[] parseWebLog(string postProcessLogFile, string ip, string userAgent)\n\t{\n\t\tJourneyEvent[] webEvents;\n\n\t\tif (ip.length == 0)\n\t\t\treturn webEvents;\n\n\t\t// Replace \"PostProcess-xxx.log\" with \"Web.log\" to get the Web log for the same date\n\t\tauto webLogFile = postProcessLogFile.matchFirst(`^(.* - )PostProcess-[a-z]+\\.log$`);\n\t\tif (!webLogFile)\n\t\t\treturn webEvents;\n\n\t\tauto logPath = webLogFile[1] ~ \"Web.log\";\n\t\tif (!exists(logPath))\n\t\t\treturn webEvents;\n\n\t\tauto content = cast(string)read(logPath);\n\t\tauto logFileName = baseName(logPath);\n\t\tint lineNum = 0;\n\n\t\t// Track referrers we've already added to avoid duplicates\n\t\tbool[string] seenReferrers;\n\n\t\tforeach (line; content.split(\"\\n\"))\n\t\t{\n\t\t\tlineNum++;\n\t\t\tif (line.length < 30 || line[0] != '[')\n\t\t\t\tcontinue;\n\n\t\t\t// Parse log line: [timestamp] \\tIP\\tSTATUS\\tTIME\\tMETHOD\\tURL\\tCONTENT-TYPE[\\tREFERER\\tUSER-AGENT]\n\t\t\tauto closeBracket = line.indexOf(\"]\");\n\t\t\tif (closeBracket < 0)\n\t\t\t\tcontinue;\n\t\t\tauto timestamp = line[1..closeBracket];\n\t\t\tauto rest = line[closeBracket + 2 .. $];\n\n\t\t\tauto fields = rest.split(\"\\t\");\n\t\t\tif (fields.length < 7)\n\t\t\t\tcontinue;\n\n\t\t\t// Field indices (first field is empty for alignment):\n\t\t\t// [0]=empty, [1]=IP, [2]=STATUS, [3]=TIME, [4]=METHOD, [5]=URL, [6]=CONTENT-TYPE, [7]=REFERER, [8]=USER-AGENT\n\t\t\tauto logIP = fields[1];\n\t\t\tauto status = fields[2];\n\t\t\tauto method = fields[4];\n\t\t\tauto url = fields[5];\n\t\t\tauto contentType = fields[6];\n\t\t\tstring referer = fields.length > 7 ? fields[7] : \"-\";\n\t\t\tstring logUserAgent = fields.length > 8 ? fields[8] : \"\";\n\n\t\t\t// Check if this matches our user's IP\n\t\t\tif (logIP != ip)\n\t\t\t\tcontinue;\n\n\t\t\t// If we have a User-Agent to match, check it (but don't require it)\n\t\t\tif (userAgent.length > 0 && logUserAgent.length > 0 && logUserAgent != userAgent)\n\t\t\t\tcontinue;\n\n\t\t\t// Only interested in text/html pages (GET and POST requests)\n\t\t\t// Also include POST redirects (3xx status with no content type)\n\t\t\tif (method != \"GET\" && method != \"POST\")\n\t\t\t\tcontinue;\n\t\t\tbool isRedirect = status.length >= 1 && status[0] == '3';\n\t\t\tif (!contentType.startsWith(\"text/html\") && !(method == \"POST\" && isRedirect))\n\t\t\t\tcontinue;\n\n\t\t\t// Skip static resources\n\t\t\tif (url.canFind(\"/static/\"))\n\t\t\t\tcontinue;\n\n\t\t\t// Extract just the path from the URL for display\n\t\t\tstring displayPath = url;\n\t\t\tauto hostEnd = url.indexOf(\"://\");\n\t\t\tif (hostEnd >= 0)\n\t\t\t{\n\t\t\t\tauto pathStart = url.indexOf(\"/\", hostEnd + 3);\n\t\t\t\tif (pathStart >= 0)\n\t\t\t\t\tdisplayPath = url[pathStart .. $];\n\t\t\t}\n\n\t\t\t// Check for external referrer (not from same site)\n\t\t\tif (referer != \"-\" && referer.length > 0)\n\t\t\t{\n\t\t\t\t// Check if referrer is external (doesn't contain our host)\n\t\t\t\tauto urlHost = url.indexOf(\"://\");\n\t\t\t\tstring ourHost;\n\t\t\t\tif (urlHost >= 0)\n\t\t\t\t{\n\t\t\t\t\tauto hostStart = urlHost + 3;\n\t\t\t\t\tauto hostEndPos = url.indexOf(\"/\", hostStart);\n\t\t\t\t\tourHost = hostEndPos >= 0 ? url[hostStart .. hostEndPos] : url[hostStart .. $];\n\t\t\t\t}\n\n\t\t\t\tbool isExternal = ourHost.length > 0 && !referer.canFind(ourHost);\n\n\t\t\t\tif (isExternal && referer !in seenReferrers)\n\t\t\t\t{\n\t\t\t\t\tseenReferrers[referer] = true;\n\t\t\t\t\tauto evt = JourneyEvent(timestamp, \"referrer\", \"External referrer\", true, \"\", logFileName, lineNum);\n\t\t\t\t\tevt.url = referer;\n\t\t\t\t\twebEvents ~= evt;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Add page visit event\n\t\t\tauto eventMessage = method == \"POST\" ? \"Form submission\" : \"Page visit\";\n\t\t\tauto evt = JourneyEvent(timestamp, \"page_visit\", eventMessage, true, displayPath, logFileName, lineNum);\n\t\t\tevt.url = url;\n\t\t\twebEvents ~= evt;\n\t\t}\n\n\t\treturn webEvents;\n\t}\n\n\t// Track IP and User-Agent for web log correlation\n\tstring userIP;\n\tstring userAgent;\n\n\t// Parse each log file\n\tforeach (ref related; relatedLogs)\n\t{\n\t\tauto logFile = related.file;\n\t\tif (!exists(logFile))\n\t\t\tcontinue;\n\n\t\tauto content = cast(string)read(logFile);\n\t\tauto logFileName = baseName(logFile);\n\n\t\t// Extract post ID from filename to show which attempt this is\n\t\tstring logPostID;\n\t\tauto m = logFile.matchFirst(`PostProcess-([a-z]{20})\\.log`);\n\t\tif (m)\n\t\t\tlogPostID = m.captures[1];\n\n\t\t// Add log file header with evidence\n\t\tevents ~= JourneyEvent(\"\", \"log_file\", logFileName, true, related.evidence, logFileName, 0);\n\n\t\tint lineNum = 0;\n\t\tforeach (line; content.split(\"\\n\"))\n\t\t{\n\t\t\tlineNum++;\n\t\t\tif (line.length < 30 || line[0] != '[')\n\t\t\t\tcontinue;\n\n\t\t\t// Extract timestamp\n\t\t\tauto closeBracket = line.indexOf(\"]\");\n\t\t\tif (closeBracket < 0)\n\t\t\t\tcontinue;\n\t\t\tauto timestamp = line[1..closeBracket];\n\t\t\tauto message = line[closeBracket + 2 .. $];\n\n\t\t\t// Parse different event types\n\t\t\tif (message.startsWith(\"IP: \"))\n\t\t\t{\n\t\t\t\tuserIP = message[4..$];\n\t\t\t\tevents ~= JourneyEvent(timestamp, \"info\", \"IP Address\", true, message[4..$], logFileName, lineNum);\n\t\t\t}\n\t\t\telse if (message.startsWith(\"[Header] User-Agent: \"))\n\t\t\t{\n\t\t\t\tuserAgent = message[21..$];\n\t\t\t}\n\t\t\telse if (message.startsWith(\"CAPTCHA OK\"))\n\t\t\t{\n\t\t\t\tevents ~= JourneyEvent(timestamp, \"captcha\", \"CAPTCHA solved successfully\", true, \"\", logFileName, lineNum);\n\t\t\t}\n\t\t\telse if (message.startsWith(\"  CAPTCHA question: \"))\n\t\t\t{\n\t\t\t\tauto jsonStr = message[20..$];\n\t\t\t\tstring question;\n\t\t\t\ttry\n\t\t\t\t\tquestion = jsonParse!string(jsonStr);\n\t\t\t\tcatch (Exception)\n\t\t\t\t\tquestion = jsonStr; // Fallback if not valid JSON\n\t\t\t\tevents ~= JourneyEvent(timestamp, \"captcha\", \"CAPTCHA question\", true, question, logFileName, lineNum);\n\t\t\t}\n\t\t\telse if (message.startsWith(\"[Form] \"))\n\t\t\t{\n\t\t\t\t// Check if this form field is a CAPTCHA response\n\t\t\t\tauto formContent = message[7..$];\n\t\t\t\tauto colonPos = formContent.indexOf(\": \");\n\t\t\t\tif (colonPos >= 0)\n\t\t\t\t{\n\t\t\t\t\tauto fieldName = formContent[0..colonPos];\n\t\t\t\t\tauto fieldValue = formContent[colonPos + 2..$];\n\t\t\t\t\tauto captchaResponse = getCaptchaResponseFromField(fieldName, fieldValue);\n\t\t\t\t\tif (captchaResponse !is null)\n\t\t\t\t\t\tevents ~= JourneyEvent(timestamp, \"captcha\", \"CAPTCHA answer\", true, captchaResponse, logFileName, lineNum);\n\t\t\t\t}\n\t\t\t}\n\t\t\telse if (message.startsWith(\"CAPTCHA failed: \"))\n\t\t\t{\n\t\t\t\tevents ~= JourneyEvent(timestamp, \"captcha\", \"CAPTCHA failed\", false, message[16..$], logFileName, lineNum);\n\t\t\t}\n\t\t\telse if (message.startsWith(\"Spam check failed (spamicity: \"))\n\t\t\t{\n\t\t\t\tauto spamMatch = message.matchFirst(`Spam check failed \\(spamicity: ([\\d.]+)\\): (.+)`);\n\t\t\t\tif (spamMatch)\n\t\t\t\t\tevents ~= JourneyEvent(timestamp, \"spam_check\", \"Spam check failed\", false,\n\t\t\t\t\t\t\"Spamicity: \" ~ spamMatch[1] ~ \" - \" ~ spamMatch[2], logFileName, lineNum);\n\t\t\t}\n\t\t\telse if (message.startsWith(\"Spam check OK (spamicity: \"))\n\t\t\t{\n\t\t\t\tauto spamMatch = message.matchFirst(`Spam check OK \\(spamicity: ([\\d.]+)\\)`);\n\t\t\t\tif (spamMatch)\n\t\t\t\t\tevents ~= JourneyEvent(timestamp, \"spam_check\", \"Spam check passed\", true,\n\t\t\t\t\t\t\"Spamicity: \" ~ spamMatch[1], logFileName, lineNum);\n\t\t\t}\n\t\t\telse if (message.startsWith(\"User is trusted, skipping spam check\"))\n\t\t\t{\n\t\t\t\tevents ~= JourneyEvent(timestamp, \"spam_check\", \"Trusted user, spam check skipped\", true, \"\", logFileName, lineNum);\n\t\t\t}\n\t\t\telse if (message.startsWith(\"Got reply from spam checker \"))\n\t\t\t{\n\t\t\t\tauto checkerMatch = message.matchFirst(`Got reply from spam checker [^:]+\\.([^.:]+): spamicity ([\\d.]+) \\(([^)]*)\\)`);\n\t\t\t\tif (checkerMatch)\n\t\t\t\t{\n\t\t\t\t\tauto checkerName = checkerMatch[1];\n\t\t\t\t\tauto spamicity = checkerMatch[2];\n\t\t\t\t\tauto detail = checkerMatch[3];\n\t\t\t\t\tauto detailStr = detail.length > 0 ? \" (\" ~ detail ~ \")\" : \"\";\n\t\t\t\t\tevents ~= JourneyEvent(timestamp, \"spam_detail\", checkerName, true,\n\t\t\t\t\t\t\"Spamicity: \" ~ spamicity ~ detailStr, logFileName, lineNum);\n\t\t\t\t}\n\t\t\t}\n\t\t\telse if (message.startsWith(\"Quarantined for moderation: \"))\n\t\t\t{\n\t\t\t\tevents ~= JourneyEvent(timestamp, \"moderation\", \"Quarantined for moderation\", false, message[28..$], logFileName, lineNum);\n\t\t\t}\n\t\t\telse if (message.startsWith(\"< Message-ID: <\"))\n\t\t\t{\n\t\t\t\tevents ~= JourneyEvent(timestamp, \"posted\", \"Post created with Message-ID\", true,\n\t\t\t\t\tmessage[15..$-1], logFileName, lineNum); // Remove \"< Message-ID: <\" and final \">\"\n\t\t\t}\n\t\t}\n\t}\n\n\t// Search for approval event\n\tif (primaryLog !is null)\n\t\tsearchBannedLog(postID, primaryLog);\n\n\t// Parse Web.log for page visits (returns separate array, doesn't modify events)\n\tJourneyEvent[] webEvents;\n\tif (primaryLog !is null)\n\t\twebEvents = parseWebLog(primaryLog, userIP, userAgent);\n\n\t// Interleave web events between PostProcess log sections based on timestamps\n\tif (webEvents.length > 0)\n\t{\n\t\t// Sort web events by timestamp\n\t\twebEvents.sort!((a, b) => a.timestamp < b.timestamp);\n\n\t\t// Split events into sections (each section starts with a log_file header)\n\t\tstruct LogSection\n\t\t{\n\t\t\tsize_t startIdx;  // Index of log_file header in events array\n\t\t\tsize_t endIdx;    // Index after last event in this section\n\t\t\tstring firstTimestamp;  // First non-header event timestamp\n\t\t}\n\t\tLogSection[] sections;\n\n\t\tfor (size_t i = 0; i < events.length; i++)\n\t\t{\n\t\t\tif (events[i].type == \"log_file\")\n\t\t\t{\n\t\t\t\tLogSection section;\n\t\t\t\tsection.startIdx = i;\n\t\t\t\t// Find the end of this section (next log_file header or end of array)\n\t\t\t\tsize_t j = i + 1;\n\t\t\t\twhile (j < events.length && events[j].type != \"log_file\")\n\t\t\t\t{\n\t\t\t\t\tif (section.firstTimestamp.length == 0 && events[j].timestamp.length > 0)\n\t\t\t\t\t\tsection.firstTimestamp = events[j].timestamp;\n\t\t\t\t\tj++;\n\t\t\t\t}\n\t\t\t\tsection.endIdx = j;\n\t\t\t\tsections ~= section;\n\t\t\t\ti = j - 1;  // Continue from end of section\n\t\t\t}\n\t\t}\n\n\t\t// Rebuild events with web events interleaved\n\t\tJourneyEvent[] newEvents;\n\t\tsize_t webIdx = 0;\n\n\t\tforeach (sectionIdx, ref section; sections)\n\t\t{\n\t\t\t// Insert web events that occurred before this section's first event\n\t\t\twhile (webIdx < webEvents.length &&\n\t\t\t\t   (section.firstTimestamp.length == 0 || webEvents[webIdx].timestamp < section.firstTimestamp))\n\t\t\t{\n\t\t\t\tnewEvents ~= webEvents[webIdx];\n\t\t\t\twebIdx++;\n\t\t\t}\n\n\t\t\t// Add this section's events\n\t\t\tfor (size_t i = section.startIdx; i < section.endIdx; i++)\n\t\t\t\tnewEvents ~= events[i];\n\t\t}\n\n\t\t// Add any remaining web events after all sections\n\t\twhile (webIdx < webEvents.length)\n\t\t{\n\t\t\tnewEvents ~= webEvents[webIdx];\n\t\t\twebIdx++;\n\t\t}\n\n\t\tevents = newEvents;\n\t}\n\n\treturn events;\n}\n\nvoid renderJourneyTimeline(JourneyEvent[] events)\n{\n\timport std.conv : to;\n\n\tif (events.length == 0)\n\t\treturn;\n\n\thtml.put(\n\t\t`<style>` ~\n\t\t\t`.journey-timeline { margin-bottom: 1em; }` ~\n\t\t\t`.journey-timeline h3 { margin: 0 0 0.5em 0; }` ~\n\t\t\t`.journey-events { border-top: 2px solid #E6E6E6; font-family: Consolas, Lucida Console, Menlo, monospace; font-size: 0.9em; }` ~\n\t\t\t`.journey-event { padding: 0.5em 0.75em; border-left: 4px solid #E6E6E6; border-right: 2px solid #E6E6E6; background: #FCFCFC; border-bottom: 1px solid #E6E6E6; }` ~\n\t\t\t`.journey-event:last-child { border-bottom: 2px solid #E6E6E6; }` ~\n\t\t\t`.journey-event.success { border-left-color: #5A5; background: #F5FFF5; }` ~\n\t\t\t`.journey-event.failure { border-left-color: #A55; background: #FFF5F5; }` ~\n\t\t\t`.journey-event.info { border-left-color: #58A; background: #F5F9FF; }` ~\n\t\t\t`.journey-event.spam_detail { border-left-color: #A85; background: #FFFAF5; padding: 0.33em 0.75em; }` ~\n\t\t\t`.journey-event.log_file { border-left-color: #85A; background: #F5F5F5; }` ~\n\t\t\t`.journey-event.approval { border-left-color: #5A5; background: #F0FFF0; }` ~\n\t\t\t`.journey-event.page_visit { border-left-color: #59A; background: #F5FAFF; }` ~\n\t\t\t`.journey-event.referrer { border-left-color: #A59; background: #FAF5FF; }` ~\n\t\t\t`.journey-event.log_file:not(:first-child) { margin-top: 1em; border-top: 2px solid #E6E6E6; }` ~\n\t\t\t`.journey-timestamp { color: #666; font-size: 0.95em; }` ~\n\t\t\t`.journey-message { font-weight: bold; }` ~\n\t\t\t`.journey-details { color: #666; font-size: 0.95em; margin-top: 0.25em; }` ~\n\t\t\t`.journey-details a { color: #369; }` ~\n\t\t\t`.journey-source { color: #999; font-size: 0.9em; float: right; }` ~\n\t\t`</style>` ~\n\t\t`<div class=\"journey-timeline\">` ~\n\t\t\t`<h3>User Journey</h3>` ~\n\t\t\t`<div class=\"journey-events\">`\n\t);\n\n\tforeach (event; events)\n\t{\n\t\tstring cssClass;\n\t\tif (event.type == \"log_file\")\n\t\t\tcssClass = \"log_file\";\n\t\telse if (event.type == \"spam_detail\")\n\t\t\tcssClass = \"spam_detail\";\n\t\telse if (event.type == \"approval\")\n\t\t\tcssClass = \"approval\";\n\t\telse if (event.type == \"page_visit\")\n\t\t\tcssClass = \"page_visit\";\n\t\telse if (event.type == \"referrer\")\n\t\t\tcssClass = \"referrer\";\n\t\telse if (event.success)\n\t\t\tcssClass = \"success\";\n\t\telse if (event.type == \"info\")\n\t\t\tcssClass = \"info\";\n\t\telse\n\t\t\tcssClass = \"failure\";\n\n\t\thtml.put(`<div class=\"journey-event `, cssClass, `\">`);\n\n\t\t// Show source file:line for regular events\n\t\tif (event.type != \"log_file\" && event.sourceFile.length > 0 && event.lineNumber > 0)\n\t\t{\n\t\t\thtml.put(`<span class=\"journey-source\">`);\n\t\t\thtml.putEncodedEntities(event.sourceFile ~ \":\" ~ event.lineNumber.to!string);\n\t\t\thtml.put(`</span>`);\n\t\t}\n\n\t\tif (event.timestamp.length > 0)\n\t\t{\n\t\t\thtml.put(`<span class=\"journey-timestamp\">`);\n\t\t\thtml.putEncodedEntities(event.timestamp);\n\t\t\thtml.put(` </span>`);\n\t\t}\n\n\t\thtml.put(`<span class=\"journey-message\">`);\n\t\thtml.putEncodedEntities(event.message);\n\t\thtml.put(`</span>`);\n\n\t\tif (event.details.length > 0 || event.url.length > 0)\n\t\t{\n\t\t\thtml.put(`<div class=\"journey-details\">`);\n\t\t\tif (event.url.length > 0)\n\t\t\t{\n\t\t\t\thtml.put(`<a href=\"`);\n\t\t\t\thtml.putEncodedEntities(event.url);\n\t\t\t\thtml.put(`\" target=\"_blank\" rel=\"noopener\">`);\n\t\t\t\t// For page visits, show the path; for referrers, show the full URL\n\t\t\t\tif (event.details.length > 0)\n\t\t\t\t\thtml.putEncodedEntities(event.details);\n\t\t\t\telse\n\t\t\t\t\thtml.putEncodedEntities(event.url);\n\t\t\t\thtml.put(`</a>`);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\thtml.putEncodedEntities(event.details);\n\t\t\t}\n\t\t\thtml.put(`</div>`);\n\t\t}\n\t\thtml.put(`</div>`);\n\t}\n\n\thtml.put(`</div></div>`);\n}\n\nvoid discussionModeration(Rfc850Post post, UrlParameters postVars)\n{\n\tif (postVars == UrlParameters.init)\n\t{\n\t\t// Display user journey timeline\n\t\tauto journeyEvents = parsePostingJourney(post.id);\n\t\trenderJourneyTimeline(journeyEvents);\n\n\t\tauto sinkNames = post.xref\n\t\t\t.map!(x => x.group.getGroupInfo())\n\t\t\t.filter!(g => g.sinkType == \"nntp\")\n\t\t\t.map!(g => g.sinkName[])\n\t\t\t.array.sort.uniq\n\t\t;\n\t\tauto deleteCommands = sinkNames\n\t\t\t.map!(sinkName => loadIni!NntpConfig(resolveSiteFile(\"config/sources/nntp/\" ~ sinkName ~ \".ini\")).deleteCommand)\n\t\t\t.filter!identity\n\t\t;\n\t\thtml.put(\n\t\t\t`<form method=\"post\" class=\"forum-form delete-form\" id=\"deleteform\">` ~\n\t\t\t\t`<input type=\"hidden\" name=\"id\" value=\"`), html.putEncodedEntities(post.id), html.put(`\">` ~\n\t\t\t\t`<input type=\"hidden\" name=\"secret\" value=\"`, userSettings.secret, `\">` ~\n\n\t\t\t\t`<div id=\"deleteform-info\">` ~\n\t\t\t\t\t_!`Perform which moderation actions on this post?` ~\n\t\t\t\t`</div>` ~\n\n\t\t\t\t`<textarea id=\"deleteform-message\" readonly=\"readonly\" rows=\"25\" cols=\"80\">`\n\t\t\t\t\t), html.putEncodedEntities(post.message), html.put(\n\t\t\t\t`</textarea><br>` ~\n\n\t\t\t\t`<input type=\"checkbox\" name=\"delete\" value=\"Yes\" checked id=\"deleteform-delete\"></input>` ~\n\t\t\t\t`<label for=\"deleteform-delete\">` ~\n\t\t\t\t\t_!`Delete local cached copy of this post from DFeed's database` ~\n\t\t\t\t`</label><br>`,\n\n\t\t\t\tfindPostingLog(post.id)\n\t\t\t\t?\t`<input type=\"checkbox\" name=\"ban\" value=\"Yes\" id=\"deleteform-ban\"></input>` ~\n\t\t\t\t\t`<label for=\"deleteform-ban\">` ~\n\t\t\t\t\t\t_!`Ban poster (place future posts in moderation queue)` ~\n\t\t\t\t\t`</label><br>`\n\t\t\t\t: ``,\n\n\t\t\t\t!deleteCommands.empty\n\t\t\t\t?\t`<input type=\"checkbox\" name=\"delsource\" value=\"Yes\" id=\"deleteform-delsource\"></input>` ~\n\t\t\t\t\t`<label for=\"deleteform-delsource\">` ~\n\t\t\t\t\t\t_!`Delete source copy from %-(%s/%)`.format(sinkNames.map!encodeHtmlEntities) ~\n\t\t\t\t\t`</label><br>`\n\t\t\t\t: ``,\n\n\t\t\t\t`<input type=\"checkbox\" name=\"callsinks\" value=\"Yes\" checked id=\"deleteform-sinks\"></input>` ~\n\t\t\t\t`<label for=\"deleteform-sinks\">` ~\n\t\t\t\t\t_!`Try to moderate in other message sinks (e.g. Twitter)` ~\n\t\t\t\t`</label><br>`,\n\n\t\t\t\t_!`Reason:`, ` <input name=\"reason\" value=\"spam\"></input><br>` ~\n\t\t\t\t`<input type=\"submit\" value=\"`, _!`Moderate`, `\"></input>` ~\n\t\t\t`</form>`\n\t\t);\n\t}\n\telse\n\t{\n\t\tif (postVars.get(\"secret\", \"\") != userSettings.secret)\n\t\t\tthrow new Exception(_!\"XSRF secret verification failed. Are your cookies enabled?\");\n\n\t\tstring messageID = postVars.get(\"id\", \"\");\n\t\tstring userName = user.getName();\n\t\tstring reason = postVars.get(\"reason\", \"\");\n\t\tbool deleteLocally = postVars.get(\"delete\"   , \"No\") == \"Yes\";\n\t\tbool ban           = postVars.get(\"ban\"      , \"No\") == \"Yes\";\n\t\tbool delSource     = postVars.get(\"delsource\", \"No\") == \"Yes\";\n\t\tbool callSinks     = postVars.get(\"callsinks\", \"No\") == \"Yes\";\n\n\t\tif (deleteLocally || ban || delSource)\n\t\t\tmoderatePost(\n\t\t\t\tmessageID,\n\t\t\t\treason,\n\t\t\t\tuserName,\n\t\t\t\tdeleteLocally ? Yes.deleteLocally : No.deleteLocally,\n\t\t\t\tban           ? Yes.ban           : No.ban          ,\n\t\t\t\tdelSource     ? Yes.deleteSource  : No.deleteSource ,\n\t\t\t\tcallSinks     ? Yes.callSinks     : No.callSinks    ,\n\t\t\t\t(string s) { html.put(encodeHtmlEntities(s) ~ \"<br>\"); },\n\t\t\t);\n\t\telse\n\t\t\thtml.put(\"No actions specified!\");\n\t}\n}\n\nvoid discussionModerationDeleted(string messageID)\n{\n\thtml.put(\n\t\t`<div class=\"forum-notice\">` ~\n\t\t\t_!`This post is not in the database.` ~\n\t\t`</div>`\n\t);\n\n\t// Display user journey timeline (from PostProcess logs)\n\tauto journeyEvents = parsePostingJourney(messageID);\n\tif (!journeyEvents.empty)\n\t{\n\t\trenderJourneyTimeline(journeyEvents);\n\t}\n\n\t// Look for deleted post info in Deleted.log\n\tauto deletedInfo = findDeletedPostInfo(messageID);\n\tbool hasDeletedInfo = deletedInfo.messageContent.length > 0 || deletedInfo.postsRow.length > 0;\n\n\tif (hasDeletedInfo)\n\t{\n\t\thtml.put(`<h3>`, _!`Deletion Record`, `</h3>`);\n\n\t\thtml.put(`<div class=\"deleted-post-info\">`);\n\n\t\tif (deletedInfo.timestamp.length)\n\t\t\thtml.put(`<p><strong>`, _!`Deleted at:`, `</strong> `, encodeHtmlEntities(deletedInfo.timestamp), `</p>`);\n\n\t\tif (deletedInfo.moderator.length)\n\t\t\thtml.put(`<p><strong>`, _!`Deleted by:`, `</strong> `, encodeHtmlEntities(deletedInfo.moderator), `</p>`);\n\n\t\tif (deletedInfo.reason.length)\n\t\t\thtml.put(`<p><strong>`, _!`Reason:`, `</strong> `, encodeHtmlEntities(deletedInfo.reason), `</p>`);\n\n\t\t// Show post metadata from database row\n\t\tif (deletedInfo.postsRow.length > 0)\n\t\t{\n\t\t\tauto subject = \"Subject\" in deletedInfo.postsRow;\n\t\t\tauto author = \"Author\" in deletedInfo.postsRow;\n\t\t\tauto authorEmail = \"AuthorEmail\" in deletedInfo.postsRow;\n\n\t\t\tif (subject)\n\t\t\t\thtml.put(`<p><strong>`, _!`Subject:`, `</strong> `, encodeHtmlEntities(*subject), `</p>`);\n\t\t\tif (author)\n\t\t\t\thtml.put(`<p><strong>`, _!`Author:`, `</strong> `, encodeHtmlEntities(*author));\n\t\t\tif (authorEmail && (*authorEmail).length)\n\t\t\t\thtml.put(` &lt;`, encodeHtmlEntities(*authorEmail), `&gt;`);\n\t\t\tif (author)\n\t\t\t\thtml.put(`</p>`);\n\t\t}\n\n\t\tif (deletedInfo.messageContent.length)\n\t\t{\n\t\t\thtml.put(\n\t\t\t\t`<p><strong>`, _!`Original message:`, `</strong></p>` ~\n\t\t\t\t`<textarea id=\"deleteform-message\" readonly=\"readonly\" rows=\"25\" cols=\"80\">`\n\t\t\t);\n\t\t\thtml.putEncodedEntities(deletedInfo.messageContent);\n\t\t\thtml.put(`</textarea>`);\n\t\t}\n\n\t\thtml.put(`</div>`);\n\t}\n}\n\nvoid deletePostApi(string group, int artNum)\n{\n\tstring messageID;\n\tforeach (string id; query!\"SELECT [ID] FROM [Groups] WHERE [Group] = ? AND [ArtNum] = ?\".iterate(group, artNum))\n\t\tmessageID = id;\n\tenforce(messageID, \"No such article in this group\");\n\n\tstring reason = \"API call\";\n\tstring userName = \"API\";\n\n\tmoderatePost(\n\t\tmessageID,\n\t\treason,\n\t\tuserName,\n\t\tYes.deleteLocally,\n\t\tNo.ban,\n\t\tNo.deleteSource,\n\t\tYes.callSinks,\n\t\t(string s) { html.put(s ~ \"\\n\"); },\n\t);\n}\n\nprivate void discussionFlagPageImpl(bool flag)(Rfc850Post post, UrlParameters postParams)\n{\n\tstatic immutable string[2] actions = [\"unflag\", \"flag\"];\n\tbool isFlagged = query!`SELECT COUNT(*) FROM [Flags] WHERE [Username]=? AND [PostID]=?`.iterate(user.getName(), post.id).selectValue!int > 0;\n\tif (postParams == UrlParameters.init)\n\t{\n\t\tif (flag == isFlagged)\n\t\t{\n\t\thtml.put(\n\t\t\t`<div id=\"flagform-info\" class=\"forum-notice\">` ~\n\t\t\t\t_!(`It looks like you've already ` ~ actions[flag] ~ `ged this post.`), ` `,\n\t\t\t\t_!(`Would you like to %s` ~ actions[!flag] ~ ` it%s?`).format(\n\t\t\t\t\t`<a href=\"` ~ encodeHtmlEntities(idToUrl(post.id, actions[!flag])) ~ `\">`,\n\t\t\t\t\t`</a>`,\n\t\t\t\t),\n\t\t\t`</div>`);\n\t\t}\n\t\telse\n\t\t{\n\t\t\thtml.put(\n\t\t\t\t`<div id=\"flagform-info\" class=\"forum-notice\">`,\n\t\t\t\t\t_!(`Are you sure you want to ` ~ actions[flag] ~ ` this post?`),\n\t\t\t\t`</div>`);\n\t\t\tformatPost(post, null, false);\n\t\t\thtml.put(\n\t\t\t\t`<form action=\"\" method=\"post\" class=\"forum-form flag-form\" id=\"flagform\">` ~\n\t\t\t\t\t`<input type=\"hidden\" name=\"secret\" value=\"`, userSettings.secret, `\">` ~\n\t\t\t\t\t`<input type=\"submit\" name=\"flag\" value=\"`, _!(actions[flag].capitalize), `\"></input>` ~\n\t\t\t\t\t`<input type=\"submit\" name=\"cancel\" value=\"`, _!`Cancel`, `\"></input>` ~\n\t\t\t\t`</form>`);\n\t\t}\n\t}\n\telse\n\t{\n\t\tenforce(postParams.get(\"secret\", \"\") == userSettings.secret, _!\"XSRF secret verification failed. Are your cookies enabled?\");\n\t\tenforce(user.getLevel() >= User.Level.canFlag, _!\"You can't flag posts!\");\n\t\tenforce(user.createdAt() < post.time, _!\"You can't flag this post!\");\n\n\t\tif (\"flag\" in postParams)\n\t\t{\n\t\t\tenforce(flag != isFlagged, _!(\"You've already \" ~ actions[flag] ~ \"ged this post.\"));\n\n\t\t\tif (flag)\n\t\t\t\tquery!`INSERT INTO [Flags] ([PostID], [Username], [Date]) VALUES (?, ?, ?)`.exec(post.id, user.getName(), Clock.currTime.stdTime);\n\t\t\telse\n\t\t\t\tquery!`DELETE FROM [Flags] WHERE [PostID]=? AND [Username]=?`.exec(post.id, user.getName());\n\n\t\t\thtml.put(\n\t\t\t\t`<div id=\"flagform-info\" class=\"forum-notice\">`,\n\t\t\t\t\t_!(`Post ` ~ actions[flag] ~ `ged.`),\n\t\t\t\t`</div>` ~\n\t\t\t\t`<form action=\"\" method=\"post\" class=\"forum-form flag-form\" id=\"flagform\">` ~\n\t\t\t\t\t`<input type=\"hidden\" name=\"secret\" value=\"`, userSettings.secret, `\">` ~\n\t\t\t\t\t`<input type=\"submit\" name=\"cancel\" value=\"`, _!`Return to post`, `\"></input>` ~\n\t\t\t\t`</form>`);\n\n\t\t\tstatic if (flag)\n\t\t\t{\n\t\t\t\tauto subject = \"%s flagged %s's post in the thread \\\"%s\\\"\".format(\n\t\t\t\t\tuser.getName(),\n\t\t\t\t\tpost.author,\n\t\t\t\t\tpost.subject,\n\t\t\t\t);\n\n\t\t\t\tforeach (mod; site.moderators)\n\t\t\t\t\tsendMail(q\"EOF\nFrom: %1$s <no-reply@%2$s>\nTo: %3$s\nSubject: %4$s\nContent-Type: text/plain; charset=utf-8\n\nHowdy %5$s,\n\n%4$s:\n%6$s://%7$s%8$s\n\nHere is the message that was flagged:\n----------------------------------------------\n%9$-(%s\n%)\n----------------------------------------------\n\nIf you believe this message should be deleted, you can click here to do so:\n%6$s://%7$s%10$s\n\nAll the best,\n%1$s\n\n~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~\nYou are receiving this message because you are configured as a site moderator on %2$s.\n\nTo stop receiving messages like this, please ask the administrator of %1$s to remove you from the list of moderators.\n.\nEOF\"\n\t\t\t\t\t\t.format(\n\t\t\t\t\t\t\t/* 1*/ site.name.length ? site.name : site.host,\n\t\t\t\t\t\t\t/* 2*/ site.host,\n\t\t\t\t\t\t\t/* 3*/ mod,\n\t\t\t\t\t\t\t/* 4*/ subject,\n\t\t\t\t\t\t\t/* 5*/ mod.canFind(\"<\") ? mod.findSplit(\"<\")[0].findSplit(\" \")[0] : mod.findSplit(\"@\")[0],\n\t\t\t\t\t\t\t/* 6*/ site.proto,\n\t\t\t\t\t\t\t/* 7*/ site.host,\n\t\t\t\t\t\t\t/* 8*/ idToUrl(post.id),\n\t\t\t\t\t\t\t/* 9*/ post.content.strip.splitAsciiLines.map!(line => line.length ? \"> \" ~ line : \">\"),\n\t\t\t\t\t\t\t/*10*/ idToUrl(post.id, \"delete\"),\n\t\t\t\t\t\t));\n\t\t\t}\n\t\t}\n\t\telse\n\t\t\tthrow new Redirect(idToUrl(post.id));\n\t}\n}\n\nvoid discussionFlagPage(Rfc850Post post, bool flag, UrlParameters postParams)\n{\n\tif (flag)\n\t\tdiscussionFlagPageImpl!true (post, postParams);\n\telse\n\t\tdiscussionFlagPageImpl!false(post, postParams);\n}\n\nvoid discussionApprovePage(string draftID, UrlParameters postParams)\n{\n\tauto draft = getDraft(draftID);\n\tif (draft.status == PostDraft.Status.sent && \"pid\" in draft.serverVars)\n\t{\n\t\thtml.put(_!`This message has already been posted.`, ` ` ~\n\t\t\t`<a href=\"`), html.putEncodedEntities(idToUrl(PostProcess.pidToMessageID(draft.serverVars[\"pid\"]))), html.put(`\">`, _!`You can view it here.`, `</a>`);\n\t\treturn;\n\t}\n\tenforce(draft.status == PostDraft.Status.moderation,\n\t\t_!\"This is not a post in need of moderation. Its status is currently:\" ~ \" \" ~ text(draft.status));\n\n\tif (postParams == UrlParameters.init)\n\t{\n\t\t// Display user journey timeline if we have a pid\n\t\tif (\"pid\" in draft.serverVars)\n\t\t{\n\t\t\tauto messageID = PostProcess.pidToMessageID(draft.serverVars[\"pid\"]);\n\t\t\tauto journeyEvents = parsePostingJourney(messageID);\n\t\t\trenderJourneyTimeline(journeyEvents);\n\t\t}\n\n\t\thtml.put(\n\t\t\t`<div id=\"approveform-info\" class=\"forum-notice\">`,\n\t\t\t\t_!`Are you sure you want to approve this post?`,\n\t\t\t`</div>`);\n\t\tauto post = draftToPost(draft);\n\t\tformatPost(post, null, false);\n\t\thtml.put(\n\t\t\t`<form action=\"\" method=\"post\" class=\"forum-form approve-form\" id=\"approveform\">` ~\n\t\t\t\t`<input type=\"hidden\" name=\"secret\" value=\"`, userSettings.secret, `\">` ~\n\t\t\t\t`<input type=\"submit\" name=\"approve\" value=\"`, _!`Approve`, `\"></input>` ~\n\t\t\t\t`<input type=\"submit\" name=\"cancel\" value=\"`, _!`Cancel`, `\"></input>` ~\n\t\t\t`</form>`);\n\t}\n\telse\n\t{\n\t\tenforce(postParams.get(\"secret\", \"\") == userSettings.secret, _!\"XSRF secret verification failed. Are your cookies enabled?\");\n\n\t\tif (\"approve\" in postParams)\n\t\t{\n\t\t\tauto pid = approvePost(draftID, user.getName());\n\n\t\t\thtml.put(_!`Post approved!`, ` <a href=\"/posting/` ~ pid ~ `\">`, _!`View posting`, `</a>`);\n\t\t}\n\t\telse\n\t\t\tthrow new Redirect(\"/\");\n\t}\n}\n\nvoid discussionUnbanByKeyPage(string key, UrlParameters postParams)\n{\n\timport std.algorithm.iteration : map;\n\timport std.conv : to;\n\n\tif (postParams == UrlParameters.init)\n\t{\n\t\tauto tree = getUnbanPreviewByKey(key);\n\t\tif (tree.allNodes.length == 0)\n\t\t{\n\t\t\thtml.put(\n\t\t\t\t`<div id=\"unbanform-info\" class=\"forum-notice\">`,\n\t\t\t\t\t_!`The specified key is not banned.`,\n\t\t\t\t`</div>` ~\n\t\t\t\t`<form action=\"\" method=\"get\" class=\"forum-form unban-form\" id=\"unbanform\">` ~\n\t\t\t\t\t`<label for=\"key\">`, _!`Key to unban:`, `</label>` ~\n\t\t\t\t\t`<input type=\"text\" name=\"key\" id=\"key\" size=\"60\" value=\"`), html.putEncodedEntities(key), html.put(`\">` ~\n\t\t\t\t\t`<input type=\"submit\" value=\"`, _!`Look up`, `\"></input>` ~\n\t\t\t\t`</form>`);\n\t\t\treturn;\n\t\t}\n\n\t\thtml.put(\n\t\t\t`<div id=\"unbanform-info\" class=\"forum-notice\">`,\n\t\t\t\t_!`Select which keys to unban:`,\n\t\t\t`</div>` ~\n\t\t\t`<style>` ~\n\t\t\t\t`.unban-tree { margin-left: 0; padding-left: 20px; list-style: none; }` ~\n\t\t\t\t`.unban-tree li { margin: 8px 0; }` ~\n\t\t\t\t`.unban-tree .unban-key { font-family: monospace; font-weight: bold; }` ~\n\t\t\t\t`.unban-tree .unban-reason { color: #666; font-style: italic; }` ~\n\t\t\t\t`.unban-tree .unban-unban-reason { color: #080; }` ~\n\t\t\t\t`.unban-node { padding: 4px; }` ~\n\t\t\t\t`.unban-node:hover { background-color: #f0f0f0; }` ~\n\t\t\t`</style>`);\n\n\t\tvoid renderNode(UnbanTree.Node* node, int depth = 0)\n\t\t{\n\t\t\thtml.put(`<li><div class=\"unban-node\">`);\n\t\t\thtml.put(`<input type=\"checkbox\" name=\"key\" value=\"`);\n\t\t\thtml.putEncodedEntities(node.key);\n\t\t\thtml.put(`\" id=\"key-`, depth.to!string, `-`);\n\t\t\thtml.putEncodedEntities(node.key);\n\t\t\thtml.put(`\" checked> <label for=\"key-`, depth.to!string, `-`);\n\t\t\thtml.putEncodedEntities(node.key);\n\t\t\thtml.put(`\"><span class=\"unban-key\">`);\n\t\t\thtml.putEncodedEntities(node.key);\n\t\t\thtml.put(`</span> <span class=\"unban-reason\">(`);\n\t\t\thtml.putEncodedEntities(node.reason);\n\t\t\thtml.put(`)</span> <span class=\"unban-unban-reason\">— `);\n\t\t\thtml.putEncodedEntities(node.unbanReason);\n\t\t\thtml.put(`</span></label></div>`);\n\n\t\t\tif (node.children.length > 0)\n\t\t\t{\n\t\t\t\thtml.put(`<ul class=\"unban-tree\">`);\n\t\t\t\tforeach (child; node.children)\n\t\t\t\t\trenderNode(child, depth + 1);\n\t\t\t\thtml.put(`</ul>`);\n\t\t\t}\n\n\t\t\thtml.put(`</li>`);\n\t\t}\n\n\t\thtml.put(\n\t\t\t`<form action=\"\" method=\"post\" class=\"forum-form unban-form\" id=\"unbanform\">` ~\n\t\t\t\t`<input type=\"hidden\" name=\"secret\" value=\"`, userSettings.secret, `\">` ~\n\t\t\t\t`<input type=\"hidden\" name=\"lookup-key\" value=\"`), html.putEncodedEntities(key), html.put(`\">`);\n\n\t\thtml.put(`<ul class=\"unban-tree\">`);\n\t\tforeach (root; tree.roots)\n\t\t\trenderNode(root);\n\t\thtml.put(`</ul>`);\n\n\t\thtml.put(\n\t\t\t\t`<input type=\"submit\" name=\"unban\" value=\"`, _!`Unban Selected`, `\"></input>` ~\n\t\t\t\t`<input type=\"submit\" name=\"cancel\" value=\"`, _!`Cancel`, `\"></input>` ~\n\t\t\t`</form>`);\n\t}\n\telse\n\t{\n\t\tenforce(postParams.get(\"secret\", \"\") == userSettings.secret, _!\"XSRF secret verification failed. Are your cookies enabled?\");\n\n\t\tif (\"unban\" in postParams)\n\t\t{\n\t\t\t// Collect all checked keys\n\t\t\tstring[] keysToUnban;\n\t\t\tforeach (name, value; postParams)\n\t\t\t\tif (name == \"key\")\n\t\t\t\t\tkeysToUnban ~= value;\n\n\t\t\tenforce(keysToUnban.length > 0, _!\"No keys selected to unban\");\n\n\t\t\t// Use the lookup key as a dummy ID for logging\n\t\t\tauto lookupKey = postParams.get(\"lookup-key\", key);\n\t\t\tunbanPoster(user.getName(), \"<unban-by-key:\" ~ lookupKey ~ \">\", keysToUnban);\n\n\t\t\thtml.put(format(_!`Unbanned %d key(s)!`, keysToUnban.length), ` <a href=\"/unban\">`, _!`Unban another key`, `</a>`);\n\t\t}\n\t\telse\n\t\t\tthrow new Redirect(\"/\");\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/post.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Formatting posts.\nmodule dfeed.web.web.view.post;\n\nimport std.algorithm.iteration : map;\nimport std.array : array, join, replicate;\nimport std.conv : text;\nimport std.exception : enforce;\nimport std.format;\n\nimport ae.net.ietf.message : Rfc850Message;\nimport ae.utils.text.html : encodeHtmlEntities;\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.database : query;\nimport dfeed.groups : GroupInfo;\nimport dfeed.loc;\nimport dfeed.message : Rfc850Post, idToUrl, idToFragment, getGroup;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.part.gravatar : getGravatarHash, putGravatar;\nimport dfeed.web.web.part.post : getParentLink, miniPostInfo, getPostActions, postActions, postLink, formatPost, formatPostParts;\nimport dfeed.web.web.part.profile : profileUrl;\nimport dfeed.web.web.part.postbody : formatBody;\nimport dfeed.web.web.part.strings : formatShortTime, summarizeTime, formatAbsoluteTime;\nimport dfeed.web.web.part.thread : discussionThreadOverview;\nimport dfeed.web.web.postinfo : PostInfo, getPost, idToThreadUrl, getPostInfo;\nimport dfeed.web.web.user : user;\n\n// ***********************************************************************\n\nvoid discussionVSplitPost(string id)\n{\n\tauto post = getPost(id);\n\tenforce(post, _!\"Post not found\");\n\n\tformatPost(post, null);\n}\n\n// ***********************************************************************\n\nstruct InfoRow { string name, value; }\n\n/// Alternative post formatting, with the meta-data header on top\nvoid formatSplitPost(Rfc850Post post, bool footerNav)\n{\n\tscope(success) user.setRead(post.rowid, true);\n\n\tInfoRow[] infoRows;\n\tstring parentLink;\n\n\tinfoRows ~= InfoRow(_!\"From\", encodeHtmlEntities(post.author));\n\t// infoRows ~= InfoRow(_!\"Date\", format(\"%s (%s)\", formatAbsoluteTime(post.time), formatShortTime(post.time, false)));\n\tinfoRows ~= InfoRow(_!\"Date\", formatAbsoluteTime(post.time));\n\n\tif (post.parentID)\n\t{\n\t\tauto parent = post.parentID ? getPostInfo(post.parentID) : null;\n\t\tif (parent)\n\t\t{\n\t\t\tparentLink = postLink(parent.rowid, parent.id, parent.author);\n\t\t\tinfoRows ~= InfoRow(_!\"In reply to\", parentLink);\n\t\t}\n\t}\n\n\tstring[] replies;\n\tforeach (int rowid, string id, string author; query!\"SELECT `ROWID`, `ID`, `Author` FROM `Posts` WHERE ParentID = ?\".iterate(post.id))\n\t\treplies ~= postLink(rowid, id, author);\n\tif (replies.length)\n\t\tinfoRows ~= InfoRow(_!\"Replies\", `<span class=\"avoid-wrap\">` ~ replies.join(`,</span> <span class=\"avoid-wrap\">`) ~ `</span>`);\n\n\tauto partList = formatPostParts(post);\n\tif (partList.length)\n\t\tinfoRows ~= InfoRow(_!\"Attachments\", partList.join(\", \"));\n\n\tstring gravatarHash = getGravatarHash(post.authorEmail);\n\n\twith (post.msg)\n\t{\n\t\thtml.put(\n\t\t\t`<div class=\"post-wrapper\">` ~\n\t\t\t`<table class=\"split-post forum-table\" id=\"`), html.putEncodedEntities(idToFragment(id)), html.put(`\">` ~\n\t\t\t`<tr class=\"post-header\"><th>` ~\n\t\t\t\t`<div class=\"post-time\">`, summarizeTime(time), `</div>` ~\n\t\t\t\t`<a title=\"`, _!`Permanent link to this post`, `\" href=\"`), html.putEncodedEntities(idToUrl(id)), html.put(`\" class=\"`, (user.isRead(post.rowid) ? \"forum-read\" : \"forum-unread\"), `\">`,\n\t\t\t\t\tencodeHtmlEntities(rawSubject),\n\t\t\t\t`</a>` ~\n\t\t\t`</th></tr>` ~\n\t\t\t`<tr><td class=\"horizontal-post-info\">` ~\n\t\t\t\t`<table><tr>` ~\n\t\t\t\t\t`<td class=\"post-info-avatar\" rowspan=\"`, text(infoRows.length), `\">`);\n\t\tputGravatar(gravatarHash, author, profileUrl(author, authorEmail), _!`%s's profile`.format(author), null, 48);\n\t\thtml.put(\n\t\t\t\t\t`</td>` ~\n\t\t\t\t\t`<td><table>`);\n\t\tforeach (a; infoRows)\n\t\t\thtml.put(`<tr><td class=\"horizontal-post-info-name\">`, a.name, `</td><td class=\"horizontal-post-info-value\">`, a.value, `</td></tr>`);\n\t\thtml.put(\n\t\t\t\t\t`</table></td>` ~\n\t\t\t\t\t`<td class=\"post-info-actions\">`), postActions(getPostActions(post.msg)), html.put(`</td>` ~\n\t\t\t\t`</tr></table>` ~\n\t\t\t`</td></tr>` ~\n\t\t\t`<tr><td class=\"post-body\">` ~\n\t\t\t\t`<table class=\"post-layout\"><tr class=\"post-layout-header\"><td>`);\n\t\tminiPostInfo(post, null);\n\t\thtml.put(\n\t\t\t\t`</td></tr>` ~\n\t\t\t\t`<tr class=\"post-layout-body\"><td>`,\n\t\t\t\t\t), formatBody(post), html.put(\n\t\t\t\t\t(error ? `<span class=\"post-error\">` ~ encodeHtmlEntities(error) ~ `</span>` : ``),\n\t\t\t\t`</td></tr>` ~\n\t\t\t\t`<tr class=\"post-layout-footer\"><td>`\n\t\t\t\t\t); postFooter(footerNav, infoRows[1..$]); html.put(\n\t\t\t\t`</td></tr></table>` ~\n\t\t\t`</td></tr>` ~\n\t\t\t`</table>` ~\n\t\t\t`</div>`\n\t\t);\n\t}\n}\n\nvoid postFooter(bool footerNav, InfoRow[] infoRows)\n{\n\thtml.put(\n\t\t`<table class=\"post-footer\"><tr>`,\n\t\t\t(footerNav ? `<td class=\"post-footer-nav\"><a href=\"javascript:navPrev()\">&laquo; ` ~ _!`Prev` ~ `</a></td>` : null),\n\t\t\t`<td class=\"post-footer-info\">`);\n\tforeach (a; infoRows)\n\t\thtml.put(`<div><span class=\"horizontal-post-info-name\">`, a.name, `</span>: <span class=\"horizontal-post-info-value\">`, a.value, `</span></div>`);\n\thtml.put(\n\t\t\t`</td>`,\n\t\t\t(footerNav ? `<td class=\"post-footer-nav\"><a href=\"javascript:navNext()\">` ~ _!`Next` ~ ` &raquo;</a></td>` : null),\n\t\t`</tr></table>`\n\t);\n}\n\nvoid discussionSplitPost(string id)\n{\n\tauto post = getPost(id);\n\tenforce(post, _!\"Post not found\");\n\n\tformatSplitPost(post, true);\n}\n\nvoid discussionSinglePost(string id, out GroupInfo groupInfo, out string title, out string authorEmail, out string threadID)\n{\n\tauto post = getPost(id);\n\tenforce(post, _!\"Post not found\");\n\tgroupInfo = post.getGroup();\n\tenforce(groupInfo, _!\"Unknown group\");\n\ttitle       = post.subject;\n\tauthorEmail = post.authorEmail;\n\tthreadID = post.cachedThreadID;\n\n\tformatSplitPost(post, false);\n\tdiscussionThreadOverview(threadID, id);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/search.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Search form and results.\nmodule dfeed.web.web.view.search;\n\nimport core.time : Duration, days;\n\nimport std.algorithm.iteration : map, filter;\nimport std.algorithm.mutation : stripLeft;\nimport std.algorithm.searching : startsWith, canFind, findSplit;\nimport std.array : replace, split, join, replicate;\nimport std.conv : to;\nimport std.exception : enforce;\nimport std.format : format;\nimport std.functional : not;\nimport std.range.primitives : empty;\nimport std.string : strip, indexOf;\n\nimport ae.net.ietf.url : UrlParameters, encodeUrlParameters;\nimport ae.utils.exception : CaughtException;\nimport ae.utils.meta : I;\nimport ae.utils.text : segmentByWhitespace;\nimport ae.utils.text.html : encodeHtmlEntities;\nimport ae.utils.time.parse : parseTime;\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.loc;\nimport dfeed.database : query;\nimport dfeed.groups : getGroupInfoByPublicName;\nimport dfeed.message : Rfc850Post, idToFragment, idToUrl;\nimport dfeed.sinks.messagedb : searchTerm;\nimport dfeed.web.web.page : html, Redirect;\nimport dfeed.web.web.part.gravatar : getGravatarHash, putGravatar;\nimport dfeed.web.web.part.pager;\nimport dfeed.web.web.part.post : miniPostInfo;\nimport dfeed.web.web.part.profile : profileUrl;\nimport dfeed.web.web.part.strings : summarizeTime;\nimport dfeed.web.web.postinfo : getPost;\nimport dfeed.web.web.user : user;\n\n/// Delimiters for formatSearchSnippet.\nenum searchDelimPrefix     = \"\\U000FDeed\"; // Private Use Area character\nenum searchDelimStartMatch = searchDelimPrefix ~ \"\\x01\";\nenum searchDelimEndMatch   = searchDelimPrefix ~ \"\\x02\";\nenum searchDelimEllipses   = searchDelimPrefix ~ \"\\x03\";\nenum searchDelimLength     = searchDelimPrefix.length + 1;\n\nvoid discussionSearch(UrlParameters parameters)\n{\n\t// HTTP form parameters => search string (visible in form, ?q= parameter) => search query (sent to database)\n\n\tstring[] terms;\n\tif (string searchScope = parameters.get(\"scope\", null))\n\t{\n\t\tif (searchScope.startsWith(\"dlang.org\"))\n\t\t\tthrow new Redirect(\"https://www.google.com/search?\" ~ encodeUrlParameters([\"sitesearch\" : searchScope, \"q\" : parameters.get(\"q\", null)]));\n\t\telse\n\t\tif (searchScope == \"forum\")\n\t\t\t{}\n\t\telse\n\t\tif (searchScope.startsWith(\"group:\") || searchScope.startsWith(\"threadmd5:\"))\n\t\t\tterms ~= searchScope;\n\t}\n\tterms ~= parameters.get(\"q\", null);\n\n\tif (parameters.get(\"exact\", null).length)\n\t\tterms ~= '\"' ~ parameters[\"exact\"].replace(`\"`, ``) ~ '\"';\n\n\tif (parameters.get(\"not\", null).length)\n\t\tforeach (word; parameters[\"not\"].split)\n\t\t\tterms ~= \"-\" ~ word.stripLeft('-');\n\n\tforeach (param; [\"group\", \"author\", \"authoremail\", \"subject\", \"content\", \"newthread\"])\n\t\tif (parameters.get(param, null).length)\n\t\t\tforeach (word; parameters[param].split)\n\t\t\t{\n\t\t\t\tif (param == \"group\")\n\t\t\t\t\tword = word.getGroupInfoByPublicName.I!(gi => gi ? gi.internalName.searchTerm : word);\n\t\t\t\tterms ~= param ~ \":\" ~ word;\n\t\t\t}\n\n\tif (parameters.get(\"startdate\", null).length || parameters.get(\"enddate\", null).length)\n\t\tterms ~= \"date:\" ~ parameters.get(\"startdate\", null) ~ \"..\" ~ parameters.get(\"enddate\", null);\n\n\tauto searchString = terms.map!strip.filter!(not!empty).join(\" \");\n\tbool doSearch = searchString.length > 0;\n\tstring autoFocus = doSearch ? \"\" : \" autofocus\";\n\n\tif (\"advsearch\" in parameters)\n\t{\n\t\thtml.put(\n\t\t\t`<form method=\"get\" id=\"advanced-search-form\">` ~\n\t\t\t`<h1>`, _!`Advanced Search`, `</h1>` ~\n\t\t\t`<p>`, _!`Find posts with...`, `</p>` ~\n\t\t\t`<table>` ~\n\t\t\t\t`<tr><td>`, _!`all these words:`     , ` </td><td><input size=\"50\" name=\"q\" value=\"`), html.putEncodedEntities(searchString), html.put(`\"`, autoFocus, `></td></tr>` ~\n\t\t\t\t`<tr><td>`, _!`this exact phrase:`   , ` </td><td><input size=\"50\" name=\"exact\"></td></tr>` ~\n\t\t\t\t`<tr><td>`, _!`none of these words:` , ` </td><td><input size=\"50\" name=\"not\"></td></tr>` ~\n\t\t\t\t`<tr><td>`, _!`posted in the group:` , ` </td><td><input size=\"50\" name=\"group\"></td></tr>` ~\n\t\t\t\t`<tr><td>`, _!`posted by:`           , ` </td><td><input size=\"50\" name=\"author\"></td></tr>` ~\n\t\t\t\t`<tr><td>`, _!`posted by (email):`   , ` </td><td><input size=\"50\" name=\"authoremail\"></td></tr>` ~\n\t\t\t\t`<tr><td>`, _!`in threads titled:`   , ` </td><td><input size=\"50\" name=\"subject\"></td></tr>` ~\n\t\t\t\t`<tr><td>`, _!`containing:`          , ` </td><td><input size=\"50\" name=\"content\"></td></tr>` ~\n\t\t\t\t`<tr><td>`, _!`posted between:`      , ` </td><td><input type=\"date\" placeholder=\"`, _!`yyyy-mm-dd`, `\" name=\"startdate\"> `, _!`and`, ` <input type=\"date\" placeholder=\"`, _!`yyyy-mm-dd`, `\" name=\"enddate\"></td></tr>` ~\n\t\t\t\t`<tr><td>`, _!`posted as new thread:`, ` </td><td><input type=\"checkbox\" name=\"newthread\" value=\"y\"><input size=\"1\" tabindex=\"-1\" style=\"visibility:hidden\"></td></tr>` ~\n\t\t\t`</table>` ~\n\t\t\t`<br>` ~\n\t\t\t`<input name=\"search\" type=\"submit\" value=\"`, _!`Advanced search`, `\">` ~\n\t\t\t`</table>` ~\n\t\t\t`</form>`\n\t\t);\n\t\tdoSearch = false;\n\t}\n\telse\n\t{\n\t\thtml.put(\n\t\t\t`<form method=\"get\" id=\"search-form\">` ~\n\t\t\t`<h1>`, _!`Search`, `</h1>` ~\n\t\t\t`<input name=\"q\" size=\"50\" value=\"`), html.putEncodedEntities(searchString), html.put(`\"`, autoFocus, `>` ~\n\t\t\t`<input name=\"search\" type=\"submit\" value=\"`, _!`Search`, `\">` ~\n\t\t\t`<input name=\"advsearch\" type=\"submit\" value=\"`, _!`Advanced search`, `\">` ~\n\t\t\t`</form>`\n\t\t);\n\t}\n\n\tif (doSearch)\n\t\ttry\n\t\t{\n\t\t\tlong startDate = 0;\n\t\t\tlong endDate = long.max;\n\n\t\t\tterms = searchString.split();\n\t\t\tstring[] queryTerms;\n\t\t\tforeach (term; terms)\n\t\t\t\tif (term.startsWith(\"date:\") && term.canFind(\"..\"))\n\t\t\t\t{\n\t\t\t\t\tlong parseDate(string date, Duration offset, long def)\n\t\t\t\t\t{\n\t\t\t\t\t\tif (!date.length)\n\t\t\t\t\t\t\treturn def;\n\t\t\t\t\t\telse\n\t\t\t\t\t\t\ttry\n\t\t\t\t\t\t\t\treturn (date.parseTime!`Y-m-d` + offset).stdTime;\n\t\t\t\t\t\t\tcatch (Exception e)\n\t\t\t\t\t\t\t\tthrow new Exception(_!\"Invalid date: %s (%s)\".format(date, e.msg));\n\t\t\t\t\t}\n\n\t\t\t\t\tauto dates = term.findSplit(\":\")[2].findSplit(\"..\");\n\t\t\t\t\tstartDate = parseDate(dates[0], 0.days, startDate);\n\t\t\t\t\tendDate   = parseDate(dates[2], 1.days, endDate);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\tif (term.startsWith(\"time:\") && term.canFind(\"..\"))\n\t\t\t\t{\n\t\t\t\t\tlong parseTime(string time, long def)\n\t\t\t\t\t{\n\t\t\t\t\t\treturn time.length ? time.to!long : def;\n\t\t\t\t\t}\n\n\t\t\t\t\tauto times = term.findSplit(\":\")[2].findSplit(\"..\");\n\t\t\t\t\tstartDate = parseTime(times[0], startDate);\n\t\t\t\t\tendDate   = parseTime(times[2], endDate);\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t\tqueryTerms ~= term;\n\n\t\t\tenforce(startDate < endDate, _!\"Start date must be before end date\");\n\t\t\tauto queryString = queryTerms.join(' ');\n\n\t\t\tint page = parameters.get(\"page\", \"1\").to!int;\n\t\t\tenforce(page >= 1, _!\"Invalid page number\");\n\n\t\t\tenum postsPerPage = 10;\n\n\t\t\tint n = 0;\n\n\t\t\tenum queryCommon =\n\t\t\t\t\"SELECT [ROWID], snippet([PostSearch], '\" ~ searchDelimStartMatch ~ \"', '\" ~ searchDelimEndMatch ~ \"', '\" ~ searchDelimEllipses ~ \"', 6) \" ~\n\t\t\t\t\"FROM [PostSearch]\";\n\t\t\tauto iterator =\n\t\t\t\tqueryTerms.length\n\t\t\t\t?\n\t\t\t\t\t(startDate == 0 && endDate == long.max)\n\t\t\t\t\t? query!(queryCommon ~ \" WHERE [PostSearch] MATCH ?                            ORDER BY [Time] DESC LIMIT ? OFFSET ?\")\n\t\t\t\t\t\t.iterate(queryString,                     postsPerPage + 1, (page-1)*postsPerPage)\n\t\t\t\t\t: query!(queryCommon ~ \" WHERE [PostSearch] MATCH ? AND [Time] BETWEEN ? AND ? ORDER BY [Time] DESC LIMIT ? OFFSET ?\")\n\t\t\t\t\t\t.iterate(queryString, startDate, endDate, postsPerPage + 1, (page-1)*postsPerPage)\n\t\t\t\t: query!(\"SELECT [ROWID], '' FROM [Posts] WHERE [Time] BETWEEN ? AND ? ORDER BY [Time] DESC LIMIT ? OFFSET ?\")\n\t\t\t\t\t.iterate(startDate, endDate, postsPerPage + 1, (page-1)*postsPerPage)\n\t\t\t\t;\n\n\t\t\tforeach (int rowid, string snippet; iterator)\n\t\t\t{\n\t\t\t\t//html.put(`<pre>`, snippet, `</pre>`);\n\t\t\t\tstring messageID;\n\t\t\t\tforeach (string id; query!\"SELECT [ID] FROM [Posts] WHERE [ROWID] = ?\".iterate(rowid))\n\t\t\t\t\tmessageID = id;\n\t\t\t\tif (!messageID)\n\t\t\t\t\tcontinue; // Can occur with deleted posts\n\n\t\t\t\tn++;\n\t\t\t\tif (n > postsPerPage)\n\t\t\t\t\tbreak;\n\n\t\t\t\tauto post = getPost(messageID);\n\t\t\t\tif (post)\n\t\t\t\t{\n\t\t\t\t\tif (!snippet.length) // No MATCH (date only)\n\t\t\t\t\t{\n\t\t\t\t\t\tenum maxWords = 20;\n\t\t\t\t\t\tauto segments = post.newContent.segmentByWhitespace;\n\t\t\t\t\t\tif (segments.length < maxWords*2)\n\t\t\t\t\t\t\tsnippet = segments.join();\n\t\t\t\t\t\telse\n\t\t\t\t\t\t\tsnippet = segments[0..maxWords*2-1].join() ~ searchDelimEllipses;\n\t\t\t\t\t}\n\t\t\t\t\tformatSearchResult(post, snippet);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif (n == 0)\n\t\t\t\thtml.put(`<p>`, _!`Your search -`, ` <b>`), html.putEncodedEntities(searchString), html.put(`</b> `, _!`- did not match any forum posts.`, `</p>`);\n\n\t\t\tif (page != 1 || n > postsPerPage)\n\t\t\t{\n\t\t\t\thtml.put(`<table class=\"forum-table post-pager\">`);\n\t\t\t\tpager(\"?\" ~ encodeUrlParameters([\"q\" : searchString]), page, n > postsPerPage ? int.max : page);\n\t\t\t\thtml.put(`</table>`);\n\t\t\t}\n\t\t}\n\t\tcatch (CaughtException e)\n\t\t\thtml.put(`<div class=\"form-error\">`, _!`Error:`, ` `), html.putEncodedEntities(e.msg), html.put(`</div>`);\n}\n\nvoid formatSearchSnippet(string s)\n{\n\twhile (true)\n\t{\n\t\tauto i = s.indexOf(searchDelimPrefix);\n\t\tif (i < 0)\n\t\t\tbreak;\n\t\thtml.putEncodedEntities(s[0..i]);\n\t\tstring delim = s[i..i+searchDelimLength];\n\t\ts = s[i+searchDelimLength..$];\n\t\tswitch (delim)\n\t\t{\n\t\t\tcase searchDelimStartMatch: html.put(`<b>`       ); break;\n\t\t\tcase searchDelimEndMatch  : html.put(`</b>`      ); break;\n\t\t\tcase searchDelimEllipses  : html.put(`<b>...</b>`); break;\n\t\t\tdefault: break;\n\t\t}\n\t}\n\thtml.putEncodedEntities(s);\n}\n\nvoid formatSearchResult(Rfc850Post post, string snippet)\n{\n\tstring gravatarHash = getGravatarHash(post.authorEmail);\n\n\twith (post.msg)\n\t{\n\t\thtml.put(\n\t\t\t`<div class=\"post-wrapper\">` ~\n\t\t\t`<table class=\"post forum-table`, (post.children ? ` with-children` : ``), `\" id=\"`), html.putEncodedEntities(idToFragment(id)), html.put(`\">` ~\n\t\t\t`<tr class=\"table-fixed-dummy\">`, `<td></td>`.replicate(2), `</tr>` ~ // Fixed layout dummies\n\t\t\t`<tr class=\"post-header\"><th colspan=\"2\">` ~\n\t\t\t\t`<div class=\"post-time\">`, summarizeTime(time), `</div>`,\n\t\t\t\tencodeHtmlEntities(post.publicGroupNames().join(\", \")), ` &raquo; ` ~\n\t\t\t\t`<a title=\"`, _!`View this post`, `\" href=\"`), html.putEncodedEntities(idToUrl(id)), html.put(`\" class=\"permalink `, (user.isRead(post.rowid) ? \"forum-read\" : \"forum-unread\"), `\">`,\n\t\t\t\t\tencodeHtmlEntities(rawSubject),\n\t\t\t\t`</a>` ~\n\t\t\t`</th></tr>` ~\n\t\t\t`<tr class=\"mini-post-info-cell\">` ~\n\t\t\t\t`<td colspan=\"2\">`\n\t\t); miniPostInfo(post, null, false); html.put(\n\t\t\t\t`</td>` ~\n\t\t\t`</tr>` ~\n\t\t\t`<tr>` ~\n\t\t\t\t`<td class=\"post-info\">` ~\n\t\t\t\t\t`<div class=\"post-author\">`), html.putEncodedEntities(author), html.put(`</div>`);\n\t\tputGravatar(gravatarHash, author, profileUrl(author, authorEmail), _!`%s's profile`.format(author), null, 80);\n\n\t\thtml.put(\n\t\t\t\t`</td>` ~\n\t\t\t\t`<td class=\"post-body\">` ~\n\t\t\t\t\t`<pre class=\"post-text\">`), formatSearchSnippet(snippet), html.put(`</pre>`,\n\t\t\t\t\t(error ? `<span class=\"post-error\">` ~ encodeHtmlEntities(error) ~ `</span>` : ``),\n\t\t\t\t`</td>` ~\n\t\t\t`</tr>` ~\n\t\t\t`</table>` ~\n\t\t\t`</div>`\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/settings.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// User settings.\nmodule dfeed.web.web.view.settings;\n\nimport std.algorithm.iteration;\nimport std.algorithm.searching;\nimport std.conv;\nimport std.exception;\nimport std.format;\n\nimport ae.net.ietf.url;\nimport ae.utils.aa : aaGet;\nimport ae.utils.json;\nimport ae.utils.text.html : encodeHtmlEntities;\nimport ae.utils.xmllite;\n\nimport dfeed.loc;\nimport dfeed.sinks.subscriptions;\nimport dfeed.site : site;\nimport dfeed.web.markdown : haveMarkdown;\nimport dfeed.web.web.page : html, Redirect;\nimport dfeed.web.web.request : currentRequest;\nimport dfeed.web.web.user;\n\nstring settingsReferrer;\n\nvoid discussionSettings(UrlParameters getVars, UrlParameters postVars)\n{\n\tsettingsReferrer = postVars.get(\"referrer\", currentRequest.headers.get(\"Referer\", null));\n\n\tif (postVars)\n\t{\n\t\tif (postVars.get(\"secret\", \"\") != userSettings.secret)\n\t\t\tthrow new Exception(_!\"XSRF secret verification failed. Are your cookies enabled?\");\n\n\t\tauto actions = postVars.keys.filter!(name => name.startsWith(\"action-\"));\n\t\tenforce(!actions.empty, _!\"No action specified\");\n\t\tauto action = actions.front[7..$];\n\n\t\tif (action == \"cancel\")\n\t\t\tthrow new Redirect(settingsReferrer ? settingsReferrer : \"/\");\n\t\telse\n\t\tif (action == \"save\")\n\t\t{\n\t\t\t// Inputs\n\t\t\tforeach (setting; [\"groupviewmode\", \"language\"])\n\t\t\t\tif (setting in postVars)\n\t\t\t\t\tuserSettings.set(setting, postVars[setting]);\n\t\t\t// Checkboxes\n\t\t\tforeach (setting; [\"enable-keynav\", \"auto-open\"])\n\t\t\t\tuserSettings.set(setting, setting in postVars ? \"true\" : \"false\");\n\t\t\tif (haveMarkdown())\n\t\t\t\tuserSettings.set(\"render-markdown\", \"render-markdown\" in postVars ? \"true\" : \"false\");\n\n\t\t\tuserSettings.pendingNotice = \"settings-saved\";\n\t\t\tthrow new Redirect(settingsReferrer ? settingsReferrer : \"/settings\");\n\t\t}\n\t\telse\n\t\tif (action == \"subscription-cancel\")\n\t\t\t{}\n\t\telse\n\t\tif (action.skipOver(\"subscription-edit-\"))\n\t\t{\n\t\t\tauto subscriptionID = action;\n\t\t\treturn discussionSubscriptionEdit(getUserSubscription(user.getName(), subscriptionID));\n\t\t}\n\t\telse\n\t\tif (action.skipOver(\"subscription-view-\"))\n\t\t\tthrow new Redirect(\"/subscription-posts/\" ~ action);\n\t\telse\n\t\tif (action.skipOver(\"subscription-feed-\"))\n\t\t\tthrow new Redirect(\"/subscription-feed/\" ~ action);\n\t\telse\n\t\tif (action == \"subscription-save\" || action == \"subscription-undelete\")\n\t\t{\n\t\t\tstring message;\n\t\t\tif (action == \"subscription-undelete\")\n\t\t\t\tmessage = _!\"Subscription undeleted.\";\n\t\t\telse\n\t\t\tif (subscriptionExists(postVars.get(\"id\", null)))\n\t\t\t\tmessage = _!\"Subscription saved.\";\n\t\t\telse\n\t\t\t\tmessage = _!\"Subscription created.\";\n\n\t\t\tauto subscription = Subscription(user.getName(), postVars);\n\t\t\ttry\n\t\t\t{\n\t\t\t\tsubscription.save();\n\t\t\t\thtml.put(`<div class=\"forum-notice\">`, message, `</div>`);\n\t\t\t}\n\t\t\tcatch (Exception e)\n\t\t\t{\n\t\t\t\thtml.put(`<div class=\"form-error\">`), html.putEncodedEntities(e.msg), html.put(`</div>`);\n\t\t\t\treturn discussionSubscriptionEdit(subscription);\n\t\t\t}\n\t\t}\n\t\telse\n\t\tif (action.skipOver(\"subscription-delete-\"))\n\t\t{\n\t\t\tauto subscriptionID = action;\n\t\t\tenforce(subscriptionExists(subscriptionID), _!\"This subscription doesn't exist.\");\n\n\t\t\thtml.put(\n\t\t\t\t`<div class=\"forum-notice\">`, _!`Subscription deleted.`, ` ` ~\n\t\t\t\t`<input type=\"submit\" name=\"action-subscription-undelete\" value=\"Undo\" form=\"subscription-form\">` ~\n\t\t\t\t`</div>` ~\n\t\t\t\t`<div style=\"display:none\">`\n\t\t\t);\n\t\t\t// Replicate the entire edit form here (but make it invisible),\n\t\t\t// so that saving the subscription recreates it on the server.\n\t\t\tdiscussionSubscriptionEdit(getUserSubscription(user.getName(), subscriptionID));\n\t\t\thtml.put(\n\t\t\t\t`</div>`\n\t\t\t);\n\n\t\t\tgetUserSubscription(user.getName(), subscriptionID).remove();\n\t\t}\n\t\telse\n\t\tif (action == \"subscription-create-content\")\n\t\t\treturn discussionSubscriptionEdit(createSubscription(user.getName(), \"content\"));\n\t\telse\n\t\t\tthrow new Exception(_!\"Unknown action:\" ~ \" \" ~ action);\n\t}\n\n\thtml.put(\n\t\t`<form method=\"post\" id=\"settings-form\">` ~\n\t\t`<h1>`, _!`Settings`, `</h1>` ~\n\t\t`<input type=\"hidden\" name=\"referrer\" value=\"`), html.putEncodedEntities(settingsReferrer), html.put(`\">` ~\n\t\t`<input type=\"hidden\" name=\"secret\" value=\"`, userSettings.secret, `\">` ~\n\n\t\t`<h2>`, _!`User Interface`, `</h2>`,\n\n\t\t_!`Language:`, ` <select name=\"language\">`\n\t);\n\tforeach (Language language, languageName; languageNames)\n\t{\n\t\thtml.put(`<option value=\"`, text(language), `\"`, language == currentLanguage ? ` selected` : null, `>`); html.putEncodedEntities(languageName); html.put(`</option>`);\n\t}\n\thtml.put(\n\t\t`</select><br>` ~\n\n\t\t_!`View mode:`, ` <select name=\"groupviewmode\">`\n\t);\n\tauto currentMode = userSettings.groupViewMode;\n\tforeach (mode; allViewModes)\n\t\thtml.put(`<option value=\"`, mode, `\"`, mode == currentMode ? ` selected` : null, `>`, viewModeName(mode), `</option>`);\n\thtml.put(\n\t\t`</select><br>` ~\n\n\t\t`<input type=\"checkbox\" name=\"enable-keynav\" id=\"enable-keynav\"`, userSettings.enableKeyNav == \"true\" ? ` checked` : null, `>` ~\n\t\t`<label for=\"enable-keynav\">`, _!`Enable keyboard shortcuts`, `</label> (<a href=\"/help#keynav\">?</a>)<br>` ~\n\n\t\t`<span title=\"`, _!`Automatically open messages after selecting them.`, `&#13;&#10;`, _!`Applicable to threaded, horizontal-split and vertical-split view modes.`, `\">` ~\n\t\t\t`<input type=\"checkbox\" name=\"auto-open\" id=\"auto-open\"`, userSettings.autoOpen == \"true\" ? ` checked` : null, `>` ~\n\t\t\t`<label for=\"auto-open\">`, _!`Focus follows message`, `</label>` ~\n\t\t`</span><br>`);\n\n\tif (haveMarkdown) html.put(\n\t\t`<span title=\"`, _!`Render Markdown posts as HTML. If disabled, they will just be shown as-is, in plain text.`, `\">` ~\n\t\t\t`<input type=\"checkbox\" name=\"render-markdown\" id=\"render-markdown\"`, userSettings.renderMarkdown == \"true\" ? ` checked` : null, `>` ~\n\t\t\t`<label for=\"render-markdown\">`, _!`Render Markdown`, `</label>` ~\n\t\t`</span> (<a href=\"/help#markdown\">?</a>)<br>`);\n\n\thtml.put(\n\t\t`<p>` ~\n\t\t\t`<input type=\"submit\" name=\"action-save\" value=\"`, _!`Save`, `\">` ~\n\t\t\t`<input type=\"submit\" name=\"action-cancel\" value=\"`, _!`Cancel`, `\">` ~\n\t\t`</p>` ~\n\n\t\t`<hr>` ~\n\n\t\t`<h2>`, _!`Subscriptions`, `</h2>`\n\t);\n\tif (user.isLoggedIn())\n\t{\n\t\tauto subscriptions = getUserSubscriptions(user.getName());\n\t\tif (subscriptions.length)\n\t\t{\n\t\t\thtml.put(`<table id=\"subscriptions\">`);\n\t\t\thtml.put(`<tr><th>`, _!`Subscription`, `</th><th colspan=\"4\">`, _!`Actions`, `</th></tr>`);\n\t\t\tforeach (subscription; subscriptions)\n\t\t\t{\n\t\t\t\thtml.put(\n\t\t\t\t\t`<tr>` ~\n\t\t\t\t\t\t`<td>`), subscription.trigger.putDescription(html), html.put(`</td>` ~\n\t\t\t\t\t\t`<td><input type=\"submit\" form=\"subscriptions-form\" name=\"action-subscription-view-`  , subscription.id, `\" value=\"`, _!`View posts`, `\"></td>` ~\n\t\t\t\t\t\t`<td><input type=\"submit\" form=\"subscriptions-form\" name=\"action-subscription-feed-`  , subscription.id, `\" value=\"`, _!`Get ATOM feed`, `\"></td>` ~\n\t\t\t\t\t\t`<td><input type=\"submit\" form=\"subscriptions-form\" name=\"action-subscription-edit-`  , subscription.id, `\" value=\"`, _!`Edit`, `\"></td>` ~\n\t\t\t\t\t\t`<td><input type=\"submit\" form=\"subscriptions-form\" name=\"action-subscription-delete-`, subscription.id, `\" value=\"`, _!`Delete`, `\"></td>` ~\n\t\t\t\t\t`</tr>`\n\t\t\t\t);\n\t\t\t}\n\t\t\thtml.put(\n\t\t\t\t`</table>`\n\t\t\t);\n\t\t}\n\t\telse\n\t\t\thtml.put(`<p>`, _!`You have no subscriptions.`, `</p>`);\n\t\thtml.put(\n\t\t\t`<p><input type=\"submit\" form=\"subscriptions-form\" name=\"action-subscription-create-content\" value=\"`, _!`Create new content alert subscription`, `\"></p>`\n\t\t);\n\t}\n\telse\n\t\thtml.put(`<p>`, _!`Please %slog in%s to manage your subscriptions and account settings.`.format(`<a href=\"/loginform\">`, `</a>`), `</p>`);\n\n\thtml.put(\n\t\t`</form>` ~\n\n\t\t`<form method=\"post\" id=\"subscriptions-form\">` ~\n\t\t`<input type=\"hidden\" name=\"referrer\" value=\"`), html.putEncodedEntities(settingsReferrer), html.put(`\">` ~\n\t\t`<input type=\"hidden\" name=\"secret\" value=\"`, userSettings.secret, `\">` ~\n\t\t`</form>`\n\t);\n\n\tif (user.isLoggedIn())\n\t{\n\t\thtml.put(\n\t\t\t`<hr>` ~\n\t\t\t`<h2>`, _!`Account settings`, `</h2>` ~\n\t\t\t`<table id=\"account-settings\">` ~\n\t\t\t`<tr><td>`, _!`Change the password used to log in to this account.`       , `</td><td><form action=\"/change-password\" method=\"post\"><input type=\"submit\" value=\"`, _!`Change password`, `\"></form></td></tr>` ~\n\t\t\t`<tr><td>`, _!`Download a file containing all data tied to this account.` , `</td><td><form action=\"/export-account\"  method=\"post\"><input type=\"submit\" value=\"`, _!`Export data`,     `\"></form></td></tr>` ~\n\t\t\t`<tr><td>`, _!`Permanently delete this account.`                          , `</td><td><form action=\"/delete-account\"  method=\"post\"><input type=\"submit\" value=\"`, _!`Delete account`,  `\"></form></td></tr>` ~\n\t\t\t`</table>`\n\t\t);\n\t}\n}\n\nvoid discussionSubscriptionEdit(Subscription subscription)\n{\n\thtml.put(\n\t\t`<form action=\"/settings\" method=\"post\" id=\"subscription-form\">` ~\n\t\t`<h1>`, _!`Edit subscription`, `</h1>` ~\n\t\t`<input type=\"hidden\" name=\"referrer\" value=\"`), html.putEncodedEntities(settingsReferrer), html.put(`\">` ~\n\t\t`<input type=\"hidden\" name=\"secret\" value=\"`, userSettings.secret, `\">` ~\n\t\t`<input type=\"hidden\" name=\"id\" value=\"`, subscription.id, `\">` ~\n\n\t\t`<h2>`, _!`Condition`, `</h2>` ~\n\t\t`<input type=\"hidden\" name=\"trigger-type\" value=\"`, subscription.trigger.type, `\">`\n\t);\n\tsubscription.trigger.putEditHTML(html);\n\n\thtml.put(\n\t\t`<h2>`, _!`Actions`, `</h2>`\n\t);\n\n\tforeach (action; subscription.actions)\n\t\taction.putEditHTML(html);\n\n\thtml.put(\n\t\t`<p>` ~\n\t\t\t`<input type=\"submit\" name=\"action-subscription-save\" value=\"`, _!`Save`, `\">` ~\n\t\t\t`<input type=\"submit\" name=\"action-subscription-cancel\" value=\"`, _!`Cancel`, `\">` ~\n\t\t`</p>` ~\n\t\t`</form>`\n\t);\n}\n\nvoid discussionChangePassword(UrlParameters postVars)\n{\n\tenforce(user.isLoggedIn(), _!\"This action is only meaningful for logged-in users.\");\n\thtml.put(`<h1>`, _!`Change password`, `</h1>`);\n\tif (\"old-password\" !in postVars)\n\t{\n\t\thtml.put(\n\t\t\t`<p>`, _!`Here you can change the password used to log in to this %s account.`.format(encodeHtmlEntities(site.name)), `</p>` ~\n\t\t\t`<p>`, _!`Please pick your new password carefully, as there are no password recovery options.`, `</p>` ~\n\t\t\t`<form method=\"post\">` ~\n\t\t\t`<table>` ~\n\t\t\t`<tr><td>`, _!`Current password:`      , `</td><td><input name=\"old-password\"   type=\"password\"></td></tr>` ~\n\t\t\t`<tr><td>`, _!`New password:`          , `</td><td><input name=\"new-password\"   type=\"password\"></td></tr>` ~\n\t\t\t`<tr><td>`, _!`New password (confirm):`, `</td><td><input name=\"new-password-2\" type=\"password\"></td></tr>` ~\n\t\t\t`</table>` ~\n\t\t\t`<input type=\"submit\" value=\"`, _!`Change password`, `\">` ~\n\t\t\t`<input type=\"hidden\" name=\"secret\" value=\"`); html.putEncodedEntities(userSettings.secret); html.put(`\">` ~\n\t\t\t`</form>`\n\t\t);\n\t}\n\telse\n\t{\n\t\tif (postVars.get(\"secret\", \"\") != userSettings.secret)\n\t\t\tthrow new Exception(_!\"XSRF secret verification failed\");\n\t\tuser.checkPassword(postVars.aaGet(\"old-password\")).enforce(_!\"The current password you entered is incorrect\");\n\t\tenforce(postVars.aaGet(\"new-password\") == postVars.aaGet(\"new-password-2\"), _!\"New passwords do not match\");\n\t\tuser.changePassword(postVars.aaGet(\"new-password\"));\n\t\thtml.put(\n\t\t\t`<p>`, _!`Password successfully changed.`, `</p>`\n\t\t);\n\t}\n}\n\nJSONFragment discussionExportAccount(UrlParameters postVars)\n{\n\tenforce(user.isLoggedIn(), _!\"This action is only meaningful for logged-in users.\");\n\thtml.put(`<h1>`, _!`Export account data`, `</h1>`);\n\tif (\"do-export\" !in postVars)\n\t{\n\t\thtml.put(\n\t\t\t`<p>`, _!`Here you can export the information regarding your account from the %s database.`.format(encodeHtmlEntities(site.name)), `</p>` ~\n\t\t\t`<form method=\"post\">` ~\n\t\t\t`<input type=\"submit\" name=\"do-export\" value=\"`, _!`Export`, `\">` ~\n\t\t\t`<input type=\"hidden\" name=\"secret\" value=\"`); html.putEncodedEntities(userSettings.secret); html.put(`\">` ~\n\t\t\t`</form>`\n\t\t);\n\t\treturn JSONFragment.init;\n\t}\n\telse\n\t{\n\t\tif (postVars.get(\"secret\", \"\") != userSettings.secret)\n\t\t\tthrow new Exception(_!\"XSRF secret verification failed\");\n\t\tauto data = user.exportData;\n\t\treturn data.toJson.JSONFragment;\n\t}\n}\n\nvoid discussionDeleteAccount(UrlParameters postVars)\n{\n\tenforce(user.isLoggedIn(), _!\"This action is only meaningful for logged-in users.\");\n\thtml.put(`<h1>`, _!`Delete account`, `</h1>`);\n\tif (\"username\" !in postVars)\n\t{\n\t\thtml.put(\n\t\t\t`<p>`, _!`Here you can permanently delete your %s account and associated data from the database.`.format(encodeHtmlEntities(site.name)), `</p>` ~\n\t\t\t`<p>`, _!`After deletion, the account username will become available for registration again.`, `</p>` ~\n\t\t\t`<p>`, _!`To confirm deletion, please enter your account username and password.`, `</p>` ~\n\t\t\t`<form method=\"post\">` ~\n\t\t\t`<table>` ~\n\t\t\t`<tr><td>`, _!`Account username:`, `</td><td><input name=\"username\"></td></tr>` ~\n\t\t\t`<tr><td>`, _!`Account password:`, `</td><td><input name=\"password\" type=\"password\"></td></tr>` ~\n\t\t\t`</table>` ~\n\t\t\t`<input type=\"submit\" value=\"`, _!`Delete this account`, `\">` ~\n\t\t\t`<input type=\"hidden\" name=\"secret\" value=\"`); html.putEncodedEntities(userSettings.secret); html.put(`\">` ~\n\t\t\t`</form>`\n\t\t);\n\t}\n\telse\n\t{\n\t\tif (postVars.get(\"secret\", \"\") != userSettings.secret)\n\t\t\tthrow new Exception(_!\"XSRF secret verification failed\");\n\t\tenforce(postVars.aaGet(\"username\") == user.getName(), _!\"The username you entered does not match the current logged-in account\");\n\t\tuser.checkPassword(postVars.aaGet(\"password\")).enforce(_!\"The password you entered is incorrect\");\n\n\t\tuser.deleteAccount();\n\t\thtml.put(\n\t\t\t`<p>`, _!`Account successfully deleted!`, `</p>`\n\t\t);\n\t}\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/subscription.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// User subscriptions.\nmodule dfeed.web.web.view.subscription;\n\nimport std.conv : text;\nimport std.format;\n\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.loc;\nimport dfeed.database : query, selectValue;\nimport dfeed.sinks.subscriptions;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.part.pager : POSTS_PER_PAGE, pager, getPageCount;\nimport dfeed.web.web.part.post : formatPost;\nimport dfeed.web.web.postinfo : getPost;\nimport dfeed.web.web.user;\n\nvoid discussionSubscriptionPosts(string subscriptionID, int page, out string title)\n{\n\tauto subscription = getUserSubscription(user.getName(), subscriptionID);\n\ttitle = _!\"View subscription:\" ~ \" \" ~ subscription.trigger.getTextDescription();\n\n\tenum postsPerPage = POSTS_PER_PAGE;\n\thtml.put(`<h1>`); html.putEncodedEntities(title);\n\tif (page != 1)\n\t\thtml.put(\" \", _!\"(page %d)\".format(page));\n\thtml.put(\"</h1>\");\n\n\tauto postCount = query!\"SELECT COUNT(*) FROM [SubscriptionPosts] WHERE [SubscriptionID] = ?\".iterate(subscriptionID).selectValue!int;\n\n\tif (postCount == 0)\n\t{\n\t\thtml.put(`<p>`, _!`It looks like there's nothing here! No posts matched this subscription so far.`, `</p>`);\n\t}\n\n\tforeach (string messageID; query!\"SELECT [MessageID] FROM [SubscriptionPosts] WHERE [SubscriptionID] = ? ORDER BY [Time] DESC LIMIT ? OFFSET ?\"\n\t\t\t\t\t\t.iterate(subscriptionID, postsPerPage, (page-1)*postsPerPage))\n\t{\n\t\tauto post = getPost(messageID);\n\t\tif (post)\n\t\t\tformatPost(post, null);\n\t\telse\n\t\t\tquery!\"DELETE FROM [SubscriptionPosts] WHERE [SubscriptionID] = ? AND [MessageID] = ?\".exec(subscriptionID, messageID);\n\t}\n\n\tif (page != 1 || postCount > postsPerPage)\n\t{\n\t\thtml.put(`<table class=\"forum-table post-pager\">`);\n\t\tpager(null, page, getPageCount(postCount, postsPerPage));\n\t\thtml.put(`</table>`);\n\t}\n\n\thtml.put(\n\t\t`<form style=\"display:block;float:right;margin-top:0.5em\" action=\"/settings\" method=\"post\">` ~\n\t\t\t`<input type=\"hidden\" name=\"secret\" value=\"`), html.putEncodedEntities(userSettings.secret), html.put(`\">` ~\n\t\t\t`<input type=\"submit\" name=\"action-subscription-edit-`), html.putEncodedEntities(subscriptionID), html.put(`\" value=\"`, _!`Edit subscription`, `\">` ~\n\t\t`</form>` ~\n\t\t`<div style=\"clear:right\"></div>`\n\t);\n}\n\nvoid discussionSubscriptionUnsubscribe(string subscriptionID)\n{\n\tauto subscription = getSubscription(subscriptionID);\n\tsubscription.unsubscribe();\n\thtml.put(\n\t\t`<h1>`, _!`Unsubscribe`, `</h1>` ~\n\t\t`<p>`, _!`This subscription has been deactivated.`, `</p>` ~\n\t\t`<p>`, _!`If you did not intend to do this, you can reactivate the subscription's actions on your %ssettings page%s.`.format(`<a href=\"/settings\">`, `</a>`), `</p>`\n\t);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/thread.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Formatting threads.\nmodule dfeed.web.web.view.thread;\n\nimport std.conv : text;\nimport std.datetime.systime : SysTime;\nimport std.exception : enforce;\nimport std.format;\n\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.loc;\nimport dfeed.database : query;\nimport dfeed.groups : GroupInfo;\nimport dfeed.message : idToUrl, Rfc850Post, getGroup;\nimport dfeed.web.web.page : html, NotFoundException;\nimport dfeed.web.web.part.pager : pager, getPageCount, POSTS_PER_PAGE;\nimport dfeed.web.web.part.post : formatPost;\nimport dfeed.web.web.part.thread;\nimport dfeed.web.web.postinfo : PostInfo, getPostInfo;\nimport dfeed.web.web.user : user;\n\n// ***********************************************************************\n\nvoid postPager(string threadID, int page, int postCount)\n{\n\tpager(idToUrl(threadID, \"thread\"), page, getPageCount(postCount, POSTS_PER_PAGE));\n}\n\nint getPostCount(string threadID)\n{\n\tforeach (int count; query!\"SELECT COUNT(*) FROM `Posts` WHERE `ThreadID` = ?\".iterate(threadID))\n\t\treturn count;\n\tassert(0);\n}\n\nint getPostThreadIndex(string threadID, SysTime postTime)\n{\n\tforeach (int index; query!\"SELECT COUNT(*) FROM `Posts` WHERE `ThreadID` = ? AND `Time` < ? ORDER BY `Time` ASC\".iterate(threadID, postTime.stdTime))\n\t\treturn index;\n\tassert(0);\n}\n\nint getPostThreadIndex(string postID)\n{\n\tauto post = getPostInfo(postID);\n\tenforce(post, _!\"No such post:\" ~ \" \" ~ postID);\n\treturn getPostThreadIndex(post.threadID, post.time);\n}\n\nstring getPostAtThreadIndex(string threadID, int index)\n{\n\tforeach (string id; query!\"SELECT `ID` FROM `Posts` WHERE `ThreadID` = ? ORDER BY `Time` ASC LIMIT 1 OFFSET ?\".iterate(threadID, index))\n\t\treturn id;\n\tthrow new NotFoundException(format(_!\"Post #%d of thread %s not found\", index, threadID));\n}\n\nvoid discussionThread(string id, int page, out GroupInfo groupInfo, out string title, out string authorEmail, bool markAsRead)\n{\n\tenforce(page >= 1, _!\"Invalid page\");\n\n\tauto postCount = getPostCount(id);\n\n\tif (page == 1 && postCount > 2)\n\t{\n\t\t// Expandable overview\n\n\t\thtml.put(\n\t\t\t`<table id=\"thread-overview\" class=\"forum-table forum-expand-container\">` ~\n\t\t\t`<tr class=\"group-index-header\"><th>`);\n\n\t\tauto pageCount = getPageCount(postCount, POSTS_PER_PAGE);\n\t\tif (pageCount > 1)\n\t\t{\n\t\t\thtml.put(\n\t\t\t\t`<div class=\"thread-overview-pager forum-expand-container\">`,\n\t\t\t\t_!`Jump to page:`, ` <b>1</b> `\n\t\t\t);\n\n\t\t\tauto threadUrl = idToUrl(id, \"thread\");\n\n\t\t\tvoid pageLink(int n)\n\t\t\t{\n\t\t\t\tauto nStr = text(n);\n\t\t\t\thtml.put(`<a href=\"`); html.putEncodedEntities(threadUrl); html.put(`?page=`, nStr, `\">`, nStr, `</a> `);\n\t\t\t}\n\n\t\t\tif (pageCount < 4)\n\t\t\t{\n\t\t\t\tforeach (p; 2..pageCount+1)\n\t\t\t\t\tpageLink(p);\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tpageLink(2);\n\t\t\t\thtml.put(`&hellip; `);\n\t\t\t\tpageLink(pageCount);\n\n\t\t\t\thtml.put(\n\t\t\t\t\t`<a class=\"thread-overview-pager forum-expand-toggle\">&nbsp;</a>` ~\n\t\t\t\t\t`<div class=\"thread-overview-pager-expanded forum-expand-content\">` ~\n\t\t\t\t\t`<form action=\"`); html.putEncodedEntities(threadUrl); html.put(`\">` ~\n\t\t\t\t\t_!`Page`, ` <input name=\"page\" class=\"thread-overview-pager-pageno\"> <input type=\"submit\" value=\"`, _!`Go`, `\">` ~\n\t\t\t\t\t`</form>` ~\n\t\t\t\t\t`</div>`\n\t\t\t\t);\n\t\t\t}\n\n\t\t\thtml.put(\n\t\t\t\t`</div>`\n\t\t\t);\n\t\t}\n\n\t\thtml.put(\n\t\t\t`<a class=\"forum-expand-toggle\">`, _!`Thread overview`, `</a>` ~\n\t\t\t`</th></tr>`,\n\t\t\t`<tr class=\"forum-expand-content\"><td class=\"group-threads-cell\"><div class=\"group-threads\"><table>`);\n\t\tformatThreadedPosts(getThreadPosts(id), false);\n\t\thtml.put(`</table></div></td></tr></table>`);\n\n\t}\n\n\tRfc850Post[] posts;\n\tforeach (int rowid, string postID, string message;\n\t\t\tquery!\"SELECT `ROWID`, `ID`, `Message` FROM `Posts` WHERE `ThreadID` = ? ORDER BY `Time` ASC LIMIT ? OFFSET ?\"\n\t\t\t.iterate(id, POSTS_PER_PAGE, (page-1)*POSTS_PER_PAGE))\n\t\tposts ~= new Rfc850Post(message, postID, rowid, id);\n\n\tRfc850Post[string] knownPosts;\n\tforeach (post; posts)\n\t\tknownPosts[post.id] = post;\n\n\tenforce(posts.length, _!\"Thread not found\");\n\n\tgroupInfo   = posts[0].getGroup();\n\ttitle       = posts[0].subject;\n\tauthorEmail = posts[0].authorEmail;\n\n\thtml.put(`<div id=\"thread-posts\">`);\n\tforeach (post; posts)\n\t\tformatPost(post, knownPosts, markAsRead);\n\thtml.put(`</div>`);\n\n\tif (page > 1 || postCount > POSTS_PER_PAGE)\n\t{\n\t\thtml.put(`<table class=\"forum-table post-pager\">`);\n\t\tpostPager(id, page, postCount);\n\t\thtml.put(`</table>`);\n\t}\n}\n\nstring discussionFirstUnread(string threadID)\n{\n\tforeach (int rowid, string id; query!\"SELECT `ROWID`, `ID` FROM `Posts` WHERE `ThreadID` = ? ORDER BY `Time` ASC\".iterate(threadID))\n\t\tif (!user.isRead(rowid))\n\t\t\treturn idToUrl(id);\n\tauto numPages = getPageCount(getPostCount(threadID), POSTS_PER_PAGE);\n\tenforce(numPages, _!\"Thread not found\");\n\treturn idToUrl(threadID, \"thread\", numPages);\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/userprofile.d",
    "content": "/*  Copyright (C) 2025  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// User profile view.\nmodule dfeed.web.web.view.userprofile;\n\nimport std.algorithm.sorting : sort;\nimport std.datetime.systime : SysTime;\nimport std.datetime.timezone : UTC;\nimport std.format : format;\n\nimport ae.net.ietf.url : encodeUrlParameter;\nimport ae.utils.text.html : encodeHtmlEntities;\n\nimport dfeed.database : query, selectValue;\nimport dfeed.groups : getGroupInfo;\nimport dfeed.loc;\nimport dfeed.message : idToUrl;\nimport dfeed.web.web.page : html, NotFoundException;\nimport dfeed.web.web.part.gravatar : getGravatarHash, putGravatar;\nimport dfeed.web.web.part.profile : getProfileHash, profileUrl;\nimport dfeed.web.web.part.strings : summarizeTime, formatShortTime;\nimport dfeed.web.web.statics : staticPath;\nimport dfeed.web.web.user : user;\n\n/// Look up author name and email from a profile hash.\n/// Returns null if not found.\nstring[2] lookupAuthorByHash(string profileHash)\n{\n\t// Iterate through distinct (Author, AuthorEmail) pairs and find matching hash.\n\t// This is O(n) in the number of unique authors, but fast in practice\n\t// since hash computation is cheap and we stop at first match.\n\tforeach (string author, string email; query!\"SELECT DISTINCT [Author], [AuthorEmail] FROM [Posts]\".iterate())\n\t{\n\t\tif (getProfileHash(author, email) == profileHash)\n\t\t\treturn [author, email];\n\t}\n\treturn [null, null];\n}\n\n/// Display user profile page.\nvoid discussionUserProfile(string profileHash, out string title, out string author)\n{\n\tauto authorInfo = lookupAuthorByHash(profileHash);\n\tauthor = authorInfo[0];\n\tstring authorEmail = authorInfo[1];\n\n\tif (author is null)\n\t\tthrow new NotFoundException(_!\"User not found\");\n\n\ttitle = author;\n\n\t// Get post count and time range\n\tint postCount;\n\tlong firstPostTime, lastPostTime;\n\tforeach (int count, long minTime, long maxTime;\n\t\tquery!\"SELECT COUNT(*), MIN([Time]), MAX([Time]) FROM [Posts] WHERE [Author] = ? AND [AuthorEmail] = ?\"\n\t\t\t.iterate(author, authorEmail))\n\t{\n\t\tpostCount = count;\n\t\tfirstPostTime = minTime;\n\t\tlastPostTime = maxTime;\n\t}\n\n\t// Get thread vs reply breakdown\n\tint threadCount = query!\"SELECT COUNT(*) FROM [Posts] WHERE [Author] = ? AND [AuthorEmail] = ? AND ([ParentID] IS NULL OR [ParentID] = '')\"\n\t\t.iterate(author, authorEmail)\n\t\t.selectValue!int;\n\tint replyCount = postCount - threadCount;\n\n\t// Get most active group\n\tstring mostActiveGroup;\n\tint mostActiveGroupCount;\n\tforeach (string grp, int cnt;\n\t\tquery!\"SELECT [Group], COUNT(*) as cnt FROM [Groups] WHERE [ID] IN (SELECT [ID] FROM [Posts] WHERE [Author] = ? AND [AuthorEmail] = ?) GROUP BY [Group] ORDER BY cnt DESC LIMIT 1\"\n\t\t\t.iterate(author, authorEmail))\n\t{\n\t\tmostActiveGroup = grp;\n\t\tmostActiveGroupCount = cnt;\n\t}\n\n\tstring gravatarHash = getGravatarHash(authorEmail);\n\n\t// Profile header\n\thtml.put(`<div class=\"user-profile\">`);\n\thtml.put(`<div class=\"user-profile-header\">`);\n\n\t// Gravatar\n\tstring gravatarUrl = \"https://www.gravatar.com/\" ~ gravatarHash;\n\thtml.put(`<div class=\"user-profile-avatar\">`);\n\tputGravatar(gravatarHash, author, gravatarUrl,\n\t\t_!`%s's Gravatar profile`.format(author), null, 128);\n\thtml.put(`<div class=\"user-profile-actions\">`);\n\thtml.put(`<a class=\"actionlink picturelink\" href=\"`, gravatarUrl, `\" title=\"`, _!`View Gravatar profile`, `\">`);\n\thtml.put(`<img src=\"`, staticPath(\"/images/picture.png\"), `\">`, _!`Gravatar profile`, `</a>`);\n\tif (user.isLoggedIn())\n\t{\n\t\thtml.put(` `);\n\t\thtml.put(`<a class=\"actionlink subscribelink\" href=\"/subscribe-user/`, profileHash, `\" title=\"`, _!`Subscribe to this user's posts`, `\">`);\n\t\thtml.put(`<img src=\"`, staticPath(\"/images/star.png\"), `\">`, _!`Subscribe`, `</a>`);\n\t}\n\thtml.put(`</div>`);\n\thtml.put(`</div>`);\n\n\t// Name and basic info\n\thtml.put(`<div class=\"user-profile-info\">`);\n\thtml.put(`<h1>`);\n\thtml.put(encodeHtmlEntities(author));\n\thtml.put(`</h1>`);\n\n\t// Stats table\n\thtml.put(`<table class=\"user-profile-stats\">`);\n\n\t// Post count\n\thtml.put(`<tr><td>`, _!`Posts:`, `</td><td>`, format(\"%d\", postCount), `</td></tr>`);\n\n\t// Thread/reply breakdown\n\thtml.put(`<tr><td>`, _!`Threads started:`, `</td><td>`, format(\"%d\", threadCount), `</td></tr>`);\n\thtml.put(`<tr><td>`, _!`Replies:`, `</td><td>`, format(\"%d\", replyCount), `</td></tr>`);\n\n\t// First post (tenure)\n\tif (firstPostTime)\n\t{\n\t\tauto firstTime = SysTime(firstPostTime, UTC());\n\t\thtml.put(`<tr><td>`, _!`First post:`, `</td><td>`, formatShortTime(firstTime, false), `</td></tr>`);\n\t}\n\n\t// Last post (last seen)\n\tif (lastPostTime)\n\t{\n\t\tauto lastTime = SysTime(lastPostTime, UTC());\n\t\thtml.put(`<tr><td>`, _!`Last seen:`, `</td><td>`, summarizeTime(lastTime), `</td></tr>`);\n\t}\n\n\t// Most active group\n\tif (mostActiveGroup.length)\n\t{\n\t\tauto groupInfo = getGroupInfo(mostActiveGroup);\n\t\tstring groupName = groupInfo ? groupInfo.publicName : mostActiveGroup;\n\t\tstring groupUrl = groupInfo ? \"/group/\" ~ groupInfo.urlName : \"#\";\n\t\thtml.put(`<tr><td>`, _!`Most active in:`, `</td><td><a href=\"`, groupUrl, `\">`, encodeHtmlEntities(groupName), `</a></td></tr>`);\n\t}\n\n\thtml.put(`</table>`);\n\n\thtml.put(`</div>`); // user-profile-info\n\thtml.put(`</div>`); // user-profile-header\n\n\t// \"See also\" section for related profiles\n\tstatic struct RelatedProfile\n\t{\n\t\tstring author;\n\t\tstring email;\n\t\tint postCount;\n\t\tlong lastPostTime;\n\t}\n\n\tRelatedProfile[] relatedProfiles;\n\n\t// Same name, different email\n\tforeach (string otherAuthor, string otherEmail, int cnt, long lastPost;\n\t\tquery!\"SELECT [Author], [AuthorEmail], COUNT(*) as cnt, MAX([Time]) as lastPost FROM [Posts] WHERE [Author] = ? AND [AuthorEmail] != ? GROUP BY [Author], [AuthorEmail]\"\n\t\t\t.iterate(author, authorEmail))\n\t{\n\t\trelatedProfiles ~= RelatedProfile(otherAuthor, otherEmail, cnt, lastPost);\n\t}\n\n\t// Same email, different name\n\tforeach (string otherAuthor, string otherEmail, int cnt, long lastPost;\n\t\tquery!\"SELECT [Author], [AuthorEmail], COUNT(*) as cnt, MAX([Time]) as lastPost FROM [Posts] WHERE [AuthorEmail] = ? AND [Author] != ? GROUP BY [Author], [AuthorEmail]\"\n\t\t\t.iterate(authorEmail, author))\n\t{\n\t\trelatedProfiles ~= RelatedProfile(otherAuthor, otherEmail, cnt, lastPost);\n\t}\n\n\t// Sort by last seen, newest first\n\trelatedProfiles.sort!((a, b) => a.lastPostTime > b.lastPostTime);\n\n\t// Recent posts section\n\thtml.put(`<div class=\"user-profile-posts\">`);\n\thtml.put(`<h2>`, _!`Recent posts`, `</h2>`);\n\n\tenum recentPostsLimit = 10;\n\tint recentCount = 0;\n\n\thtml.put(`<table class=\"forum-table\">`);\n\thtml.put(`<tr><th>`, _!`Subject`, `</th><th>`, _!`Group`, `</th><th>`, _!`Date`, `</th></tr>`);\n\n\tforeach (string id, string subject, string grp, long time, int rowid;\n\t\tquery!\"SELECT p.[ID], p.[Subject], g.[Group], p.[Time], p.[ROWID] FROM [Posts] p LEFT JOIN [Groups] g ON p.[ID] = g.[ID] WHERE p.[Author] = ? AND p.[AuthorEmail] = ? ORDER BY p.[Time] DESC LIMIT ?\"\n\t\t\t.iterate(author, authorEmail, recentPostsLimit))\n\t{\n\t\trecentCount++;\n\t\tauto postTime = SysTime(time, UTC());\n\t\tauto groupInfo = getGroupInfo(grp);\n\t\tstring groupName = groupInfo ? groupInfo.publicName : grp;\n\n\t\thtml.put(`<tr>`);\n\t\thtml.put(`<td><a class=\"`, user.isRead(rowid) ? \"forum-read\" : \"forum-unread\", `\" href=\"`);\n\t\thtml.put(encodeHtmlEntities(idToUrl(id)));\n\t\thtml.put(`\">`);\n\t\thtml.put(encodeHtmlEntities(subject));\n\t\thtml.put(`</a></td>`);\n\t\thtml.put(`<td>`, encodeHtmlEntities(groupName), `</td>`);\n\t\thtml.put(`<td>`, summarizeTime(postTime), `</td>`);\n\t\thtml.put(`</tr>`);\n\t}\n\n\thtml.put(`</table>`);\n\n\t// \"View all posts\" link (only for logged-in users to avoid exposing email in URL)\n\tif (postCount > recentPostsLimit && user.isLoggedIn())\n\t{\n\t\tstring searchUrl = \"/search?author=\" ~ encodeUrlParameter(author) ~ \"&authoremail=\" ~ encodeUrlParameter(authorEmail);\n\t\thtml.put(`<p><a href=\"`, searchUrl, `\">`, _!`View all %d posts`.format(postCount), ` &raquo;</a></p>`);\n\t}\n\n\thtml.put(`</div>`); // user-profile-posts\n\n\t// \"See also\" section for related profiles\n\tif (relatedProfiles.length)\n\t{\n\t\thtml.put(`<div class=\"user-profile-seealso\">`);\n\t\thtml.put(`<h2>`, _!`See also`, `</h2>`);\n\t\thtml.put(`<table class=\"forum-table\">`);\n\t\thtml.put(`<tr><th>`, _!`User`, `</th><th>`, _!`Last seen`, `</th></tr>`);\n\n\t\tforeach (ref profile; relatedProfiles)\n\t\t{\n\t\t\tauto profileGravatarHash = getGravatarHash(profile.email);\n\t\t\tauto lastTime = SysTime(profile.lastPostTime, UTC());\n\n\t\t\thtml.put(`<tr>`);\n\t\t\thtml.put(`<td class=\"seealso-user\">`);\n\t\t\tputGravatar(profileGravatarHash, profile.author, profileUrl(profile.author, profile.email),\n\t\t\t\t_!`%s's profile`.format(profile.author), `class=\"forum-postsummary-gravatar\" `);\n\t\t\thtml.put(`<div class=\"truncated\"><a class=\"forum-postsummary-subject\" href=\"`, profileUrl(profile.author, profile.email), `\">`);\n\t\t\thtml.put(encodeHtmlEntities(profile.author));\n\t\t\thtml.put(`</a></div>`);\n\t\t\thtml.put(`<div class=\"truncated forum-postsummary-info\">`, format(_!`%d posts`, profile.postCount), `</div>`);\n\t\t\thtml.put(`</td>`);\n\t\t\thtml.put(`<td class=\"seealso-lastseen\">`, summarizeTime(lastTime), `</td>`);\n\t\t\thtml.put(`</tr>`);\n\t\t}\n\n\t\thtml.put(`</table>`);\n\t\thtml.put(`</div>`); // user-profile-seealso\n\t}\n\n\thtml.put(`</div>`); // user-profile\n}\n"
  },
  {
    "path": "src/dfeed/web/web/view/widgets.d",
    "content": "﻿/*  Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2022  Vladimir Panteleev <vladimir@thecybershadow.net>\n *\n *  This program is free software: you can redistribute it and/or modify\n *  it under the terms of the GNU Affero General Public License as\n *  published by the Free Software Foundation, either version 3 of the\n *  License, or (at your option) any later version.\n *\n *  This program is distributed in the hope that it will be useful,\n *  but WITHOUT ANY WARRANTY; without even the implied warranty of\n *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the\n *  GNU Affero General Public License for more details.\n *\n *  You should have received a copy of the GNU Affero General Public License\n *  along with this program.  If not, see <http://www.gnu.org/licenses/>.\n */\n\n/// Rendering of iframe widgets.\nmodule dfeed.web.web.view.widgets;\n\nimport std.algorithm.searching;\nimport std.format : format;\n\nimport ae.utils.xmllite : putEncodedEntities;\n\nimport dfeed.loc;\nimport dfeed.sinks.cache;\nimport dfeed.web.web.page : html;\nimport dfeed.web.web.perf;\nimport dfeed.web.web.part.gravatar : getGravatarHash, putGravatar;\nimport dfeed.web.web.part.profile : profileUrl;\nimport dfeed.web.web.part.strings : summarizeTime;\nimport dfeed.web.web.postinfo : PostInfo, getPostInfo;\nimport dfeed.web.web.statics;\nimport dfeed.database;\nimport dfeed.message;\nimport dfeed.web.web.config : config;\nimport dfeed.web.web.user : user;\n\nCached!(ActiveDiscussion[]) activeDiscussionsCache;\nCached!(string[]) latestAnnouncementsCache;\nenum framePostsLimit = 10;\n\nstatic struct ActiveDiscussion { string id; int postCount; }\n\nActiveDiscussion[] getActiveDiscussions()\n{\n\tenum PERF_SCOPE = \"getActiveDiscussions\"; mixin(MeasurePerformanceMixin);\n\tenum postCountLimit = 10;\n\tActiveDiscussion[] result;\n\tforeach (string group, string firstPostID; query!\"SELECT [Group], [ID] FROM [Threads] ORDER BY [Created] DESC LIMIT 100\".iterate())\n\t{\n\t\tif (config.activeDiscussionExclude.canFind(group))\n\t\t\tcontinue;\n\n\t\tint postCount;\n\t\tforeach (int count; query!\"SELECT COUNT(*) FROM `Posts` WHERE `ThreadID` = ?\".iterate(firstPostID))\n\t\t\tpostCount = count;\n\t\tif (postCount < postCountLimit)\n\t\t\tcontinue;\n\n\t\tresult ~= ActiveDiscussion(firstPostID, postCount);\n\t\tif (result.length == framePostsLimit)\n\t\t\tbreak;\n\t}\n\treturn result;\n}\n\nstring[] getLatestAnnouncements()\n{\n\tenum PERF_SCOPE = \"getLatestAnnouncements\"; mixin(MeasurePerformanceMixin);\n\tif (!config.announceGroup.length)\n\t\treturn null;\n\tstring[] result;\n\tforeach (string firstPostID; query!\"SELECT [ID] FROM [Threads] WHERE [Group] = ? ORDER BY [RowID] DESC LIMIT ?\".iterate(config.announceGroup, framePostsLimit))\n\t\tresult ~= firstPostID;\n\treturn result;\n}\n\nvoid summarizeFrameThread(PostInfo* info, string infoText)\n{\n\tif (info)\n\t\twith (*info)\n\t\t{\n\t\t\tputGravatar(getGravatarHash(info.authorEmail), author, profileUrl(author, authorEmail), _!`%s's profile`.format(author), `target=\"_top\" class=\"forum-postsummary-gravatar\" `);\n\t\t\thtml.put(\n\t\t\t\t`<a target=\"_top\" class=\"forum-postsummary-subject `, (user.isRead(rowid) ? \"forum-read\" : \"forum-unread\"), `\" href=\"`), html.putEncodedEntities(idToUrl(id)), html.put(`\">`), html.putEncodedEntities(subject), html.put(`</a><br>` ~\n\t\t\t\t`<div class=\"forum-postsummary-info\">`, infoText, `</div>`,\n\t\t\t\t_!`by`, ` <span class=\"forum-postsummary-author\">`), html.putEncodedEntities(author), html.put(`</span>`\n\t\t\t);\n\t\t\treturn;\n\t\t}\n\n\thtml.put(`<div class=\"forum-no-data\">-</div>`);\n}\n\nvoid discussionFrameAnnouncements()\n{\n\tif (!config.announceGroup.length)\n\t{\n\t\thtml.put(`<div class=\"forum-no-data\">`, _!`Announcements widget not configured`, `</div>`);\n\t\treturn;\n\t}\n\tauto latestAnnouncements = latestAnnouncementsCache(getLatestAnnouncements());\n\n\thtml.put(`<table class=\"forum-table\"><thead><tr><th>` ~\n\t\t`<a target=\"_top\" class=\"feed-icon\" title=\"`, _!`Subscribe`, `\" href=\"/feed/threads/`), html.putEncodedEntities(config.announceGroup), html.put(`\"><img src=\"`, staticPath(\"/images/rss.png\"),`\"></img></a>` ~\n\t\t`<a target=\"_top\" href=\"/group/`), html.putEncodedEntities(config.announceGroup), html.put(`\">`, _!`Latest announcements`, `</a>` ~\n\t\t`</th></tr></thead><tbody>`);\n\tforeach (row; latestAnnouncements)\n\t\tif (auto info = getPostInfo(row))\n\t\t\thtml.put(`<tr><td>`), summarizeFrameThread(info, summarizeTime(info.time)), html.put(`</td></tr>`);\n\thtml.put(`</tbody></table>`);\n}\n\nvoid discussionFrameDiscussions()\n{\n\tauto activeDiscussions = activeDiscussionsCache(getActiveDiscussions());\n\n\thtml.put(`<table class=\"forum-table\"><thead><tr><th><a target=\"_top\" href=\"/\">`, _!`Active discussions`, `</a></th></tr></thead><tbody>`);\n\tforeach (row; activeDiscussions)\n\t\tif (auto info = getPostInfo(row.id))\n\t\t\thtml.put(`<tr><td>`), summarizeFrameThread(info, \"%d posts\".format(row.postCount)), html.put(`</td></tr>`);\n\thtml.put(`</tbody></table>`);\n}\n\n"
  },
  {
    "path": "tests/captcha-screenshot.spec.ts",
    "content": "import { test, expect } from \"@playwright/test\";\nimport { execSync } from \"child_process\";\nimport path from \"path\";\n\nconst PROJECT_ROOT = path.resolve(__dirname, \"..\");\nconst DB_PATH = process.env.DFEED_DB || path.join(PROJECT_ROOT, \"data/db/dfeed.s3db\");\n\ntest(\"capture moderator journey view\", { timeout: 30000 }, async ({ page, context, baseURL }) => {\n  const timestamp = Date.now();\n  const modUsername = `mod${timestamp}`;\n\n  // Step 1: Register a moderator user first\n  await page.goto(\"/registerform\");\n  await page.fill(\"#loginform-username\", modUsername);\n  await page.fill(\"#loginform-password\", \"testpass123\");\n  await page.fill(\"#loginform-password2\", \"testpass123\");\n  await page.click('input[type=\"submit\"]');\n  await page.waitForURL(\"**/\");\n\n  console.log(`Registered moderator user: ${modUsername}`);\n\n  // Step 2: Update user to full moderator level (100) via SQL\n  // Level 90 = canApproveDrafts, Level 100 = canModerate (can access /moderate/ page)\n  const sqlCmd = `UPDATE Users SET Level=100 WHERE Username='${modUsername}';`;\n  console.log(`Running SQL: ${sqlCmd}`);\n  execSync(`sqlite3 \"${DB_PATH}\" \"${sqlCmd}\"`);\n  console.log(\"User promoted to moderator\");\n\n  // Step 3: Create a moderated draft as anonymous user\n  // Clear cookies to act as new anonymous user\n  await context.clearCookies();\n\n  await page.goto(\"/newpost/test\");\n  await expect(page.locator(\"#postform\")).toBeVisible();\n\n  // Fill form with hardspamtest (triggers CAPTCHA AND moderation)\n  await page.fill(\"#postform-name\", \"Test User\");\n  await page.fill(\"#postform-email\", \"test@example.com\");\n  await page.fill(\"#postform-subject\", `hardspamtest ${timestamp}`);\n  await page.fill(\"#postform-text\", \"Testing CAPTCHA question logging and moderation journey\");\n\n  // Submit to trigger CAPTCHA\n  await page.click('input[name=\"action-send\"]');\n\n  // Wait for CAPTCHA\n  const captchaCheckbox = page.locator('input[name=\"dummy_captcha_checkbox\"]');\n  await expect(captchaCheckbox).toBeVisible();\n\n  // Capture the draft ID (did) from the hidden form field before solving CAPTCHA\n  const draftId = await page.locator('input[name=\"did\"]').inputValue();\n  console.log(`Draft ID (did): ${draftId}`);\n\n  // Screenshot 1: CAPTCHA challenge\n  await page.screenshot({ path: path.join(__dirname, \"screenshot-captcha-1-challenge.png\"), fullPage: true });\n\n  // Solve CAPTCHA\n  await captchaCheckbox.check();\n  await page.click('input[name=\"action-send\"]');\n\n  // Wait for moderation notice\n  await expect(page.locator(\"body\")).toContainText(\"approved by a moderator\", { timeout: 10000 });\n\n  // Screenshot 2: Moderation notice\n  await page.screenshot({ path: path.join(__dirname, \"screenshot-captcha-2-moderation-notice.png\"), fullPage: true });\n\n  // Step 4: Log in as moderator\n  await page.goto(\"/loginform\");\n  await page.fill(\"#loginform-username\", modUsername);\n  await page.fill(\"#loginform-password\", \"testpass123\");\n  await page.click('input[type=\"submit\"]');\n  await page.waitForURL(\"**/\");\n\n  // Step 5: Navigate to the moderation approval page and approve the post\n  if (draftId) {\n    await page.goto(`/approve-moderated-draft/${draftId}`);\n    await page.waitForTimeout(500);\n\n    // Screenshot 3: Approval confirmation page\n    await page.screenshot({ path: path.join(__dirname, \"screenshot-captcha-3-approval-page.png\"), fullPage: true });\n\n    // Click Approve button\n    await page.click('input[name=\"approve\"]');\n    await page.waitForTimeout(1000);\n\n    // Screenshot 4: Post approved confirmation\n    await page.screenshot({ path: path.join(__dirname, \"screenshot-captcha-4-post-approved.png\"), fullPage: true });\n\n    // Extract the posting link to get the message ID\n    const viewLink = await page.locator('a:has-text(\"View posting\")').getAttribute('href');\n    console.log(`View posting link: ${viewLink}`);\n\n    if (viewLink) {\n      // Get the post ID from /posting/<postID>\n      const postIdMatch = viewLink.match(/posting\\/([a-z]+)/);\n      if (postIdMatch) {\n        const postId = postIdMatch[1];\n        // The message ID format is <postID@localhost>\n        const encodedMessageId = encodeURIComponent(`${postId}@localhost`);\n\n        // Navigate to the moderation page to see the journey view\n        await page.goto(`/moderate/${encodedMessageId}`);\n        await page.waitForTimeout(500);\n\n        // Screenshot 5: User Journey view with CAPTCHA question/answer\n        await page.screenshot({ path: path.join(__dirname, \"screenshot-captcha-5-journey-view.png\"), fullPage: true });\n        console.log(\"Captured user journey view with CAPTCHA question/answer\");\n      }\n    }\n  }\n});\n"
  },
  {
    "path": "tests/deleted-post-moderation.spec.ts",
    "content": "import { test, expect } from \"@playwright/test\";\nimport { execSync } from \"child_process\";\nimport path from \"path\";\n\nconst PROJECT_ROOT = path.resolve(__dirname, \"..\");\nconst DB_PATH = process.env.DFEED_DB || path.join(PROJECT_ROOT, \"data/db/dfeed.s3db\");\n\ntest.describe(\"Deleted Post Moderation\", () => {\n  test(\"shows user journey and deletion record for deleted post\", async ({ page, context }) => {\n    const timestamp = Date.now();\n    const modUsername = `mod${timestamp}`;\n    const testSubject = `Delete Test ${timestamp}`;\n    const testBody = `This post will be deleted ${timestamp}`;\n\n    // Step 1: Register a moderator user\n    await page.goto(\"/registerform\");\n    await page.fill(\"#loginform-username\", modUsername);\n    await page.fill(\"#loginform-password\", \"testpass123\");\n    await page.fill(\"#loginform-password2\", \"testpass123\");\n    await page.click('input[type=\"submit\"]');\n    await page.waitForURL(\"**/\");\n\n    // Promote user to moderator level (100)\n    const sqlCmd = `UPDATE Users SET Level=100 WHERE Username='${modUsername}';`;\n    execSync(`sqlite3 \"${DB_PATH}\" \"${sqlCmd}\"`);\n\n    // Step 2: Create a post that triggers CAPTCHA (so we have journey data)\n    await context.clearCookies();\n\n    await page.goto(\"/newpost/test\");\n    await expect(page.locator(\"#postform\")).toBeVisible();\n\n    // Use \"spamtest\" to trigger CAPTCHA but NOT hard moderation\n    await page.fill(\"#postform-name\", \"Test User\");\n    await page.fill(\"#postform-email\", \"test@example.com\");\n    await page.fill(\"#postform-subject\", `spamtest ${testSubject}`);\n    await page.fill(\"#postform-text\", testBody);\n\n    // Submit to trigger CAPTCHA\n    await page.click('input[name=\"action-send\"]');\n\n    // Wait for CAPTCHA and solve it\n    const captchaCheckbox = page.locator('input[name=\"dummy_captcha_checkbox\"]');\n    await expect(captchaCheckbox).toBeVisible();\n    await captchaCheckbox.check();\n    await page.click('input[name=\"action-send\"]');\n\n    // Wait for redirect to the posted thread\n    await expect(page).toHaveURL(/\\/(thread|post)\\//, { timeout: 10000 });\n\n    // Extract the post ID from the URL\n    const url = page.url();\n    const postIdMatch = url.match(/\\/(thread|post)\\/([a-z]+)/);\n    expect(postIdMatch).toBeTruthy();\n    const postId = postIdMatch![2];\n    const encodedMessageId = encodeURIComponent(`${postId}@localhost`);\n\n    // Step 3: Log in as moderator\n    await page.goto(\"/loginform\");\n    await page.fill(\"#loginform-username\", modUsername);\n    await page.fill(\"#loginform-password\", \"testpass123\");\n    await page.click('input[type=\"submit\"]');\n    await page.waitForURL(\"**/\");\n\n    // Step 4: Go to moderation page and delete the post\n    await page.goto(`/moderate/${encodedMessageId}`);\n\n    // Verify the post content is shown (before deletion)\n    await expect(page.locator(\"#deleteform-message\")).toBeVisible();\n    await expect(page.locator(\"#deleteform-message\")).toContainText(testBody);\n\n    // Verify user journey is shown before deletion\n    const journeyBeforeDeletion = page.locator(\".journey-timeline\");\n    await expect(journeyBeforeDeletion).toBeVisible();\n\n    // Delete the post (only local copy, don't ban)\n    await page.check(\"#deleteform-delete\");\n    await page.fill('input[name=\"reason\"]', \"test deletion\");\n    await page.click('input[type=\"submit\"]');\n\n    // Verify deletion confirmation\n    await expect(page.locator(\"body\")).toContainText(\"Post deleted\");\n\n    // Step 5: Visit the moderation page again for the now-deleted post\n    await page.goto(`/moderate/${encodedMessageId}`);\n\n    // Verify \"not in database\" notice is shown\n    await expect(page.locator(\".forum-notice\")).toContainText(\"not in the database\");\n\n    // Verify User Journey section still exists (from PostProcess logs)\n    const journeySection = page.locator(\".journey-timeline\");\n    await expect(journeySection).toBeVisible();\n\n    // Verify CAPTCHA events are still visible in journey\n    const captchaQuestion = journeySection.locator(\".journey-event\", {\n      has: page.locator(\".journey-message\", { hasText: \"CAPTCHA question\" })\n    });\n    await expect(captchaQuestion.first()).toBeVisible();\n\n    // Verify Deletion Record section is shown\n    await expect(page.locator(\"body\")).toContainText(\"Deletion Record\");\n\n    // Verify deletion metadata\n    await expect(page.locator(\"body\")).toContainText(\"Deleted by:\");\n    await expect(page.locator(\"body\")).toContainText(modUsername);\n    await expect(page.locator(\"body\")).toContainText(\"Reason:\");\n    await expect(page.locator(\"body\")).toContainText(\"test deletion\");\n\n    // Verify original message content is preserved\n    await expect(page.locator(\"#deleteform-message\")).toBeVisible();\n    await expect(page.locator(\"#deleteform-message\")).toContainText(testBody);\n  });\n\n  test(\"shows appropriate message for non-existent post with no logs\", async ({ page, context }) => {\n    const timestamp = Date.now();\n    const modUsername = `mod${timestamp}`;\n\n    // Register and promote to moderator\n    await page.goto(\"/registerform\");\n    await page.fill(\"#loginform-username\", modUsername);\n    await page.fill(\"#loginform-password\", \"testpass123\");\n    await page.fill(\"#loginform-password2\", \"testpass123\");\n    await page.click('input[type=\"submit\"]');\n    await page.waitForURL(\"**/\");\n\n    const sqlCmd = `UPDATE Users SET Level=100 WHERE Username='${modUsername}';`;\n    execSync(`sqlite3 \"${DB_PATH}\" \"${sqlCmd}\"`);\n\n    // Log in as moderator\n    await page.goto(\"/loginform\");\n    await page.fill(\"#loginform-username\", modUsername);\n    await page.fill(\"#loginform-password\", \"testpass123\");\n    await page.click('input[type=\"submit\"]');\n    await page.waitForURL(\"**/\");\n\n    // Visit moderation page for a non-existent message ID\n    const fakeMessageId = encodeURIComponent(`nonexistent${timestamp}@localhost`);\n    await page.goto(`/moderate/${fakeMessageId}`);\n\n    // Should show \"not in database\" notice\n    await expect(page.locator(\".forum-notice\")).toContainText(\"not in the database\");\n\n    // Should NOT show journey timeline (no logs exist)\n    await expect(page.locator(\".journey-timeline\")).not.toBeVisible();\n\n    // Should NOT show deletion record (never existed)\n    await expect(page.locator(\"body\")).not.toContainText(\"Deletion Record\");\n  });\n});\n"
  },
  {
    "path": "tests/deleted-post-screenshot.spec.ts",
    "content": "import { test, expect } from \"@playwright/test\";\nimport { execSync } from \"child_process\";\nimport path from \"path\";\n\nconst PROJECT_ROOT = path.resolve(__dirname, \"..\");\nconst DB_PATH = process.env.DFEED_DB || path.join(PROJECT_ROOT, \"data/db/dfeed.s3db\");\n\ntest(\"capture deleted post moderation view\", { timeout: 60000 }, async ({ page, context }) => {\n  const timestamp = Date.now();\n  const modUsername = `mod${timestamp}`;\n\n  // Step 1: Register a moderator user\n  await page.goto(\"/registerform\");\n  await page.fill(\"#loginform-username\", modUsername);\n  await page.fill(\"#loginform-password\", \"testpass123\");\n  await page.fill(\"#loginform-password2\", \"testpass123\");\n  await page.click('input[type=\"submit\"]');\n  await page.waitForURL(\"**/\");\n\n  console.log(`Registered moderator user: ${modUsername}`);\n\n  // Promote to moderator level\n  const sqlCmd = `UPDATE Users SET Level=100 WHERE Username='${modUsername}';`;\n  execSync(`sqlite3 \"${DB_PATH}\" \"${sqlCmd}\"`);\n  console.log(\"User promoted to moderator\");\n\n  // Step 2: Create a post as anonymous user (with CAPTCHA to generate journey logs)\n  await context.clearCookies();\n\n  await page.goto(\"/newpost/test\");\n  await expect(page.locator(\"#postform\")).toBeVisible();\n\n  // Use \"spamtest\" to trigger CAPTCHA but NOT hard moderation (so post goes through)\n  await page.fill(\"#postform-name\", \"Test Spammer\");\n  await page.fill(\"#postform-email\", \"spammer@example.com\");\n  await page.fill(\"#postform-subject\", `spamtest Delete Demo ${timestamp}`);\n  await page.fill(\"#postform-text\", \"This post will be deleted to demonstrate the deleted post moderation view.\\n\\nIt shows the user journey and deletion record even after the post is removed from the database.\");\n\n  // Submit to trigger CAPTCHA\n  await page.click('input[name=\"action-send\"]');\n\n  // Solve CAPTCHA\n  const captchaCheckbox = page.locator('input[name=\"dummy_captcha_checkbox\"]');\n  await expect(captchaCheckbox).toBeVisible();\n  await captchaCheckbox.check();\n  await page.click('input[name=\"action-send\"]');\n\n  // Wait for redirect to the posted thread\n  await expect(page).toHaveURL(/\\/(thread|post)\\//, { timeout: 10000 });\n\n  // Extract the post ID from the URL\n  const url = page.url();\n  const postIdMatch = url.match(/\\/(thread|post)\\/([a-z]+)/);\n  expect(postIdMatch).toBeTruthy();\n  const postId = postIdMatch![2];\n  const encodedMessageId = encodeURIComponent(`${postId}@localhost`);\n  console.log(`Created post with ID: ${postId}`);\n\n  // Step 3: Log in as moderator\n  await page.goto(\"/loginform\");\n  await page.fill(\"#loginform-username\", modUsername);\n  await page.fill(\"#loginform-password\", \"testpass123\");\n  await page.click('input[type=\"submit\"]');\n  await page.waitForURL(\"**/\");\n\n  // Step 4: Go to moderation page and take screenshot before deletion\n  await page.goto(`/moderate/${encodedMessageId}`);\n  await page.waitForTimeout(500);\n\n  // Screenshot 1: Moderation page before deletion\n  await page.screenshot({\n    path: path.join(__dirname, \"screenshot-deleted-1-before.png\"),\n    fullPage: true\n  });\n  console.log(\"Screenshot 1: Moderation page before deletion\");\n\n  // Step 5: Delete the post\n  await page.check(\"#deleteform-delete\");\n  await page.fill('input[name=\"reason\"]', \"spam demo\");\n  await page.click('input[type=\"submit\"]');\n\n  // Wait for deletion confirmation\n  await expect(page.locator(\"body\")).toContainText(\"Post deleted\");\n  await page.waitForTimeout(500);\n\n  // Screenshot 2: Deletion confirmation\n  await page.screenshot({\n    path: path.join(__dirname, \"screenshot-deleted-2-confirmation.png\"),\n    fullPage: true\n  });\n  console.log(\"Screenshot 2: Deletion confirmation\");\n\n  // Step 6: Visit the moderation page again for the deleted post\n  await page.goto(`/moderate/${encodedMessageId}`);\n  await page.waitForTimeout(500);\n\n  // Screenshot 3: Deleted post moderation view with journey and deletion record\n  await page.screenshot({\n    path: path.join(__dirname, \"screenshot-deleted-3-after.png\"),\n    fullPage: true\n  });\n  console.log(\"Screenshot 3: Deleted post moderation view\");\n\n  // Verify the key elements are present\n  await expect(page.locator(\".forum-notice\")).toContainText(\"not in the database\");\n  await expect(page.locator(\".journey-timeline\")).toBeVisible();\n  await expect(page.locator(\"body\")).toContainText(\"Deletion Record\");\n  await expect(page.locator(\"body\")).toContainText(`Deleted by:`);\n  await expect(page.locator(\"body\")).toContainText(modUsername);\n});\n"
  },
  {
    "path": "tests/index.spec.ts",
    "content": "import { test, expect } from '@playwright/test';\n\ntest('index page loads successfully', async ({ page }) => {\n  const response = await page.goto('/');\n\n  // Verify the page loads with a successful status\n  expect(response?.status()).toBe(200);\n\n  // Verify we're on a DFeed instance by checking for expected content\n  await expect(page.locator('body')).toBeVisible();\n});\n"
  },
  {
    "path": "tests/package.json",
    "content": "{\n  \"name\": \"dfeed-e2e-tests\",\n  \"version\": \"1.0.0\",\n  \"description\": \"End-to-end tests for DFeed using Playwright\",\n  \"scripts\": {\n    \"test\": \"playwright test\"\n  },\n  \"devDependencies\": {\n    \"@playwright/test\": \"^1.40.0\"\n  }\n}\n"
  },
  {
    "path": "tests/playwright.config.ts",
    "content": "import { defineConfig, devices } from '@playwright/test';\n\nexport default defineConfig({\n  testDir: '.',\n  timeout: 30000,\n  retries: 0,\n  use: {\n    baseURL: process.env.DFEED_URL || 'http://localhost:8080',\n    trace: 'on-first-retry',\n  },\n  projects: [\n    {\n      name: 'default',\n      testIgnore: /.*-screenshot\\.spec\\.ts$/,\n      use: {\n        ...devices['Desktop Firefox'],\n      },\n    },\n    {\n      name: 'screenshots',\n      testMatch: /.*-screenshot\\.spec\\.ts$/,\n      use: {\n        ...devices['Desktop Firefox'],\n      },\n    },\n  ],\n});\n"
  },
  {
    "path": "tests/posting.spec.ts",
    "content": "import { test, expect } from \"@playwright/test\";\n\ntest.describe(\"Posting\", () => {\n  test(\"can create a new thread in local group\", async ({ page }) => {\n    // Generate unique identifiers for this test\n    const timestamp = Date.now();\n    const testSubject = `Test Thread ${timestamp}`;\n    const testBody = `This is a test message body created at ${timestamp}`;\n    const testName = \"Test User\";\n    const testEmail = \"test@example.com\";\n\n    // Navigate to the test group\n    await page.goto(\"/group/test\");\n    await expect(page.locator(\"body\")).toBeVisible();\n\n    // Click \"Create thread\" button\n    await page.click(\n      'input[value=\"Create thread\"], input[alt=\"Create thread\"]'\n    );\n\n    // Wait for the new post form\n    await expect(page.locator(\"#postform\")).toBeVisible();\n\n    // Fill in the form\n    await page.fill(\"#postform-name\", testName);\n    await page.fill(\"#postform-email\", testEmail);\n    await page.fill(\"#postform-subject\", testSubject);\n    await page.fill(\"#postform-text\", testBody);\n\n    // Submit the form\n    await page.click('input[name=\"action-send\"]');\n\n    // Wait for redirect to the posted thread\n    await expect(page).toHaveURL(/\\/(thread|post)\\//);\n\n    // Verify the post content is visible\n    await expect(page.locator(\"body\")).toContainText(testSubject);\n    await expect(page.locator(\"body\")).toContainText(testBody);\n  });\n\n  test(\"thread appears in group listing after posting\", async ({ page }) => {\n    // Generate unique identifiers for this test\n    const timestamp = Date.now();\n    const testSubject = `Listing Test ${timestamp}`;\n    const testBody = `Message for listing test ${timestamp}`;\n\n    // Create a new thread\n    await page.goto(\"/newpost/test\");\n    await page.fill(\"#postform-name\", \"Test User\");\n    await page.fill(\"#postform-email\", \"test@example.com\");\n    await page.fill(\"#postform-subject\", testSubject);\n    await page.fill(\"#postform-text\", testBody);\n    await page.click('input[name=\"action-send\"]');\n\n    // Wait for posting to complete\n    await expect(page).toHaveURL(/\\/(thread|post)\\//);\n\n    // Navigate to group listing\n    await page.goto(\"/group/test\");\n\n    // Verify the new thread appears in the listing\n    await expect(page.locator(\"body\")).toContainText(testSubject);\n  });\n\n  test(\"posting form renders correctly\", async ({ page }) => {\n    // Navigate to new post form\n    await page.goto(\"/newpost/test\");\n\n    // Verify form elements are present\n    await expect(page.locator(\"#postform\")).toBeVisible();\n    await expect(page.locator(\"#postform-name\")).toBeVisible();\n    await expect(page.locator(\"#postform-email\")).toBeVisible();\n    await expect(page.locator(\"#postform-subject\")).toBeVisible();\n    await expect(page.locator(\"#postform-text\")).toBeVisible();\n    await expect(page.locator('input[name=\"action-send\"]')).toBeVisible();\n    await expect(page.locator('input[name=\"action-save\"]')).toBeVisible();\n  });\n\n  test(\"can preview post before sending\", async ({ page }) => {\n    const timestamp = Date.now();\n    const testSubject = `Preview Test ${timestamp}`;\n    const testBody = `Preview message body ${timestamp}`;\n\n    await page.goto(\"/newpost/test\");\n\n    // Fill in the form\n    await page.fill(\"#postform-name\", \"Test User\");\n    await page.fill(\"#postform-email\", \"test@example.com\");\n    await page.fill(\"#postform-subject\", testSubject);\n    await page.fill(\"#postform-text\", testBody);\n\n    // Click \"Save and preview\" button\n    await page.click('input[name=\"action-save\"]');\n\n    // Verify preview is shown - the page should show the message content\n    await expect(page.locator(\"body\")).toContainText(testBody);\n\n    // Form should still be visible for editing\n    await expect(page.locator(\"#postform\")).toBeVisible();\n  });\n\n  test(\"spam detection triggers CAPTCHA challenge\", async ({ page }) => {\n    const timestamp = Date.now();\n    // \"spamtest\" in subject triggers SimpleChecker's spam detection\n    const testSubject = `spamtest ${timestamp}`;\n    const testBody = `Testing CAPTCHA flow ${timestamp}`;\n\n    await page.goto(\"/newpost/test\");\n\n    // Fill in the form with spam-triggering subject\n    await page.fill(\"#postform-name\", \"Test User\");\n    await page.fill(\"#postform-email\", \"test@example.com\");\n    await page.fill(\"#postform-subject\", testSubject);\n    await page.fill(\"#postform-text\", testBody);\n\n    // Submit the form\n    await page.click('input[name=\"action-send\"]');\n\n    // Should be challenged with CAPTCHA (dummy checkbox)\n    // The page should show the CAPTCHA checkbox\n    const captchaCheckbox = page.locator('input[name=\"dummy_captcha_checkbox\"]');\n    await expect(captchaCheckbox).toBeVisible();\n\n    // Should show \"I am not a robot\" text\n    await expect(page.locator(\"body\")).toContainText(\"I am not a robot\");\n\n    // Form should still be visible with our data preserved\n    await expect(page.locator(\"#postform\")).toBeVisible();\n    await expect(page.locator(\"#postform-subject\")).toHaveValue(testSubject);\n  });\n\n  test(\"solving CAPTCHA allows post submission\", async ({ page }) => {\n    const timestamp = Date.now();\n    // \"spamtest\" in subject triggers SimpleChecker's spam detection\n    const testSubject = `spamtest solved ${timestamp}`;\n    const testBody = `Testing CAPTCHA solution ${timestamp}`;\n\n    await page.goto(\"/newpost/test\");\n\n    // Fill in the form with spam-triggering subject\n    await page.fill(\"#postform-name\", \"Test User\");\n    await page.fill(\"#postform-email\", \"test@example.com\");\n    await page.fill(\"#postform-subject\", testSubject);\n    await page.fill(\"#postform-text\", testBody);\n\n    // Submit the form - should trigger CAPTCHA\n    await page.click('input[name=\"action-send\"]');\n\n    // Wait for CAPTCHA checkbox to appear\n    const captchaCheckbox = page.locator('input[name=\"dummy_captcha_checkbox\"]');\n    await expect(captchaCheckbox).toBeVisible();\n\n    // Check the CAPTCHA checkbox\n    await captchaCheckbox.check();\n\n    // Submit again with CAPTCHA solved\n    await page.click('input[name=\"action-send\"]');\n\n    // Should redirect to the posted thread\n    await expect(page).toHaveURL(/\\/(thread|post)\\//);\n\n    // Verify the post content is visible\n    await expect(page.locator(\"body\")).toContainText(testSubject);\n    await expect(page.locator(\"body\")).toContainText(testBody);\n  });\n\n  test(\"CAPTCHA-solved post appears in group listing\", async ({ page }) => {\n    const timestamp = Date.now();\n    const testSubject = `spamtest listing ${timestamp}`;\n    const testBody = `CAPTCHA listing test ${timestamp}`;\n\n    await page.goto(\"/newpost/test\");\n\n    // Fill in and submit with spam-triggering subject\n    await page.fill(\"#postform-name\", \"Test User\");\n    await page.fill(\"#postform-email\", \"test@example.com\");\n    await page.fill(\"#postform-subject\", testSubject);\n    await page.fill(\"#postform-text\", testBody);\n    await page.click('input[name=\"action-send\"]');\n\n    // Solve CAPTCHA\n    const captchaCheckbox = page.locator('input[name=\"dummy_captcha_checkbox\"]');\n    await expect(captchaCheckbox).toBeVisible();\n    await captchaCheckbox.check();\n    await page.click('input[name=\"action-send\"]');\n\n    // Wait for posting to complete\n    await expect(page).toHaveURL(/\\/(thread|post)\\//);\n\n    // Navigate to group listing\n    await page.goto(\"/group/test\");\n\n    // Verify the new thread appears in the listing\n    await expect(page.locator(\"body\")).toContainText(testSubject);\n  });\n\n  test(\"hard spam detection triggers CAPTCHA challenge\", async ({ page }) => {\n    const timestamp = Date.now();\n    // \"hardspamtest\" in subject triggers certainlySpam (1.0) response\n    const testSubject = `hardspamtest ${timestamp}`;\n    const testBody = `Testing hard spam moderation flow ${timestamp}`;\n\n    await page.goto(\"/newpost/test\");\n\n    // Fill in the form with hard spam-triggering subject\n    await page.fill(\"#postform-name\", \"Test User\");\n    await page.fill(\"#postform-email\", \"test@example.com\");\n    await page.fill(\"#postform-subject\", testSubject);\n    await page.fill(\"#postform-text\", testBody);\n\n    // Submit the form\n    await page.click('input[name=\"action-send\"]');\n\n    // Should be challenged with CAPTCHA (dummy checkbox)\n    const captchaCheckbox = page.locator('input[name=\"dummy_captcha_checkbox\"]');\n    await expect(captchaCheckbox).toBeVisible();\n\n    // Should show \"I am not a robot\" text\n    await expect(page.locator(\"body\")).toContainText(\"I am not a robot\");\n  });\n\n  test(\"hard spam post is quarantined after solving CAPTCHA\", async ({\n    page,\n  }) => {\n    const timestamp = Date.now();\n    // \"hardspamtest\" in subject triggers certainlySpam (1.0) response\n    const testSubject = `hardspamtest moderated ${timestamp}`;\n    const testBody = `Testing hard spam quarantine ${timestamp}`;\n\n    await page.goto(\"/newpost/test\");\n\n    // Fill in the form with hard spam-triggering subject\n    await page.fill(\"#postform-name\", \"Test User\");\n    await page.fill(\"#postform-email\", \"test@example.com\");\n    await page.fill(\"#postform-subject\", testSubject);\n    await page.fill(\"#postform-text\", testBody);\n\n    // Submit the form - should trigger CAPTCHA\n    await page.click('input[name=\"action-send\"]');\n\n    // Wait for CAPTCHA checkbox to appear\n    const captchaCheckbox = page.locator('input[name=\"dummy_captcha_checkbox\"]');\n    await expect(captchaCheckbox).toBeVisible();\n\n    // Check the CAPTCHA checkbox\n    await captchaCheckbox.check();\n\n    // Submit again with CAPTCHA solved\n    await page.click('input[name=\"action-send\"]');\n\n    // Should NOT redirect to thread - should show moderation message\n    // The URL should stay on posting page (not redirect to thread)\n    await expect(page).not.toHaveURL(/\\/(thread|post)\\//);\n\n    // Should show moderation message\n    await expect(page.locator(\"body\")).toContainText(\n      \"approved by a moderator\"\n    );\n  });\n\n  test(\"quarantined post does not appear in group listing\", async ({ page }) => {\n    const timestamp = Date.now();\n    const testSubject = `hardspamtest hidden ${timestamp}`;\n    const testBody = `This post should be hidden ${timestamp}`;\n\n    await page.goto(\"/newpost/test\");\n\n    // Fill in and submit with hard spam-triggering subject\n    await page.fill(\"#postform-name\", \"Test User\");\n    await page.fill(\"#postform-email\", \"test@example.com\");\n    await page.fill(\"#postform-subject\", testSubject);\n    await page.fill(\"#postform-text\", testBody);\n    await page.click('input[name=\"action-send\"]');\n\n    // Solve CAPTCHA\n    const captchaCheckbox = page.locator('input[name=\"dummy_captcha_checkbox\"]');\n    await expect(captchaCheckbox).toBeVisible();\n    await captchaCheckbox.check();\n    await page.click('input[name=\"action-send\"]');\n\n    // Should show moderation message\n    await expect(page.locator(\"body\")).toContainText(\n      \"approved by a moderator\"\n    );\n\n    // Navigate to group listing\n    await page.goto(\"/group/test\");\n\n    // Verify the quarantined thread does NOT appear in the listing\n    await expect(page.locator(\"body\")).not.toContainText(testSubject);\n  });\n});\n\ntest.describe(\"Registered User Experience\", () => {\n  test(\"registered user data persists after clearing cookies and signing back in\", async ({\n    page,\n    context,\n  }) => {\n    const timestamp = Date.now();\n    const testUsername = `testuser${timestamp}`;\n    const testPassword = \"testpass123\";\n    const testName = `Test User ${timestamp}`;\n    const testEmail = `test${timestamp}@example.com`;\n    const testSubject = `Registered User Test ${timestamp}`;\n    const testBody = `Testing registered user persistence ${timestamp}`;\n\n    // Step 1: Register a new user\n    await page.goto(\"/registerform\");\n    await expect(page.locator(\"#registerform\")).toBeVisible();\n\n    await page.fill(\"#loginform-username\", testUsername);\n    await page.fill(\"#loginform-password\", testPassword);\n    await page.fill(\"#loginform-password2\", testPassword);\n    await page.click('input[type=\"submit\"]');\n\n    // Should be redirected after successful registration\n    await expect(page).not.toHaveURL(/registerform/);\n\n    // Step 2: Make a post (this saves name/email to user settings)\n    await page.goto(\"/newpost/test\");\n    await expect(page.locator(\"#postform\")).toBeVisible();\n\n    await page.fill(\"#postform-name\", testName);\n    await page.fill(\"#postform-email\", testEmail);\n    await page.fill(\"#postform-subject\", testSubject);\n    await page.fill(\"#postform-text\", testBody);\n    await page.click('input[name=\"action-send\"]');\n\n    // Wait for posting to complete and verify post is displayed\n    await expect(page).toHaveURL(/\\/(thread|post)\\//);\n    await expect(page.locator(\"body\")).toContainText(testSubject);\n    await expect(page.locator(\"body\")).toContainText(testBody);\n\n    // Step 3: Clear cookies (simulating browser close/cookie expiration)\n    await context.clearCookies();\n\n    // Step 4: Sign back in\n    await page.goto(\"/loginform\");\n    await expect(page.locator(\"#loginform\")).toBeVisible();\n\n    await page.fill(\"#loginform-username\", testUsername);\n    await page.fill(\"#loginform-password\", testPassword);\n    // Ensure \"Remember me\" is checked for persistent session\n    await page.check(\"#loginform-remember\");\n    await page.click('input[type=\"submit\"]');\n\n    // Wait for navigation to complete (either redirect or error page)\n    await page.waitForLoadState(\"networkidle\");\n\n    // Verify we're redirected (not on login page)\n    await expect(page).not.toHaveURL(/\\/login/);\n\n    // Verify user is logged in by checking for logout link with username\n    await expect(\n      page.locator(`a:has-text(\"Log out ${testUsername}\")`)\n    ).toBeVisible();\n\n    // Step 5a: Check that the post is marked as read\n    await page.goto(\"/group/test\");\n\n    // Find the link to our test post - it should have the \"forum-read\" class\n    const postLink = page.locator(`a:has-text(\"${testSubject}\")`).first();\n    await expect(postLink).toBeVisible();\n    await expect(postLink).toHaveClass(/forum-read/);\n\n    // Step 5b: Check that posting form has same user details pre-filled\n    await page.goto(\"/newpost/test\");\n    await expect(page.locator(\"#postform\")).toBeVisible();\n\n    // Verify name and email are pre-filled with the same values\n    await expect(page.locator(\"#postform-name\")).toHaveValue(testName);\n    await expect(page.locator(\"#postform-email\")).toHaveValue(testEmail);\n  });\n});\n"
  },
  {
    "path": "tests/user-journey.spec.ts",
    "content": "import { test, expect } from \"@playwright/test\";\nimport { execSync } from \"child_process\";\nimport path from \"path\";\n\nconst PROJECT_ROOT = path.resolve(__dirname, \"..\");\nconst DB_PATH = process.env.DFEED_DB || path.join(PROJECT_ROOT, \"data/db/dfeed.s3db\");\n\ntest.describe(\"User Journey\", () => {\n  test(\"shows CAPTCHA question and answer on approval and moderation pages\", async ({ page, context }) => {\n    const timestamp = Date.now();\n    const modUsername = `mod${timestamp}`;\n\n    // Step 1: Register a moderator user\n    await page.goto(\"/registerform\");\n    await page.fill(\"#loginform-username\", modUsername);\n    await page.fill(\"#loginform-password\", \"testpass123\");\n    await page.fill(\"#loginform-password2\", \"testpass123\");\n    await page.click('input[type=\"submit\"]');\n    await page.waitForURL(\"**/\");\n\n    // Promote user to moderator level (100)\n    const sqlCmd = `UPDATE Users SET Level=100 WHERE Username='${modUsername}';`;\n    execSync(`sqlite3 \"${DB_PATH}\" \"${sqlCmd}\"`);\n\n    // Step 2: Create a moderated post as anonymous user\n    await context.clearCookies();\n\n    await page.goto(\"/newpost/test\");\n    await expect(page.locator(\"#postform\")).toBeVisible();\n\n    // Fill form with hardspamtest (triggers CAPTCHA AND moderation)\n    await page.fill(\"#postform-name\", \"Test User\");\n    await page.fill(\"#postform-email\", \"test@example.com\");\n    await page.fill(\"#postform-subject\", `hardspamtest ${timestamp}`);\n    await page.fill(\"#postform-text\", \"Testing CAPTCHA question and answer in user journey\");\n\n    // Submit to trigger CAPTCHA\n    await page.click('input[name=\"action-send\"]');\n\n    // Wait for CAPTCHA and solve it\n    const captchaCheckbox = page.locator('input[name=\"dummy_captcha_checkbox\"]');\n    await expect(captchaCheckbox).toBeVisible();\n\n    // Capture the draft ID before solving CAPTCHA\n    const draftId = await page.locator('input[name=\"did\"]').inputValue();\n    expect(draftId).toBeTruthy();\n\n    // Solve CAPTCHA and submit\n    await captchaCheckbox.check();\n    await page.click('input[name=\"action-send\"]');\n\n    // Wait for moderation notice\n    await expect(page.locator(\"body\")).toContainText(\"approved by a moderator\", { timeout: 10000 });\n\n    // Step 3: Log in as moderator\n    await page.goto(\"/loginform\");\n    await page.fill(\"#loginform-username\", modUsername);\n    await page.fill(\"#loginform-password\", \"testpass123\");\n    await page.click('input[type=\"submit\"]');\n    await page.waitForURL(\"**/\");\n\n    // Step 4: Check the approval page has User Journey with CAPTCHA info\n    await page.goto(`/approve-moderated-draft/${draftId}`);\n\n    // Verify User Journey section exists\n    const journeySection = page.locator(\".journey-timeline\");\n    await expect(journeySection).toBeVisible();\n\n    // Verify CAPTCHA question is shown (use .journey-message for more specific matching)\n    const captchaQuestion = journeySection.locator(\".journey-event\", { has: page.locator(\".journey-message\", { hasText: \"CAPTCHA question\" }) });\n    await expect(captchaQuestion.first()).toBeVisible();\n    await expect(captchaQuestion.first()).toContainText(\"Dummy CAPTCHA\");\n\n    // Verify CAPTCHA answer is shown\n    const captchaAnswer = journeySection.locator(\".journey-event\", { has: page.locator(\".journey-message\", { hasText: \"CAPTCHA answer\" }) });\n    await expect(captchaAnswer.first()).toBeVisible();\n    await expect(captchaAnswer.first()).toContainText(\"checked\", { ignoreCase: true });\n\n    // Step 5: Approve the post\n    await page.click('input[name=\"approve\"]');\n    await expect(page.locator(\"body\")).toContainText(\"Post approved\");\n\n    // Get the posting link to find the message ID\n    const viewLink = await page.locator('a:has-text(\"View posting\")').getAttribute('href');\n    expect(viewLink).toBeTruthy();\n\n    const postIdMatch = viewLink!.match(/posting\\/([a-z]+)/);\n    expect(postIdMatch).toBeTruthy();\n\n    const postId = postIdMatch![1];\n    const encodedMessageId = encodeURIComponent(`${postId}@localhost`);\n\n    // Step 6: Check the moderation page for the live post also has User Journey with CAPTCHA info\n    await page.goto(`/moderate/${encodedMessageId}`);\n\n    // Verify User Journey section exists\n    const moderationJourney = page.locator(\".journey-timeline\");\n    await expect(moderationJourney).toBeVisible();\n\n    // Verify CAPTCHA question is shown (use .journey-message for more specific matching)\n    const modCaptchaQuestion = moderationJourney.locator(\".journey-event\", { has: page.locator(\".journey-message\", { hasText: \"CAPTCHA question\" }) });\n    await expect(modCaptchaQuestion.first()).toBeVisible();\n    await expect(modCaptchaQuestion.first()).toContainText(\"Dummy CAPTCHA\");\n\n    // Verify CAPTCHA answer is shown\n    const modCaptchaAnswer = moderationJourney.locator(\".journey-event\", { has: page.locator(\".journey-message\", { hasText: \"CAPTCHA answer\" }) });\n    await expect(modCaptchaAnswer.first()).toBeVisible();\n    await expect(modCaptchaAnswer.first()).toContainText(\"checked\", { ignoreCase: true });\n\n    // Verify approval event is shown\n    const approvalEvent = moderationJourney.locator(\".journey-event.approval\");\n    await expect(approvalEvent).toBeVisible();\n    await expect(approvalEvent).toContainText(\"Approved by moderator\");\n    await expect(approvalEvent).toContainText(`Moderator: ${modUsername}`);\n  });\n});\n"
  }
]