Repository: CyberShadow/DFeed Branch: master Commit: 0639bf986002 Files: 137 Total size: 881.0 KB Directory structure: gitextract_4yp5s7nn/ ├── .github/ │ └── workflows/ │ └── build.yml ├── .gitignore ├── .gitmodules ├── README.md ├── agpl-3.0.txt ├── dub.sdl ├── dub.selections.json ├── flake.nix ├── makejson ├── schema_v1.sql ├── site-defaults/ │ ├── config/ │ │ ├── apis/ │ │ │ ├── akismet.ini.sample │ │ │ ├── bitly.ini.sample │ │ │ ├── mailhide.ini.sample │ │ │ ├── openai.ini.sample │ │ │ ├── projecthoneypot.ini.sample │ │ │ ├── recaptcha.ini.sample │ │ │ └── stopforumspam.ini │ │ ├── backup.ini.sample │ │ ├── groups.ini │ │ ├── groups.ini.sample │ │ ├── sinks/ │ │ │ ├── irc/ │ │ │ │ └── irc.ini.sample │ │ │ └── twitter/ │ │ │ └── twitter.ini.sample │ │ ├── site.ini.sample │ │ ├── sources/ │ │ │ ├── feeds/ │ │ │ │ └── example.ini.sample │ │ │ ├── github/ │ │ │ │ └── github.ini.sample │ │ │ ├── mailman/ │ │ │ │ └── example.ini.sample │ │ │ ├── mailrelay/ │ │ │ │ └── example.ini.sample │ │ │ ├── nntp/ │ │ │ │ └── example.ini.sample │ │ │ ├── smtp/ │ │ │ │ └── example.ini.sample │ │ │ ├── socket/ │ │ │ │ └── socket.ini.sample │ │ │ └── stackoverflow/ │ │ │ └── so.ini.sample │ │ ├── user.ini.sample │ │ └── web.ini.sample │ └── web/ │ ├── help-english.htt │ ├── help-turkish.htt │ ├── highlight-js/ │ │ ├── LICENSE │ │ ├── highlight.pack.js │ │ └── styles/ │ │ └── vs.css │ ├── skel.htt │ └── static/ │ ├── css/ │ │ ├── dfeed.css │ │ └── style.css │ ├── js/ │ │ └── dfeed.js │ ├── robots_private.txt │ └── robots_public.txt ├── src/ │ └── dfeed/ │ ├── backup.d │ ├── bayes.d │ ├── bitly.d │ ├── common.d │ ├── database.d │ ├── debugging.d │ ├── groups.d │ ├── loc/ │ │ ├── english.d │ │ ├── package.d │ │ └── turkish.d │ ├── mail.d │ ├── message.d │ ├── paths.d │ ├── progs/ │ │ ├── dfeed.d │ │ ├── nntpdownload.d │ │ ├── sendspamfeedback.d │ │ └── unban.d │ ├── sinks/ │ │ ├── cache.d │ │ ├── irc.d │ │ ├── messagedb.d │ │ ├── subscriptions.d │ │ └── twitter.d │ ├── site.d │ ├── sources/ │ │ ├── github.d │ │ ├── mailman.d │ │ ├── mailrelay.d │ │ ├── newsgroups.d │ │ ├── socket.d │ │ └── web/ │ │ ├── feed.d │ │ ├── reddit.d │ │ ├── stackoverflow.d │ │ └── webpoller.d │ └── web/ │ ├── captcha/ │ │ ├── common.d │ │ ├── dcaptcha.d │ │ ├── dummy.d │ │ ├── package.d │ │ └── recaptcha.d │ ├── lint.d │ ├── list.d │ ├── mailhide.d │ ├── markdown.d │ ├── moderation.d │ ├── posting.d │ ├── spam/ │ │ ├── akismet.d │ │ ├── bayes.d │ │ ├── blogspam.d │ │ ├── openai.d │ │ ├── package.d │ │ ├── projecthoneypot.d │ │ ├── simple.d │ │ └── stopforumspam.d │ ├── user.d │ └── web/ │ ├── cache.d │ ├── config.d │ ├── draft.d │ ├── moderation.d │ ├── page.d │ ├── part/ │ │ ├── gravatar.d │ │ ├── pager.d │ │ ├── post.d │ │ ├── postbody.d │ │ ├── profile.d │ │ ├── strings.d │ │ └── thread.d │ ├── perf.d │ ├── postinfo.d │ ├── posting.d │ ├── postmod.d │ ├── request.d │ ├── server.d │ ├── site.d │ ├── statics.d │ ├── user.d │ └── view/ │ ├── feed.d │ ├── group.d │ ├── index.d │ ├── login.d │ ├── moderation.d │ ├── post.d │ ├── search.d │ ├── settings.d │ ├── subscription.d │ ├── thread.d │ ├── userprofile.d │ └── widgets.d └── tests/ ├── captcha-screenshot.spec.ts ├── deleted-post-moderation.spec.ts ├── deleted-post-screenshot.spec.ts ├── index.spec.ts ├── package.json ├── playwright.config.ts ├── posting.spec.ts └── user-journey.spec.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/build.yml ================================================ name: build on: [ push, pull_request ] jobs: build-dub: strategy: fail-fast: false matrix: os: [ ubuntu-22.04 ] dc: [ dmd-2.110.0 ] runs-on: ${{ matrix.os }} steps: - name: Install dependencies run: sudo apt-get update && sudo apt-get install -y libcurl4-openssl-dev - name: Prepare compiler uses: dlang-community/setup-dlang@v1 with: compiler: ${{ matrix.dc }} - uses: actions/checkout@v2 - name: Build run: | dub build build-nix: timeout-minutes: 60 runs-on: ubuntu-24.04 steps: - name: Checkout repository uses: actions/checkout@v4 - name: Install Nix uses: DeterminateSystems/nix-installer-action@v18 with: extra-conf: | extra-experimental-features = nix-command flakes - name: Setup Nix cache uses: DeterminateSystems/magic-nix-cache-action@v11 - name: Build DFeed with Nix run: nix build --show-trace --print-build-logs - name: Run flake checks run: nix flake check --show-trace --print-build-logs ================================================ FILE: .gitignore ================================================ # Temporary files *.exe *.o *.obj *.def !ws2_32x.def *.ksp *.pdb *.rsp *.map *.mem *.suo *.ilk *.min.* *.jar /dfeed.json /core /bad-zlib.z /bad-base64.txt /feed-error.xml /so-error.txt # Data, logs and configuration /data/ /logs/ /build.local /site/ # Binaries /dfeed /dfeed_web /mldownload /nntpdownload /rebuilddb /rebuildthreads /sanitizedb /sendspamfeedback /unban /bayes-checkall /bayes-checkdatum /bayes-prepdata /bayes-train /*-test-application /src/dfeed/progs/* !/src/dfeed/progs/*.d result # Deimos /deimos/openssl/ ================================================ FILE: .gitmodules ================================================ [submodule "ae"] path = lib/ae url = https://github.com/CyberShadow/ae [submodule "dcaptcha"] path = lib/dcaptcha url = https://github.com/CyberShadow/dcaptcha [submodule "deimos-openssl"] path = lib/deimos-openssl url = https://github.com/D-Programming-Deimos/openssl ================================================ FILE: README.md ================================================ DFeed ===== DFeed is a multi-protocol news aggregator and forum system: - NNTP client - Mailing list archive - Web-based forum interface - ATOM feed aggregator - IRC bot ## Demo Instance A demo instance is available here: https://dfeed-demo.cy.md/ ## Directory Structure - `src/` - Application source code - `site-defaults/` - Default configuration and web templates (tracked in repo) - `site/` - Site-specific overrides (gitignored, your customizations go here) Files in `site/` override files in `site-defaults/`. This allows you to customize your installation without modifying tracked files. ## Quick Start ```bash git clone --recursive https://github.com/CyberShadow/DFeed.git cd DFeed ``` ### Building with Dub (executable only) ```bash dub build ``` ### Building with Nix (executable and minified resources) ```bash nix build . ``` ### Configuration Create your site-specific configuration in `site/`: ```bash mkdir -p site/config/sources/nntp cp site-defaults/config/site.ini.sample site/config/site.ini # Edit site/config/site.ini with your settings # Add an NNTP source: echo "host = your.nntp.server" > site/config/sources/nntp/myserver.ini # Configure web interface: echo "listen.port = 80" > site/config/web.ini ``` ### Running ```bash ./dfeed ``` On first start, DFeed downloads messages from configured NNTP servers. Access the web interface at http://localhost:8080/. ## Site-Specific Deployments For an example of a complete site-specific setup, see the [dlang.org forum configuration](https://github.com/CyberShadow/d-programming-language.org/tree/dfeed/dfeed). ================================================ FILE: agpl-3.0.txt ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Copyright (C) This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: dub.sdl ================================================ name "dfeed" description "D news aggregator, newsgroup client, web newsreader and IRC bot" authors "Vladimir Panteleev " homepage "https://github.com/CyberShadow/DFeed" license "Affero GPL v3" --------------------------- # Main package is the DFeed program itself. sourcePaths sourceFiles "src/dfeed/progs/dfeed.d" mainSourceFile "src/dfeed/progs/dfeed.d" dependency "dfeed:lib" version="*" path="." targetType "executable" --------------------------- # All modules. subPackage { name "lib" excludedSourceFiles "src/dfeed/progs/*.d" targetType "sourceLibrary" dependency "ae" version="==0.0.3666" dependency "ae:zlib" version="==0.0.3666" dependency "ae:sqlite" version="==0.0.3666" dependency "ae:openssl" version="==0.0.3666" dependency "dcaptcha" version="==1.0.1" } --------------------------- # NNTP downloader program. subPackage { name "nntpdownload" sourcePaths sourceFiles "src/dfeed/progs/nntpdownload.d" dependency "dfeed:lib" version="*" path="." targetType "executable" } --------------------------- # Spam feedback program subPackage { name "sendspamfeedback" sourcePaths sourceFiles "src/dfeed/progs/sendspamfeedback.d" dependency "dfeed:lib" version="*" path="." targetType "executable" } ================================================ FILE: dub.selections.json ================================================ { "fileVersion": 1, "versions": { "ae": "0.0.3666", "dcaptcha": "1.0.1", "openssl": "3.3.0" } } ================================================ FILE: flake.nix ================================================ { description = "DFeed - D news aggregator, newsgroup client, web newsreader and IRC bot"; inputs = { nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; self.submodules = true; }; outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = nixpkgs.legacyPackages.${system}; # Helper to download compressors (for minification) htmlcompressor = pkgs.fetchurl { url = "https://storage.googleapis.com/google-code-archive-downloads/v2/code.google.com/htmlcompressor/htmlcompressor-1.5.3.jar"; sha256 = "1ydh1hqndnvw0d8kws5339mj6qn2yhjd8djih27423nv1hrlx2c8"; }; yuicompressor = pkgs.fetchurl { url = "https://github.com/yui/yuicompressor/releases/download/v2.4.8/yuicompressor-2.4.8.jar"; sha256 = "1qjxlak9hbl9zd3dl5ks0w4zx5z64wjsbk7ic73r1r45fasisdrh"; }; # Filter source to only include files needed for D build dfeedSrc = pkgs.lib.cleanSourceWith { src = self; filter = path: type: let baseName = baseNameOf path; relPath = pkgs.lib.removePrefix (toString self + "/") (toString path); in # Include D source directories pkgs.lib.hasPrefix "src/" relPath || pkgs.lib.hasPrefix "lib/" relPath || # Include root-level build files baseName == "dub.sdl" || baseName == "dub.selections.json" || # Allow traversing directories type == "directory"; }; # Shared test configuration generation generateTestConfig = '' # Create test environment mkdir -p site/config/apis data/db # Create minimal site.ini cat > site/config/site.ini << 'SITEINI' name = DFeed Test Instance host = localhost proto = http SITEINI # Create web.ini with test port cat > site/config/web.ini << 'WEBINI' [listen] port = 8080 WEBINI # Disable StopForumSpam in sandbox (no network access) cat > site/config/apis/stopforumspam.ini << 'SPAMINI' enabled = false SPAMINI # Configure user authentication with a test salt cat > site/config/user.ini << 'USERINI' salt = test-salt-for-playwright-tests-only USERINI # Configure test group with dummy captcha and disabled rate limiting for testing cat > site/config/groups.ini << 'GROUPSINI' [sets.test] name=Test shortName=Test visible=true [groups.test] internalName=test publicName=Test Forum navName=Test urlName=test groupSet=test description=A test forum for trying out posting sinkType=local announce=false captcha=dummy postThrottleRejectCount=0 postThrottleCaptchaCount=0 GROUPSINI # Database is automatically created and migrated by dfeed ''; # Shared server startup/shutdown logic startServer = '' # Start dfeed server in background ${self.packages.${system}.default}/bin/dfeed --no-sources & DFEED_PID=$! # Wait for server to be ready (up to 30 seconds) echo "Waiting for DFeed server to start..." for i in $(seq 1 30); do if curl -s http://localhost:8080/ > /dev/null 2>&1; then echo "Server is ready!" break fi if ! kill -0 $DFEED_PID 2>/dev/null; then echo "Server process died unexpectedly" exit 1 fi sleep 1 done # Verify server is actually responding if ! curl -s http://localhost:8080/ > /dev/null 2>&1; then echo "Server failed to start within 30 seconds" kill $DFEED_PID 2>/dev/null || true exit 1 fi ''; stopServer = '' # Stop server kill $DFEED_PID 2>/dev/null || true wait $DFEED_PID 2>/dev/null || true ''; # Reference site-defaults separately (not part of D source) siteDefaultsSrc = "${self}/site-defaults"; in { packages.default = pkgs.stdenv.mkDerivation { pname = "dfeed"; version = "unstable"; src = dfeedSrc; # Don't strip debug symbols (we build with -g) dontStrip = true; nativeBuildInputs = with pkgs; [ dmd dtools # Provides rdmd jre_minimal # For htmlcompressor and yuicompressor git which ]; buildInputs = with pkgs; [ curl sqlite openssl ]; # Setup build environment preConfigure = '' # Make compressors available cp ${htmlcompressor} htmlcompressor-1.5.3.jar cp ${yuicompressor} yuicompressor-2.4.8.jar # Copy site-defaults for minification (not part of D source) cp -r ${siteDefaultsSrc} site-defaults chmod -R u+w site-defaults ''; buildPhase = '' runHook preBuild # Set rdmd to use dmd by default export DCOMPILER=dmd # Detect OpenSSL version if [ -f lib/deimos-openssl/scripts/generate_version.d ]; then echo "Generating OpenSSL version detection..." rdmd --compiler=dmd lib/deimos-openssl/scripts/generate_version.d fi # Set up D compiler flags flags=( -m64 -g -Isrc -Ilib -L-lcurl -L-lsqlite3 -L-lssl -L-lcrypto ) # Add version flag for OpenSSL auto-detection if [ -f lib/deimos-openssl/scripts/generate_version.d ]; then flags+=(-version=DeimosOpenSSLAutoDetect) fi # Build all programs for fn in src/dfeed/progs/*.d; do name=$(basename "$fn" .d) echo "Building $name..." rdmd --compiler=dmd --build-only -of"$name" "''${flags[@]}" "$fn" done # Minify site-defaults resources (if not already minified) HTMLTOOL="java -jar htmlcompressor-1.5.3.jar --compress-css" JSTOOL="java -jar yuicompressor-2.4.8.jar --type js" CSSTOOL="java -jar yuicompressor-2.4.8.jar --type css" for htt in site-defaults/web/*.htt; do min="''${htt%.htt}.min.htt" if [ ! -f "$min" ] || [ "$htt" -nt "$min" ]; then echo "Minifying $htt..." $HTMLTOOL < "$htt" > "$min" || cp "$htt" "$min" fi done for css in site-defaults/web/static/css/*.css; do [[ "$css" == *.min.css ]] && continue min="''${css%.css}.min.css" if [ ! -f "$min" ] || [ "$css" -nt "$min" ]; then echo "Minifying $css..." $CSSTOOL < "$css" > "$min" || cp "$css" "$min" fi done for js in site-defaults/web/static/js/*.js; do [[ "$js" == *.min.js ]] && continue min="''${js%.js}.min.js" if [ ! -f "$min" ] || [ "$js" -nt "$min" ]; then echo "Minifying $js..." $JSTOOL < "$js" > "$min" || cp "$js" "$min" fi done runHook postBuild ''; installPhase = '' runHook preInstall mkdir -p $out/bin mkdir -p $out/share/dfeed # Install binaries for prog in dfeed nntpdownload sendspamfeedback unban; do if [ -f "$prog" ]; then install -Dm755 "$prog" $out/bin/"$prog" fi done # Install site-defaults (generic resources) cp -r site-defaults $out/share/dfeed/ runHook postInstall ''; meta = with pkgs.lib; { description = "D news aggregator, newsgroup client, web newsreader and IRC bot"; homepage = "https://github.com/CyberShadow/DFeed"; license = licenses.agpl3Plus; platforms = platforms.linux; maintainers = [ ]; }; }; # Generate screenshots from Playwright tests packages.screenshots = pkgs.stdenv.mkDerivation { pname = "dfeed-screenshots"; version = "unstable"; src = self; nativeBuildInputs = with pkgs; [ playwright-test curl sqlite # Fonts for proper rendering in screenshots liberation_ttf dejavu_fonts freefont_ttf ]; HOME = "/tmp/playwright-home"; FONTCONFIG_FILE = pkgs.makeFontsConf { fontDirectories = with pkgs; [ liberation_ttf dejavu_fonts freefont_ttf ]; }; buildPhase = '' runHook preBuild ${generateTestConfig} ${startServer} cd tests playwright test --project=screenshots --reporter=list || true cd .. ${stopServer} runHook postBuild ''; installPhase = '' mkdir -p $out cp tests/screenshot-*.png $out/ 2>/dev/null || echo "No screenshots found" ls -la $out/ ''; }; # Development shell for working on the project devShells.default = pkgs.mkShell { buildInputs = with pkgs; [ dmd dtools dub curl sqlite openssl jre_minimal gnumake git ]; shellHook = '' echo "DFeed development environment" echo "DMD version: $(dmd --version | head -1)" ''; }; # Checks to run with 'nix flake check' checks = { # Verify that the package builds successfully build = self.packages.${system}.default; # Run Playwright end-to-end tests playwright = pkgs.stdenv.mkDerivation { pname = "dfeed-playwright-tests"; version = "unstable"; src = self; nativeBuildInputs = with pkgs; [ playwright-test curl sqlite ]; # Playwright needs writable home for cache HOME = "/tmp/playwright-home"; buildPhase = '' runHook preBuild ${generateTestConfig} ${startServer} # Run Playwright tests cd tests playwright test --project=default --reporter=list || TEST_RESULT=$? cd .. ${stopServer} # Check test result if [ "''${TEST_RESULT:-0}" != "0" ]; then echo "Playwright tests failed" exit 1 fi runHook postBuild ''; installPhase = '' mkdir -p $out echo "Playwright tests passed" > $out/result ''; }; # Run D unittests unittests = pkgs.stdenv.mkDerivation { pname = "dfeed-unittests"; version = "unstable"; src = self; nativeBuildInputs = with pkgs; [ dmd dtools ]; buildInputs = with pkgs; [ curl sqlite openssl ]; buildPhase = '' runHook preBuild export DCOMPILER=dmd # Detect OpenSSL version if [ -f lib/deimos-openssl/scripts/generate_version.d ]; then echo "Generating OpenSSL version detection..." rdmd --compiler=dmd lib/deimos-openssl/scripts/generate_version.d fi # Compile library with unittests echo "Compiling and running unittests..." dmd -unittest -main -i -Isrc -Ilib -L-lcurl -L-lsqlite3 -L-lssl -L-lcrypto \ $(find src -name "*.d" | grep -v "src/dfeed/progs/") \ -version=DeimosOpenSSLAutoDetect \ -od=unittest-obj -of=unittest-runner runHook postBuild ''; checkPhase = '' echo "Running unittests..." ./unittest-runner ''; installPhase = '' mkdir -p $out echo "Unittests passed" > $out/result ''; doCheck = true; }; }; } ); } ================================================ FILE: makejson ================================================ #!/bin/bash set -eu git ls-files | grep '^src/.*\.d$' | xargs dmd -Xfdfeed.json -o- ================================================ FILE: schema_v1.sql ================================================ -- Initial version of the database schema. -- See src/dfeed/database.d for updates since this initial revision. -- Table `Groups` CREATE TABLE [Groups] ( [Group] VARCHAR(50) NULL, [ArtNum] INTEGER NULL, [ID] VARCHAR(50) NULL , Time INTEGER); -- Table `Posts` CREATE TABLE [Posts] ( [ID] VARCHAR(50) NULL, [Message] TEXT NULL, [Author] VARCHAR(255) NULL, [Subject] VARCHAR(255) NULL, [Time] INTEGER NULL, [ParentID] VARCHAR(50) NULL, [ThreadID] VARCHAR(50) NULL , [AuthorEmail] VARCHAR(50)); -- Table `Threads` CREATE TABLE [Threads] ( [Group] VARCHAR(50) NULL, [ID] VARCHAR(50) NULL, [LastUpdated] INTEGER NULL , LastPost VARCHAR(50), [Created] INTEGER NULL); -- Index `PostThreadID` on table `Posts` CREATE INDEX [PostThreadID] ON [Posts]( [ThreadID] ASC ); -- Index `ThreadGroup` on table `Threads` CREATE INDEX [ThreadGroup] ON [Threads] ( [Group] ); -- Index `GroupTime` on table `Groups` CREATE INDEX GroupTime ON Groups (`Group`, Time DESC); -- Index `ThreadOrder` on table `Threads` CREATE INDEX ThreadOrder ON Threads ([Group], [LastUpdated] DESC); -- Index `GroupID` on table `Groups` CREATE UNIQUE INDEX [GroupID] ON [Groups]( [Group] ASC, [ID] ASC ); -- Index `PostID` on table `Posts` CREATE UNIQUE INDEX [PostID] ON "Posts"( [ID] ASC ); -- Index `ThreadID` on table `Threads` CREATE INDEX "ThreadID" ON "Threads" ( ID ); -- Index `PostParentID` on table `Posts` CREATE INDEX PostParentID ON Posts ( ParentID ); -- Table `Users` CREATE TABLE [Users] ( [Username] VARCHAR(50), [Password] VARCHAR(50), [Session] VARCHAR(50) , [Level] INTEGER NOT NULL DEFAULT 0, [Created] INTEGER); -- Index `UserName` on table `Users` CREATE UNIQUE INDEX [UserName] ON [Users] ( [Username] ); -- Table `UserSettings` CREATE TABLE [UserSettings] ( [User] VARCHAR(50), [Name] VARCHAR(50), [Value] TEXT ); -- Index `UserSetting` on table `UserSettings` CREATE UNIQUE INDEX [UserSetting] on [UserSettings] ( [User], [Name] ); -- Index `GroupArtNum` on table `Groups` CREATE INDEX [GroupArtNum] ON [Groups] ( [Group], [ArtNum] ); -- Index `PostTime` on table `Posts` CREATE INDEX [PostTime] ON [Posts] ( [Time] DESC ); -- Table `Drafts` CREATE 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); -- Index `DraftID` on table `Drafts` CREATE UNIQUE INDEX [DraftID] ON [Drafts] ([ID]); -- Index `DraftUserID` on table `Drafts` CREATE INDEX [DraftUserID] ON [Drafts] ([UserID], [Status]); -- Index `DraftPostID` on table `Drafts` CREATE UNIQUE INDEX [DraftPostID] ON [Drafts] ([PostID]); -- Table `Subscriptions` CREATE TABLE [Subscriptions] ( [ID] VARCHAR(20) NOT NULL PRIMARY KEY, [Username] VARCHAR(50) NOT NULL, [Data] TEXT NULL ); -- Table `ReplyTriggers` CREATE TABLE [ReplyTriggers] ([Email] VARCHAR(50) NOT NULL, [SubscriptionID] VARCHAR(20) NOT NULL); -- Index `ReplyTriggerSubscripion` on table `ReplyTriggers` CREATE UNIQUE INDEX [ReplyTriggerSubscripion] ON [ReplyTriggers] ([SubscriptionID]); -- Index `ReplyTriggerEmail` on table `ReplyTriggers` CREATE INDEX [ReplyTriggerEmail] ON [ReplyTriggers] ([Email]); -- Table `ThreadTriggers` CREATE TABLE [ThreadTriggers] ([ThreadID] VARCHAR(50) NOT NULL, [SubscriptionID] VARCHAR(20) NOT NULL); -- Index `ThreadTriggerSubscription` on table `ThreadTriggers` CREATE UNIQUE INDEX [ThreadTriggerSubscription] ON [ThreadTriggers] ([SubscriptionID]); -- Index `ThreadTriggerThreadID` on table `ThreadTriggers` CREATE INDEX [ThreadTriggerThreadID] ON [ThreadTriggers] ([ThreadID]); -- Table `ContentTriggers` CREATE TABLE [ContentTriggers] ([SubscriptionID] VARCHAR(20) NOT NULL PRIMARY KEY); -- Table `SubscriptionPosts` CREATE TABLE [SubscriptionPosts] ( [SubscriptionID] VARCHAR(20) NOT NULL, [MessageID] VARCHAR(50) NOT NULL, [MessageRowID] INTEGER NOT NULL, [Time] INTEGER NOT NULL ); -- Index `SubscriptionPostID` on table `SubscriptionPosts` CREATE INDEX [SubscriptionPostID] ON [SubscriptionPosts] ([SubscriptionID], [Time] DESC); -- Table `PostSearch` CREATE VIRTUAL TABLE [PostSearch] USING fts4([Time], [ThreadMD5], [Group], [Author], [AuthorEmail], [Subject], [Content], [NewThread], order=desc); -- Index `ThreadCreated` on table `Threads` CREATE INDEX [ThreadCreated] ON [Threads] ([Created] DESC); -- Index `PostAuthorEmail` on table `Posts` CREATE INDEX [PostAuthorEmail] ON [Posts] ([AuthorEmail]); -- Table `Flags` CREATE TABLE [Flags] ([PostID] VARCHAR(50), [Username] VARCHAR(50), [Date] INTEGER); -- Index `UserFlags` on table `Flags` CREATE INDEX [UserFlags] ON [Flags] ([Username], [PostID]); ================================================ FILE: site-defaults/config/apis/akismet.ini.sample ================================================ # Akismet (http://akismet.com/) key for spam checks. #key = abcdefghijkl ================================================ FILE: site-defaults/config/apis/bitly.ini.sample ================================================ # bitly.com API credentials. Used for shortening links for IRC. #login = your_username #apiKey = R_0123456789abcdef0123456789abcdef ================================================ FILE: site-defaults/config/apis/mailhide.ini.sample ================================================ # Google MailHide API. Not currently used. #publicKey = ABCDEFGHIJKLMNOPQRSTUVWX== #privateKey = 0123456789abcdef0123456789abcdef ================================================ FILE: site-defaults/config/apis/openai.ini.sample ================================================ # OpenAI API configuration for spam detection # Sign up at https://platform.openai.com/ to get an API key # API key (required) #apiKey = sk-proj-... # Model to use (default: gpt-4o-mini) # Supported models: gpt-4o-mini, gpt-4o, gpt-4-turbo, gpt-3.5-turbo # Note: We use logprobs for confidence assessment, so reasoning models (o1/o3) are not supported #model = gpt-4o-mini ================================================ FILE: site-defaults/config/apis/projecthoneypot.ini.sample ================================================ # Project Honey Pot (https://www.projecthoneypot.org/) API key for spam checks. #key=abcdefghijkl ================================================ FILE: site-defaults/config/apis/recaptcha.ini.sample ================================================ # reCAPTCHA API publicKey = ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmn privateKey = ABCDEFGHIJKMLMOPQRSTUVWXYZabcdefghijklmn ================================================ FILE: site-defaults/config/apis/stopforumspam.ini ================================================ # StopForumSpam spam checker configuration. # This service checks poster IP addresses against a database of known spammers. # See: https://www.stopforumspam.com/ # Whether to enable StopForumSpam checking. # Set to false to disable (e.g., in environments without network access). enabled = true ================================================ FILE: site-defaults/config/backup.ini.sample ================================================ # Incremental daily backups using xdelta3 # Copy this file to backup.ini to enable. # Time of day to run the backup. # Hour (0-23): hour = 7 # Minute (0-59): minute = 0 ================================================ FILE: site-defaults/config/groups.ini ================================================ # Default DFeed groups configuration # For testing and as an example. Site deployments should create their own. [sets.general] name=General shortName=General visible=true [groups.general] internalName=general publicName=General Discussion navName=General urlName=general groupSet=general description=General discussion forum sinkType=local announce=false captcha=none [sets.test] name=Test shortName=Test visible=true [groups.test] internalName=test publicName=Test Forum navName=Test urlName=test groupSet=test description=A test forum for trying out posting sinkType=local announce=false captcha=none ================================================ FILE: site-defaults/config/groups.ini.sample ================================================ # The groups, as displayed on the web interface's index page. # Groups are arranged in sets. [sets.example-set] # Long name (shown on index page) name=Example set # Short name (shown in navigation) shortName=Example set # Example group. [groups.example] # Name used by the mailing lists, NNTP servers, etc. internalName=example # Name visible on web pages. publicName=Example # urlName is what appears in URLs. urlName=example # description is displayed on the index page. description=Example group # ID (section name) of the set this group appears in. groupSet=example-set # alsoVia is an optional set of links for other ways to access this group. alsoVia.nntp.name=NNTP alsoVia.nntp.url=news://news.example.com/example # How posted messages are propagated. # sinkType can be smtp or nntp. # # The corresponding configuration file from # config/sinks//.ini # will be consulted. sinkType=smtp sinkName=example # Whether to show a warning that a subscription is required # when attempting to post to a mailing list (sinkType==smtp). # Enabled by default. subscriptionRequired=true # Whether new threads in the post are considered very important, and # will be announced to e.g. Twitter (if configured). announce=false # CAPTCHA mechanism to use for challenging users who post # messages which triggered a spam filter. # Valid values are: # - none - no CAPTCHA, messages go directly to the moderation queue # - recaptcha - Google's reCAPTCHA service, requires configuring keys # - dcaptcha - randomly generated D Programming Language questions captcha=none ================================================ FILE: site-defaults/config/sinks/irc/irc.ini.sample ================================================ # IRC sink parameters. # IRC server to connect to. #server = irc.libera.chat # Port. Defaults to 6667. #port = 6667 # Nickname to use. #nick = DFeed\My\Test # Primary channel - only "important" announcements will be posted here. #channel = dfeed.test # Secondary (feed) channel, for all announcements. #channel2 = dfeed.test2 ================================================ FILE: site-defaults/config/sinks/twitter/twitter.ini.sample ================================================ # Twitter sink parameters. # OAuth parameters. oauth.consumerKey = abcdefghijklmnopqrstuvwxy oauth.consumerSecret = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWX oauthAccessToken = 123456789012345678-abcdefghijklmnopqrstuvwxyzABCDE oauthAccessTokenSecret = abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRS # How to format the tweet, as std.string.format arguments. # Arguments passed are, in order: subject, author name, URL formatString = %s by %s: %s #dlang ================================================ FILE: site-defaults/config/site.ini.sample ================================================ # The name of the website. Used in emails. name = Example Forum # The canonical domain name for your instance. # This is used in a number of places: # - Requests coming to other domain names will be redirected to the canonical one. # - Message IDs for new messages will use this setting for the hostname part. # - External URLs to web resources (e.g. in IRC announcements) will use this setting for the host part. # Default is "localhost". host = forum.example.com # Default HTTP URI protocol (http or https). # Used as above. When set to https, will redirect detected http requests to https. proto = http # A description of this DFeed instance to be shown on the help page (in the About section). about =

This is an example DFeed instance.

# OpenGraph image URL for social media previews. # If not set, no image will be used in OpenGraph metadata. ogImage = # List of email addresses of site moderators. # These will be used for notifications when a post is flagged. # E.g.: moderators=["John Doe ", "Jane Doe "] moderators=[] ================================================ FILE: site-defaults/config/sources/feeds/example.ini.sample ================================================ # Atom feed example configuration. # Feed title - used in IRC announcements. #name = Example # Direct URL to the ATOM XML file. #url = http://www.example.com/feeds/atom.xml # Action (verb used in IRC announcements). # The announcements is formatted like " ". # If left empty, as a special case, the author and verb is not included. # Defaults to "posted". #action = posted ================================================ FILE: site-defaults/config/sources/github/github.ini.sample ================================================ # GitHub hook secret value #secret = ================================================ FILE: site-defaults/config/sources/mailman/example.ini.sample ================================================ # Mailman HTTP archives. baseURL = http://lists.example.com/pipermail/ lists = general,announcements ================================================ FILE: site-defaults/config/sources/mailrelay/example.ini.sample ================================================ # Messages coming from other sources (e.g. email, for mailing lists) # can be piped to the address/port configured here. # DFeed will accept one message per connection, and process it as usual. #addr = 127.0.0.1 #port = 12345 # Exim configuration: # Create an alias file with the content: # | nc -q 0 127.0.0.1 12345 # qmail/vpopmail configuration: # For address foo@bar.com, create the file # ~vpopmail/domains/bar.com/.qmail-foo # with the content: # | nc -q 0 127.0.0.1 12345 ================================================ FILE: site-defaults/config/sources/nntp/example.ini.sample ================================================ # NNTP source. host = news.example.com # Whether posting is allowed. # This SHOULD be configured in groups.ini, but it can be disabled here as well. # Note that currently this will work only as a last-minute check, # i.e. the user will be notified only as they attempt to send the # message. postingAllowed = true # Command to delete messages from the source NNTP server. # If specified, an option will appear to delete messages from the # source in the post moderation UI. # The command is invoked with the first argument being the message ID, # and the following arguments being the group:article-number tuples # (as they appear in the Xref header). #deleteCommand = /path/to/script.sh ================================================ FILE: site-defaults/config/sources/smtp/example.ini.sample ================================================ # Mailing list "source" for sent messages. # Mailing list domain name (part after @ for email addresses). domain = example.com # MX server (telnet to this host and port 25 should succeed). server = mail.example.com # Port (default = 25). port = 25 # URL base for list info listInfo = http://lists.example.com/cgi-bin/mailman/ ================================================ FILE: site-defaults/config/sources/socket/socket.ini.sample ================================================ # This is a special source, currently used for # instant MediaWiki edit notifications, # but can be further expanded as needed. # See socket.d for details. #port = 12345 #password = abcdefghij ================================================ FILE: site-defaults/config/sources/stackoverflow/so.ini.sample ================================================ # StackOverflow questions feed. # Comma-separated list of tags to watch. tags = d # Optional - API key. # Requests will be rate-limited without one. #key = abcdefghijklmnopqrstuvwx ================================================ FILE: site-defaults/config/user.ini.sample ================================================ # A unique string used in hashing user passwords. # salt = insert random string here ================================================ FILE: site-defaults/config/web.ini.sample ================================================ # HTTP header to use for the client's real IP address when behind a reverse proxy. # Common values: "X-Forwarded-For", "X-Real-IP" # If not set, the direct connection IP is used. #remoteIPHeader = X-Forwarded-For # Optional additional cookie-less domain used for static resources. # Should not be a subdomain of the main domain. #staticDomain = example-static.com # Whether this instance should be indexed by search engines # (or crawled by other web spiders). # This affects what is served in robots.txt. # The default is false. #indexable = true # API secret for programmatic access (e.g., for moderation APIs). # If not set, API endpoints will be disabled. #apiSecret = your-secret-here # Widget configuration for the home page. # Group to use for the "Latest announcements" widget. # If not set, the widget will not be displayed. #announceGroup = announce # Groups to exclude from the "Active discussions" widget. # Use an array format: ["group1", "group2"] #activeDiscussionExclude = ["announce"] # HTTP socket parameters. [listen] # Network address to bind to. By default, binds on all interfaces. # addr = 127.0.0.1 # Port to listen on. port = 8080 ================================================ FILE: site-defaults/web/help-english.htt ================================================ <!-- HTML --> <h2 id="view-modes">View modes</h2> <p> You can browse the forum using one of several view modes: <ul> <li><b>Basic</b> - A forum-like view with paged linear threads.</li> <li><b>Threaded</b> - Threaded group overview with single post display, similar to mailing list archives.</li> <li><b>Horizontal-split</b> - JavaScript-powered interface with a split view, similar to a usenet client.</li> <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> </ul> The view mode can be changed on the <a href="/settings">settings page</a>. </p> <h2 id="keynav">Keyboard navigation</h2> <p> Keyboard shortcuts are available for all view modes (in thread and post listing pages, as well as the forum index). If JavaScript is enabled, press <kbd>?</kbd> to view a list of shortcuts. </p> <p> If you wish, you can disable keyboard shortcuts on the <a href="/settings">settings page</a>. </p> <h2 id="read-post-history">Read post history</h2> <p> The posts you've viewed are saved to a compressed cookie, or on the server if you're logged in. Viewing a thread in basic view will mark all displayed posts as "read". Posts can be marked as "unread" using the <kbd>u</kbd> keyboard shortcut. </p> <p> To avoid losing read post history, consider registering an account to avoid cookie limitations / expiration / accidental deletion. </p> <h2 id="accounts">Accounts</h2> <p> You do not need an account to browse or post to this forum. Preferences and read post history are stored in browser cookies for unregistered users. </p> <p> You can register an account to keep them on the server instead. Registering an account will transfer all variables from cookies to the server database. </p> <p> 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. </p> <h2 id="email">Email address</h2> <p> When posting, you need to indicate an email address. It doesn't need to be a valid one; this software will not send anything to the specified address. The email address will be made public to other users of the news server / mailing list you are posting to. Therefore, please be aware that malicious robots may be able to collect your address and send spam to it. </p> <p> The email address is also used to display an avatar (see below). </p> <h2 id="markdown">Markdown formatting</h2> <p> You may optionally use Markdown formatting when authoring posts. The specific variant of Markdown used is <a href="https://github.github.com/gfm/#what-is-github-flavored-markdown-">GitHub Flavored Markdown</a>. </p> <p> The following is a quick guide for some available syntax: </p> <style> #forum-content table.help-table { border-spacing: initial; margin: 16px 0; width: auto; } #forum-content table.help-table th { background-color: #F5F5F5; } #forum-content table.help-table th, #forum-content table.help-table td { border: 1px solid #E6E6E6; padding: 0.1em 0.3em; } </style> <table class="help-table"> <tr><th>Formatting</th><th>What you type</th><th>What you get</th></tr> <tr><td>Bold text</td><td><code>**sample text**</code></td><td> <b>sample text</b> </td></tr> <tr><td>Italic text</td><td><code>*sample text*</code></td><td> <i>sample text</i> </td></tr> <tr><td>Links</td><td><code>[GitHub](https://github.com/)</code></td><td> <a href="https://github.com/">GitHub</a> </td></tr> <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> <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> <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> </table> <p> For more information, consult <a href="https://guides.github.com/features/mastering-markdown/">GitHub's documentation</a> or <a href="https://github.github.com/gfm/">the full specification</a>, though please note that not all GitHub extensions are enabled on this forum. </p> <p> Markdown rendering may be completely disabled from the <a href="/settings">settings page</a>. </p> <h2 id="avatars">Avatars</h2> <p> The forum will display avatars associated with users' email addresses. If the email address is registered with <a href="http://en.gravatar.com/">Gravatar</a>, the associated avatar is shown. 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. </p> <p> To use a custom avatar on this forum, <a href="http://en.gravatar.com/site/signup/">register an account at Gravatar</a>, associate an email address with an image, and use that email address when posting to this forum. Additionally, you can create a Gravatar profile, which will be accessible by clicking on your avatar. </p> <h2 id="profiles">User profiles and signatures</h2> <p> Since messages can come from a variety of sources, this forum does not have customizable user profiles. 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. Click a user's avatar to go to their Gravatar profile page, assuming they have created one. </p> <p id="signatures"> For similar reasons, this forum does not allow configuring a signature. Signatures are not as useful in messages on the web today, and often devolve to a low signal-to-noise ratio. Instead, you can put relevant information on your Gravatar profile, or on your website (and link to it from your Gravatar profile). </p> <h2 id="canonical">Canonical links</h2> <p> 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 (by right-clicking it and selecting "Copy link location", or your browser's equivalent). 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. 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). </p> <p> To get the canonical link to a thread, just use the first post's canonical link. If you use the "threaded" or "horizontal-split" view mode, you can simply copy the URL from your address bar. Each post's title is also a canonical link to the post in question in any view mode. </p> <h2 id="drafts">Drafts</h2> <p> When you click "Save and preview", a draft of your message will be saved on the server. If JavaScript is enabled, this will also occur periodically as you are typing the message. </p> <p> If you accidentally close the browser tab with the message, you can restore it by opening a posting form (by clicking "Create thread" or replying to a post). A notice will appear before the form if there are any unsent drafts. To discard a draft, click the "Discard draft" button at the bottom of the posting form. </p> <h2 id="about">About</h2> <p> This website is powered by DFeed, an NNTP / mailing list web frontend / forum software, news aggregator and IRC bot. DFeed was written mostly by <a href="https://thecybershadow.net/">Vladimir Panteleev</a>. The source code is available under the <a href="http://www.gnu.org/licenses/agpl-3.0.html">GNU Affero General Public License</a> on GitHub: <a href="https://github.com/CyberShadow/DFeed">https://github.com/CyberShadow/DFeed</a> </p> <?about?> <h2 id="contributing">Contributing</h2> <p> This forum software is open-source, and written in the D programming language. Contributions are welcome. You can help improve this software by reporting bugs, giving feedback, and submitting pull requests. Patches for fixes, improvements, documentation, unit tests, refactoring, etc. are all welcome. </p> <p> 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. </p> ================================================ FILE: site-defaults/web/help-turkish.htt ================================================ <!-- HTML --> <h2 id="view-modes">Görünüm seçenekleri</h2> <p> Bu forumu farklı biçimlerde görüntüleyebilirsiniz: <ul> <li><b>Temel</b> - Konuların art arda sayfalar halinde listelendiği forum benzeri görünüm.</li> <li><b>Gönderi listesi</b> - Konu gönderilerinin tek satırda gösterildiği genel görünüm.</li> <li><b>Yatay bölünmüş</b> - Usenet programlarına benzer biçimde bölünmüş JavaScript destekli arayüz.</li> <li><b>Dikey bölünmüş</b> - E-posta programlarına benzer biçimde üstte liste, altta ileti olan görünüm.</li> </ul> Görünüm, <a href="/settings">ayarlar sayfasında</a> değiştirilebilir. </p> <h2 id="keynav">Klavye kısayolları</h2> <p> Tüm görünüm seçenekleri için klavye kısayolları mevcuttur (konu ve gönderi listelerinde ve forum dizininde). Kısayolların listesini görüntülemek için <kbd>?</kbd> tuşuna basın. (JavaScript gerektirir.) </p> <p> Klavye kısayollarını <a href="/settings">ayarlar sayfasında</a> devre dışı bırakabilirsiniz. </p> <h2 id="read-post-history">Okuma geçmişi</h2> <p> 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. Bir konu temel görünümde görüntülendiğinde, görüntülenen tüm gönderiler "okundu" olarak işaretlenir. Gönderiler <kbd>u</kbd> klavye kısayolu kullanılarak tekrar "okunmamış" olarak işaretlenebilir. </p> <p> 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. </p> <h2 id="accounts">Kullanıcı hesapları</h2> <p> Bu forumu okumak veya mesaj göndermek için hesap açmanıza gerek yok. O durumda ayarlarınız ve okuma geçmişiniz bir tarayıcı çerezinde saklanır. </p> <p> Bir hesap açtığınızda ise ayarlarınız ve okuma geçmişiniz sunucu tarafında saklanır. Yeni bir hesabın açılması, tüm değişkenleri çerezlerden sunucu veritabanına aktaracaktır. </p> <p> 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. </p> <h2 id="email">E-posta</h2> <p> 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. 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. Bu nedenle, verdiğiniz adresi kötü niyetli robotların da görebileceklerini ve bu adrese spam gönderebileceklerini unutmayın. </p> <p> Belirttiğiniz e-posta adresi avatar görüntülemek için de kullanılır. </p> <h2 id="avatars">Avatarlar</h2> <p> Forum, kullanıcıların e-posta adresleriyle ilişkili avatarlar gösterir. Kullanılan e-posta adresi <a href="http://tr.gravatar.com/">Gravatar'da</a> kayıtlıysa oradaki avatar gösterilir. Değilse, avatar yerine e-posta adresinden otomatik olarak oluşturulmuş bir <a href="https://en.wikipedia.org/wiki/Identicon">Identicon</a> gösterilir. </p> <p> Bu forumda avatar kullanmak için <a href="http://tr.gravatar.com/site/signup/">Gravatar'da bir hesap açın,</a> orada bir e-posta adresini bir resimle ilişkilendirin, ve bu foruma mesaj gönderirken o e-posta adresini kullanın. Ek olarak, avatarınıza tıklandığında erişilen bir Gravatar profili de oluşturabilirsiniz. </p> <h2 id="profiles">Kullanıcı profilleri ve bilgileri</h2> <p> Mesajlar farklı kaynaklardan gelebildiğinden bu forum kullanıcı profili içermez. 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. Gravatar profili olan kullanıcıların profillerine avatarlarına tıklayarak erişebilirsiniz. </p> <p id="signatures"> Bu forum kullanıcı bilgilerini de benzer nedenlerden dolayı içermez. Bunun yerine, ilgili bilgileri Gravatar profilinize veya kendi sitenize koyabilirsiniz ve Gravatar profilinizden sitenize bağlantı verebilirsiniz. </p> <h2 id="canonical">Kalıcı bağlantılar</h2> <p> 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 (sağ tıklayıp tarayıcınızın bağlantı kopyalama olanağından yararlanarak). 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. 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. </p> <p> Bir konunun kalıcı bağlantısı için ilk gönderinin bağlantısını kullanmanız yeterlidir. "Gönderi listesi" veya "Yatay bölünmüş" görünümü kullanıyorsanız adres çubuğundaki bağlantıyı kopyalayabilirsiniz. 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. </p> <h2 id="drafts">Taslaklar</h2> <p> "Kaydet ve önizle"yi tıkladığınızda mesajınızın bir taslağı sunucuya kaydedilir. JavaScript etkinse, bu, siz mesajı yazarken de kendiliğinden gerçekleşecektir. </p> <p> 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 ("Yeni konu aç"ı tıklayarak veya bir gönderiyi yanıtlayarak) geri yükleyebilirsiniz. Gönderilmemiş taslak varsa, gönderme formunun üstünde bir uyarı görünecektir. Bir taslağı silmek için kayıt formunun altındaki "Taslağı sil" düğmesini tıklayın. </p> <h2 id="about">About</h2> <p> This website is powered by DFeed, an NNTP / mailing list web frontend / forum software, news aggregator and IRC bot. DFeed was written mostly by <a href="https://thecybershadow.net/">Vladimir Panteleev</a>. The source code is available under the <a href="http://www.gnu.org/licenses/agpl-3.0.html">GNU Affero General Public License</a> on GitHub: <a href="https://github.com/CyberShadow/DFeed">https://github.com/CyberShadow/DFeed</a> </p> <?about?> <h2 id="contributing">Sizin katkılarınız</h2> <p> D programlama dilinde yazılmış olan bu forum açık kaynaklıdır. 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. Düzeltme, iyileştirme, belgeleme, birim testleri, kodun yeniden düzenlenmesi vb. her türlü yardımı kabul etmekteyiz. </p> <p> 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. </p> ================================================ FILE: site-defaults/web/highlight-js/LICENSE ================================================ BSD 3-Clause License Copyright (c) 2006, Ivan Sagalaev. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: * Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. * Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. * Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission. THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ================================================ FILE: site-defaults/web/highlight-js/highlight.pack.js ================================================ /* Highlight.js 10.7.1 (421b23b0) License: BSD-3-Clause Copyright (c) 2006-2021, Ivan Sagalaev */ var hljs=function(){"use strict";function e(t){ return t instanceof Map?t.clear=t.delete=t.set=()=>{ throw Error("map is read-only")}:t instanceof Set&&(t.add=t.clear=t.delete=()=>{ throw Error("set is read-only") }),Object.freeze(t),Object.getOwnPropertyNames(t).forEach((n=>{var i=t[n] ;"object"!=typeof i||Object.isFrozen(i)||e(i)})),t}var t=e,n=e;t.default=n ;class i{constructor(e){ void 0===e.data&&(e.data={}),this.data=e.data,this.isMatchIgnored=!1} ignoreMatch(){this.isMatchIgnored=!0}}function s(e){ return e.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'") }function a(e,...t){const n=Object.create(null);for(const t in e)n[t]=e[t] ;return t.forEach((e=>{for(const t in e)n[t]=e[t]})),n}const r=e=>!!e.kind ;class l{constructor(e,t){ this.buffer="",this.classPrefix=t.classPrefix,e.walk(this)}addText(e){ this.buffer+=s(e)}openNode(e){if(!r(e))return;let t=e.kind ;e.sublanguage||(t=`${this.classPrefix}${t}`),this.span(t)}closeNode(e){ r(e)&&(this.buffer+="</span>")}value(){return this.buffer}span(e){ this.buffer+=`<span class="${e}">`}}class o{constructor(){this.rootNode={ children:[]},this.stack=[this.rootNode]}get top(){ return this.stack[this.stack.length-1]}get root(){return this.rootNode}add(e){ this.top.children.push(e)}openNode(e){const t={kind:e,children:[]} ;this.add(t),this.stack.push(t)}closeNode(){ if(this.stack.length>1)return this.stack.pop()}closeAllNodes(){ for(;this.closeNode(););}toJSON(){return JSON.stringify(this.rootNode,null,4)} walk(e){return this.constructor._walk(e,this.rootNode)}static _walk(e,t){ return"string"==typeof t?e.addText(t):t.children&&(e.openNode(t), t.children.forEach((t=>this._walk(e,t))),e.closeNode(t)),e}static _collapse(e){ "string"!=typeof e&&e.children&&(e.children.every((e=>"string"==typeof e))?e.children=[e.children.join("")]:e.children.forEach((e=>{ o._collapse(e)})))}}class c extends o{constructor(e){super(),this.options=e} addKeyword(e,t){""!==e&&(this.openNode(t),this.addText(e),this.closeNode())} addText(e){""!==e&&this.add(e)}addSublanguage(e,t){const n=e.root ;n.kind=t,n.sublanguage=!0,this.add(n)}toHTML(){ return new l(this,this.options).value()}finalize(){return!0}}function g(e){ return e?"string"==typeof e?e:e.source:null} const 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={ begin:"\\\\[\\s\\S]",relevance:0},E={className:"string",begin:"'",end:"'", illegal:"\\n",contains:[b]},x={className:"string",begin:'"',end:'"', illegal:"\\n",contains:[b]},v={ begin:/\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/ },w=(e,t,n={})=>{const i=a({className:"comment",begin:e,end:t,contains:[]},n) ;return i.contains.push(v),i.contains.push({className:"doctag", begin:"(?:TODO|FIXME|NOTE|BUG|OPTIMIZE|HACK|XXX):",relevance:0}),i },y=w("//","$"),N=w("/\\*","\\*/"),R=w("#","$");var _=Object.freeze({ __proto__:null,MATCH_NOTHING_RE:/\b\B/,IDENT_RE:h,UNDERSCORE_IDENT_RE:d, NUMBER_RE:f,C_NUMBER_RE:p,BINARY_NUMBER_RE:m, RE_STARTERS_RE:"!|!=|!==|%|%=|&|&&|&=|\\*|\\*=|\\+|\\+=|,|-|-=|/=|/|:|;|<<|<<=|<=|<|===|==|=|>>>=|>>=|>=|>>>|>>|>|\\?|\\[|\\{|\\(|\\^|\\^=|\\||\\|=|\\|\\||~", SHEBANG:(e={})=>{const t=/^#![ ]*\// ;return e.binary&&(e.begin=((...e)=>e.map((e=>g(e))).join(""))(t,/.*\b/,e.binary,/\b.*/)), a({className:"meta",begin:t,end:/$/,relevance:0,"on:begin":(e,t)=>{ 0!==e.index&&t.ignoreMatch()}},e)},BACKSLASH_ESCAPE:b,APOS_STRING_MODE:E, QUOTE_STRING_MODE:x,PHRASAL_WORDS_MODE:v,COMMENT:w,C_LINE_COMMENT_MODE:y, C_BLOCK_COMMENT_MODE:N,HASH_COMMENT_MODE:R,NUMBER_MODE:{className:"number", begin:f,relevance:0},C_NUMBER_MODE:{className:"number",begin:p,relevance:0}, BINARY_NUMBER_MODE:{className:"number",begin:m,relevance:0},CSS_NUMBER_MODE:{ className:"number", begin: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)?", relevance:0},REGEXP_MODE:{begin:/(?=\/[^/\n]*\/)/,contains:[{className:"regexp", begin:/\//,end:/\/[gimuy]*/,illegal:/\n/,contains:[b,{begin:/\[/,end:/\]/, relevance:0,contains:[b]}]}]},TITLE_MODE:{className:"title",begin:h,relevance:0 },UNDERSCORE_TITLE_MODE:{className:"title",begin:d,relevance:0},METHOD_GUARD:{ begin:"\\.\\s*[a-zA-Z_]\\w*",relevance:0},END_SAME_AS_BEGIN:e=>Object.assign(e,{ "on:begin":(e,t)=>{t.data._beginMatch=e[1]},"on:end":(e,t)=>{ t.data._beginMatch!==e[1]&&t.ignoreMatch()}})});function k(e,t){ "."===e.input[e.index-1]&&t.ignoreMatch()}function M(e,t){ t&&e.beginKeywords&&(e.begin="\\b("+e.beginKeywords.split(" ").join("|")+")(?!\\.)(?=\\b|\\s)", e.__beforeBegin=k,e.keywords=e.keywords||e.beginKeywords,delete e.beginKeywords, void 0===e.relevance&&(e.relevance=0))}function O(e,t){ Array.isArray(e.illegal)&&(e.illegal=((...e)=>"("+e.map((e=>g(e))).join("|")+")")(...e.illegal)) }function A(e,t){if(e.match){ if(e.begin||e.end)throw Error("begin & end are not supported with match") ;e.begin=e.match,delete e.match}}function L(e,t){ void 0===e.relevance&&(e.relevance=1)} const I=["of","and","for","in","not","or","if","then","parent","list","value"] ;function j(e,t,n="keyword"){const i={} ;return"string"==typeof e?s(n,e.split(" ")):Array.isArray(e)?s(n,e):Object.keys(e).forEach((n=>{ Object.assign(i,j(e[n],t,n))})),i;function s(e,n){ t&&(n=n.map((e=>e.toLowerCase()))),n.forEach((t=>{const n=t.split("|") ;i[n[0]]=[e,B(n[0],n[1])]}))}}function B(e,t){ return t?Number(t):(e=>I.includes(e.toLowerCase()))(e)?0:1} function T(e,{plugins:t}){function n(t,n){ return RegExp(g(t),"m"+(e.case_insensitive?"i":"")+(n?"g":""))}class i{ constructor(){ this.matchIndexes={},this.regexes=[],this.matchAt=1,this.position=0} addRule(e,t){ t.position=this.position++,this.matchIndexes[this.matchAt]=t,this.regexes.push([t,e]), this.matchAt+=(e=>RegExp(e.toString()+"|").exec("").length-1)(e)+1}compile(){ 0===this.regexes.length&&(this.exec=()=>null) ;const e=this.regexes.map((e=>e[1]));this.matcherRe=n(((e,t="|")=>{let n=0 ;return e.map((e=>{n+=1;const t=n;let i=g(e),s="";for(;i.length>0;){ const e=u.exec(i);if(!e){s+=i;break} s+=i.substring(0,e.index),i=i.substring(e.index+e[0].length), "\\"===e[0][0]&&e[1]?s+="\\"+(Number(e[1])+t):(s+=e[0],"("===e[0]&&n++)}return s })).map((e=>`(${e})`)).join(t)})(e),!0),this.lastIndex=0}exec(e){ this.matcherRe.lastIndex=this.lastIndex;const t=this.matcherRe.exec(e) ;if(!t)return null ;const n=t.findIndex(((e,t)=>t>0&&void 0!==e)),i=this.matchIndexes[n] ;return t.splice(0,n),Object.assign(t,i)}}class s{constructor(){ this.rules=[],this.multiRegexes=[], this.count=0,this.lastIndex=0,this.regexIndex=0}getMatcher(e){ if(this.multiRegexes[e])return this.multiRegexes[e];const t=new i ;return this.rules.slice(e).forEach((([e,n])=>t.addRule(e,n))), t.compile(),this.multiRegexes[e]=t,t}resumingScanAtSamePosition(){ return 0!==this.regexIndex}considerAll(){this.regexIndex=0}addRule(e,t){ this.rules.push([e,t]),"begin"===t.type&&this.count++}exec(e){ const t=this.getMatcher(this.regexIndex);t.lastIndex=this.lastIndex ;let n=t.exec(e) ;if(this.resumingScanAtSamePosition())if(n&&n.index===this.lastIndex);else{ const t=this.getMatcher(0);t.lastIndex=this.lastIndex+1,n=t.exec(e)} return n&&(this.regexIndex+=n.position+1, this.regexIndex===this.count&&this.considerAll()),n}} if(e.compilerExtensions||(e.compilerExtensions=[]), e.contains&&e.contains.includes("self"))throw Error("ERR: contains `self` is not supported at the top-level of a language. See documentation.") ;return e.classNameAliases=a(e.classNameAliases||{}),function t(i,r){const l=i ;if(i.isCompiled)return l ;[A].forEach((e=>e(i,r))),e.compilerExtensions.forEach((e=>e(i,r))), i.__beforeBegin=null,[M,O,L].forEach((e=>e(i,r))),i.isCompiled=!0;let o=null ;if("object"==typeof i.keywords&&(o=i.keywords.$pattern, delete i.keywords.$pattern), i.keywords&&(i.keywords=j(i.keywords,e.case_insensitive)), i.lexemes&&o)throw Error("ERR: Prefer `keywords.$pattern` to `mode.lexemes`, BOTH are not allowed. (see mode reference) ") ;return o=o||i.lexemes||/\w+/, l.keywordPatternRe=n(o,!0),r&&(i.begin||(i.begin=/\B|\b/), l.beginRe=n(i.begin),i.endSameAsBegin&&(i.end=i.begin), i.end||i.endsWithParent||(i.end=/\B|\b/), i.end&&(l.endRe=n(i.end)),l.terminatorEnd=g(i.end)||"", i.endsWithParent&&r.terminatorEnd&&(l.terminatorEnd+=(i.end?"|":"")+r.terminatorEnd)), i.illegal&&(l.illegalRe=n(i.illegal)), i.contains||(i.contains=[]),i.contains=[].concat(...i.contains.map((e=>(e=>(e.variants&&!e.cachedVariants&&(e.cachedVariants=e.variants.map((t=>a(e,{ variants:null},t)))),e.cachedVariants?e.cachedVariants:S(e)?a(e,{ starts:e.starts?a(e.starts):null }):Object.isFrozen(e)?a(e):e))("self"===e?i:e)))),i.contains.forEach((e=>{t(e,l) })),i.starts&&t(i.starts,r),l.matcher=(e=>{const t=new s ;return e.contains.forEach((e=>t.addRule(e.begin,{rule:e,type:"begin" }))),e.terminatorEnd&&t.addRule(e.terminatorEnd,{type:"end" }),e.illegal&&t.addRule(e.illegal,{type:"illegal"}),t})(l),l}(e)}function S(e){ return!!e&&(e.endsWithParent||S(e.starts))}function P(e){const t={ props:["language","code","autodetect"],data:()=>({detectedLanguage:"", unknownLanguage:!1}),computed:{className(){ return this.unknownLanguage?"":"hljs "+this.detectedLanguage},highlighted(){ if(!this.autoDetect&&!e.getLanguage(this.language))return console.warn(`The language "${this.language}" you specified could not be found.`), this.unknownLanguage=!0,s(this.code);let t={} ;return this.autoDetect?(t=e.highlightAuto(this.code), this.detectedLanguage=t.language):(t=e.highlight(this.language,this.code,this.ignoreIllegals), this.detectedLanguage=this.language),t.value},autoDetect(){ return!(this.language&&(e=this.autodetect,!e&&""!==e));var e}, ignoreIllegals:()=>!0},render(e){return e("pre",{},[e("code",{ class:this.className,domProps:{innerHTML:this.highlighted}})])}};return{ Component:t,VuePlugin:{install(e){e.component("highlightjs",t)}}}}const D={ "after:highlightElement":({el:e,result:t,text:n})=>{const i=H(e) ;if(!i.length)return;const a=document.createElement("div") ;a.innerHTML=t.value,t.value=((e,t,n)=>{let i=0,a="";const r=[];function l(){ return 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 }function o(e){a+="<"+C(e)+[].map.call(e.attributes,(function(e){ return" "+e.nodeName+'="'+s(e.value)+'"'})).join("")+">"}function c(e){ a+="</"+C(e)+">"}function g(e){("start"===e.event?o:c)(e.node)} for(;e.length||t.length;){let t=l() ;if(a+=s(n.substring(i,t[0].offset)),i=t[0].offset,t===e){r.reverse().forEach(c) ;do{g(t.splice(0,1)[0]),t=l()}while(t===e&&t.length&&t[0].offset===i) ;r.reverse().forEach(o) }else"start"===t[0].event?r.push(t[0].node):r.pop(),g(t.splice(0,1)[0])} return a+s(n.substr(i))})(i,H(a),n)}};function C(e){ return e.nodeName.toLowerCase()}function H(e){const t=[];return function e(n,i){ for(let s=n.firstChild;s;s=s.nextSibling)3===s.nodeType?i+=s.nodeValue.length:1===s.nodeType&&(t.push({ event:"start",offset:i,node:s}),i=e(s,i),C(s).match(/br|hr|img|input/)||t.push({ event:"stop",offset:i,node:s}));return i}(e,0),t}const $=e=>{console.error(e) },U=(e,...t)=>{console.log("WARN: "+e,...t)},z=(e,t)=>{ console.log(`Deprecated as of ${e}. ${t}`)},K=s,G=a,V=Symbol("nomatch") ;return(e=>{const n=Object.create(null),s=Object.create(null),a=[];let r=!0 ;const l=/(^(<[^>]+>|\t|)+|\n)/gm,o="Could not find the language '{}', did you forget to load/include a language module?",g={ disableAutodetect:!0,name:"Plain text",contains:[]};let u={ noHighlightRe:/^(no-?highlight)$/i, languageDetectRe:/\blang(?:uage)?-([\w-]+)\b/i,classPrefix:"hljs-", tabReplace:null,useBR:!1,languages:null,__emitter:c};function h(e){ return u.noHighlightRe.test(e)}function d(e,t,n,i){let s="",a="" ;"object"==typeof t?(s=e, n=t.ignoreIllegals,a=t.language,i=void 0):(z("10.7.0","highlight(lang, code, ...args) has been deprecated."), z("10.7.0","Please use highlight(code, options) instead.\nhttps://github.com/highlightjs/highlight.js/issues/2277"), a=e,s=t);const r={code:s,language:a};M("before:highlight",r) ;const l=r.result?r.result:f(r.language,r.code,n,i) ;return l.code=r.code,M("after:highlight",l),l}function f(e,t,s,l){ function c(e,t){const n=v.case_insensitive?t[0].toLowerCase():t[0] ;return Object.prototype.hasOwnProperty.call(e.keywords,n)&&e.keywords[n]} function g(){null!=R.subLanguage?(()=>{if(""===M)return;let e=null ;if("string"==typeof R.subLanguage){ if(!n[R.subLanguage])return void k.addText(M) ;e=f(R.subLanguage,M,!0,_[R.subLanguage]),_[R.subLanguage]=e.top }else e=p(M,R.subLanguage.length?R.subLanguage:null) ;R.relevance>0&&(O+=e.relevance),k.addSublanguage(e.emitter,e.language) })():(()=>{if(!R.keywords)return void k.addText(M);let e=0 ;R.keywordPatternRe.lastIndex=0;let t=R.keywordPatternRe.exec(M),n="";for(;t;){ n+=M.substring(e,t.index);const i=c(R,t);if(i){const[e,s]=i ;if(k.addText(n),n="",O+=s,e.startsWith("_"))n+=t[0];else{ const n=v.classNameAliases[e]||e;k.addKeyword(t[0],n)}}else n+=t[0] ;e=R.keywordPatternRe.lastIndex,t=R.keywordPatternRe.exec(M)} n+=M.substr(e),k.addText(n)})(),M=""}function h(e){ return e.className&&k.openNode(v.classNameAliases[e.className]||e.className), R=Object.create(e,{parent:{value:R}}),R}function d(e,t,n){let s=((e,t)=>{ const n=e&&e.exec(t);return n&&0===n.index})(e.endRe,n);if(s){if(e["on:end"]){ const n=new i(e);e["on:end"](t,n),n.isMatchIgnored&&(s=!1)}if(s){ for(;e.endsParent&&e.parent;)e=e.parent;return e}} if(e.endsWithParent)return d(e.parent,t,n)}function m(e){ return 0===R.matcher.regexIndex?(M+=e[0],1):(I=!0,0)}function b(e){ const n=e[0],i=t.substr(e.index),s=d(R,e,i);if(!s)return V;const a=R ;a.skip?M+=n:(a.returnEnd||a.excludeEnd||(M+=n),g(),a.excludeEnd&&(M=n));do{ R.className&&k.closeNode(),R.skip||R.subLanguage||(O+=R.relevance),R=R.parent }while(R!==s.parent) ;return s.starts&&(s.endSameAsBegin&&(s.starts.endRe=s.endRe), h(s.starts)),a.returnEnd?0:n.length}let E={};function x(n,a){const l=a&&a[0] ;if(M+=n,null==l)return g(),0 ;if("begin"===E.type&&"end"===a.type&&E.index===a.index&&""===l){ if(M+=t.slice(a.index,a.index+1),!r){const t=Error("0 width match regex") ;throw t.languageName=e,t.badRule=E.rule,t}return 1} if(E=a,"begin"===a.type)return function(e){ const t=e[0],n=e.rule,s=new i(n),a=[n.__beforeBegin,n["on:begin"]] ;for(const n of a)if(n&&(n(e,s),s.isMatchIgnored))return m(t) ;return n&&n.endSameAsBegin&&(n.endRe=RegExp(t.replace(/[-/\\^$*+?.()|[\]{}]/g,"\\$&"),"m")), n.skip?M+=t:(n.excludeBegin&&(M+=t), g(),n.returnBegin||n.excludeBegin||(M=t)),h(n),n.returnBegin?0:t.length}(a) ;if("illegal"===a.type&&!s){ const e=Error('Illegal lexeme "'+l+'" for mode "'+(R.className||"<unnamed>")+'"') ;throw e.mode=R,e}if("end"===a.type){const e=b(a);if(e!==V)return e} if("illegal"===a.type&&""===l)return 1 ;if(L>1e5&&L>3*a.index)throw Error("potential infinite loop, way more iterations than matches") ;return M+=l,l.length}const v=N(e) ;if(!v)throw $(o.replace("{}",e)),Error('Unknown language: "'+e+'"') ;const w=T(v,{plugins:a});let y="",R=l||w;const _={},k=new u.__emitter(u);(()=>{ const e=[];for(let t=R;t!==v;t=t.parent)t.className&&e.unshift(t.className) ;e.forEach((e=>k.openNode(e)))})();let M="",O=0,A=0,L=0,I=!1;try{ for(R.matcher.considerAll();;){ L++,I?I=!1:R.matcher.considerAll(),R.matcher.lastIndex=A ;const e=R.matcher.exec(t);if(!e)break;const n=x(t.substring(A,e.index),e) ;A=e.index+n}return x(t.substr(A)),k.closeAllNodes(),k.finalize(),y=k.toHTML(),{ relevance:Math.floor(O),value:y,language:e,illegal:!1,emitter:k,top:R}}catch(n){ if(n.message&&n.message.includes("Illegal"))return{illegal:!0,illegalBy:{ msg:n.message,context:t.slice(A-100,A+100),mode:n.mode},sofar:y,relevance:0, value:K(t),emitter:k};if(r)return{illegal:!1,relevance:0,value:K(t),emitter:k, language:e,top:R,errorRaised:n};throw n}}function p(e,t){ t=t||u.languages||Object.keys(n);const i=(e=>{const t={relevance:0, emitter:new u.__emitter(u),value:K(e),illegal:!1,top:g} ;return t.emitter.addText(e),t})(e),s=t.filter(N).filter(k).map((t=>f(t,e,!1))) ;s.unshift(i);const a=s.sort(((e,t)=>{ if(e.relevance!==t.relevance)return t.relevance-e.relevance ;if(e.language&&t.language){if(N(e.language).supersetOf===t.language)return 1 ;if(N(t.language).supersetOf===e.language)return-1}return 0})),[r,l]=a,o=r ;return o.second_best=l,o}const m={"before:highlightElement":({el:e})=>{ u.useBR&&(e.innerHTML=e.innerHTML.replace(/\n/g,"").replace(/<br[ /]*>/g,"\n")) },"after:highlightElement":({result:e})=>{ u.useBR&&(e.value=e.value.replace(/\n/g,"<br>"))}},b=/^(<[^>]+>|\t)+/gm,E={ "after:highlightElement":({result:e})=>{ u.tabReplace&&(e.value=e.value.replace(b,(e=>e.replace(/\t/g,u.tabReplace))))}} ;function x(e){let t=null;const n=(e=>{let t=e.className+" " ;t+=e.parentNode?e.parentNode.className:"";const n=u.languageDetectRe.exec(t) ;if(n){const t=N(n[1]) ;return t||(U(o.replace("{}",n[1])),U("Falling back to no-highlight mode for this block.",e)), t?n[1]:"no-highlight"}return t.split(/\s+/).find((e=>h(e)||N(e)))})(e) ;if(h(n))return;M("before:highlightElement",{el:e,language:n}),t=e ;const i=t.textContent,a=n?d(i,{language:n,ignoreIllegals:!0}):p(i) ;M("after:highlightElement",{el:e,result:a,text:i }),e.innerHTML=a.value,((e,t,n)=>{const i=t?s[t]:n ;e.classList.add("hljs"),i&&e.classList.add(i)})(e,n,a.language),e.result={ language:a.language,re:a.relevance,relavance:a.relevance },a.second_best&&(e.second_best={language:a.second_best.language, re:a.second_best.relevance,relavance:a.second_best.relevance})}const v=()=>{ v.called||(v.called=!0, z("10.6.0","initHighlighting() is deprecated. Use highlightAll() instead."), document.querySelectorAll("pre code").forEach(x))};let w=!1;function y(){ "loading"!==document.readyState?document.querySelectorAll("pre code").forEach(x):w=!0 }function N(e){return e=(e||"").toLowerCase(),n[e]||n[s[e]]} function R(e,{languageName:t}){"string"==typeof e&&(e=[e]),e.forEach((e=>{ s[e.toLowerCase()]=t}))}function k(e){const t=N(e) ;return t&&!t.disableAutodetect}function M(e,t){const n=e;a.forEach((e=>{ e[n]&&e[n](t)}))} "undefined"!=typeof window&&window.addEventListener&&window.addEventListener("DOMContentLoaded",(()=>{ w&&y()}),!1),Object.assign(e,{highlight:d,highlightAuto:p,highlightAll:y, fixMarkup:e=>{ return 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"), t=e, u.tabReplace||u.useBR?t.replace(l,(e=>"\n"===e?u.useBR?"<br>":e:u.tabReplace?e.replace(/\t/g,u.tabReplace):e)):t ;var t},highlightElement:x, highlightBlock:e=>(z("10.7.0","highlightBlock will be removed entirely in v12.0"), z("10.7.0","Please use highlightElement now."),x(e)),configure:e=>{ e.useBR&&(z("10.3.0","'useBR' will be removed entirely in v11.0"), z("10.3.0","Please see https://github.com/highlightjs/highlight.js/issues/2559")), u=G(u,e)},initHighlighting:v,initHighlightingOnLoad:()=>{ z("10.6.0","initHighlightingOnLoad() is deprecated. Use highlightAll() instead."), w=!0},registerLanguage:(t,i)=>{let s=null;try{s=i(e)}catch(e){ if($("Language definition for '{}' could not be registered.".replace("{}",t)), !r)throw e;$(e),s=g} s.name||(s.name=t),n[t]=s,s.rawDefinition=i.bind(null,e),s.aliases&&R(s.aliases,{ languageName:t})},unregisterLanguage:e=>{delete n[e] ;for(const t of Object.keys(s))s[t]===e&&delete s[t]}, listLanguages:()=>Object.keys(n),getLanguage:N,registerAliases:R, requireLanguage:e=>{ z("10.4.0","requireLanguage will be removed entirely in v11."), z("10.4.0","Please see https://github.com/highlightjs/highlight.js/pull/2844") ;const t=N(e);if(t)return t ;throw Error("The '{}' language is required, but not loaded.".replace("{}",e))}, autoDetection:k,inherit:G,addPlugin:e=>{(e=>{ e["before:highlightBlock"]&&!e["before:highlightElement"]&&(e["before:highlightElement"]=t=>{ e["before:highlightBlock"](Object.assign({block:t.el},t)) }),e["after:highlightBlock"]&&!e["after:highlightElement"]&&(e["after:highlightElement"]=t=>{ e["after:highlightBlock"](Object.assign({block:t.el},t))})})(e),a.push(e)}, vuePlugin:P(e).VuePlugin}),e.debugMode=()=>{r=!1},e.safeMode=()=>{r=!0 },e.versionString="10.7.1";for(const e in _)"object"==typeof _[e]&&t(_[e]) ;return Object.assign(e,_),e.addPlugin(m),e.addPlugin(D),e.addPlugin(E),e})({}) }();"object"==typeof exports&&"undefined"!=typeof module&&(module.exports=hljs);hljs.registerLanguage("java",(()=>{"use strict" ;var e="\\.([0-9](_*[0-9])*)",n="[0-9a-fA-F](_*[0-9a-fA-F])*",a={ className:"number",variants:[{ begin:`(\\b([0-9](_*[0-9])*)((${e})|\\.)?|(${e}))[eE][+-]?([0-9](_*[0-9])*)[fFdD]?\\b` },{begin:`\\b([0-9](_*[0-9])*)((${e})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ begin:`(${e})[fFdD]?\\b`},{begin:"\\b([0-9](_*[0-9])*)[fFdD]\\b"},{ begin:`\\b0[xX]((${n})\\.?|(${n})?\\.(${n}))[pP][+-]?([0-9](_*[0-9])*)[fFdD]?\\b` },{begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${n})[lL]?\\b`},{ begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], relevance:0};return e=>{ var 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={ className:"meta",begin:"@[\xc0-\u02b8a-zA-Z_$][\xc0-\u02b8a-zA-Z_$0-9]*", contains:[{begin:/\(/,end:/\)/,contains:["self"]}]};const r=a;return{ name:"Java",aliases:["jsp"],keywords:n,illegal:/<\/|#/, contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{begin:/\w+@/, relevance:0},{className:"doctag",begin:"@[A-Za-z]+"}]}),{ begin:/import java\.[a-z]+\./,keywords:"import",relevance:2 },e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{ className:"class",beginKeywords:"class interface enum",end:/[{;=]/, excludeEnd:!0,relevance:1,keywords:"class interface enum",illegal:/[:"\[\]]/, contains:[{beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ beginKeywords:"new throw return else",relevance:0},{className:"class", begin:"record\\s+"+e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,excludeEnd:!0, end:/[{;=]/,keywords:n,contains:[{beginKeywords:"record"},{ begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, contains:[e.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/, keywords:n,relevance:0,contains:[e.C_BLOCK_COMMENT_MODE] },e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"function", begin:"([\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*\\(", returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:n,contains:[{ begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, contains:[e.UNDERSCORE_TITLE_MODE]},{className:"params",begin:/\(/,end:/\)/, keywords:n,relevance:0, contains:[s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,r,e.C_BLOCK_COMMENT_MODE] },e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},r,s]}}})());hljs.registerLanguage("plaintext",(()=>{"use strict";return t=>({ name:"Plain text",aliases:["text","txt"],disableAutodetect:!0})})());hljs.registerLanguage("bash",(()=>{"use strict";function e(...e){ return e.map((e=>{return(s=e)?"string"==typeof s?s:s.source:null;var s })).join("")}return s=>{const n={},t={begin:/\$\{/,end:/\}/,contains:["self",{ begin:/:-/,contains:[n]}]};Object.assign(n,{className:"variable",variants:[{ begin:e(/\$[\w\d#@][\w\d_]*/,"(?![\\w\\d])(?![$])")},t]});const a={ className:"subst",begin:/\$\(/,end:/\)/,contains:[s.BACKSLASH_ESCAPE]},i={ begin:/<<-?\s*(?=\w+)/,starts:{contains:[s.END_SAME_AS_BEGIN({begin:/(\w+)/, end:/(\w+)/,className:"string"})]}},c={className:"string",begin:/"/,end:/"/, contains:[s.BACKSLASH_ESCAPE,n,a]};a.contains.push(c);const o={begin:/\$\(\(/, end:/\)\)/,contains:[{begin:/\d+#[0-9a-f]+/,className:"number"},s.NUMBER_MODE,n] },r=s.SHEBANG({binary:"(fish|bash|zsh|sh|csh|ksh|tcsh|dash|scsh)",relevance:10 }),l={className:"function",begin:/\w[\w\d_]*\s*\(\s*\)\s*\{/,returnBegin:!0, contains:[s.inherit(s.TITLE_MODE,{begin:/\w[\w\d_]*/})],relevance:0};return{ name:"Bash",aliases:["sh","zsh"],keywords:{$pattern:/\b[a-z._-]+\b/, keyword:"if then else elif fi for while in do done case esac function", literal:"true false", built_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" },contains:[r,s.SHEBANG(),l,o,s.HASH_COMMENT_MODE,i,c,{className:"",begin:/\\"/ },{className:"string",begin:/'/,end:/'/},n]}}})());hljs.registerLanguage("shell",(()=>{"use strict";return s=>({ name:"Shell Session",aliases:["console"],contains:[{className:"meta", begin:/^\s{0,3}[/~\w\d[\]()@-]*[>%$#]/,starts:{end:/[^\\](?=\s*$)/, subLanguage:"bash"}}]})})());hljs.registerLanguage("properties",(()=>{"use strict";return e=>{ var n="[ \\t\\f]*",a=n+"[:=]"+n,t="("+a+"|[ \\t\\f]+)",r="([^\\\\\\W:= \\t\\f\\n]|\\\\.)+",s="([^\\\\:= \\t\\f\\n]|\\\\.)+",i={ end:t,relevance:0,starts:{className:"string",end:/$/,relevance:0,contains:[{ begin:"\\\\\\\\"},{begin:"\\\\\\n"}]}};return{name:".properties", case_insensitive:!0,illegal:/\S/,contains:[e.COMMENT("^\\s*[!#]","$"),{ returnBegin:!0,variants:[{begin:r+a,relevance:1},{begin:r+"[ \\t\\f]+", relevance:0}],contains:[{className:"attr",begin:r,endsParent:!0,relevance:0}], starts:i},{begin:s+t,returnBegin:!0,relevance:0,contains:[{className:"meta", begin:s,endsParent:!0,relevance:0}],starts:i},{className:"attr",relevance:0, begin:s+n+"$"}]}}})());hljs.registerLanguage("sql",(()=>{"use strict";function e(e){ return e?"string"==typeof e?e:e.source:null}function r(...r){ return r.map((r=>e(r))).join("")}function t(...r){ return"("+r.map((r=>e(r))).join("|")+")"}return e=>{ const 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={ begin:r(/\b/,t(...c),/\s*\(/),keywords:{built_in:c}};return{name:"SQL", case_insensitive:!0,illegal:/[{}]|<\//,keywords:{$pattern:/\b[\w\.]+/, keyword:((e,{exceptions:r,when:t}={})=>{const n=t ;return r=r||[],e.map((e=>e.match(/\|\d+$/)||r.includes(e)?e:n(e)?e+"|0":e)) })(l,{when:e=>e.length<3}),literal:a,type:i, built_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"] },contains:[{begin:t(...o),keywords:{$pattern:/[\w\.]+/,keyword:l.concat(o), literal:a,type:i}},{className:"type", begin:t("double precision","large object","with timezone","without timezone") },u,{className:"variable",begin:/@[a-z0-9]+/},{className:"string",variants:[{ begin:/'/,end:/'/,contains:[{begin:/''/}]}]},{begin:/"/,end:/"/,contains:[{ begin:/""/}]},e.C_NUMBER_MODE,e.C_BLOCK_COMMENT_MODE,n,{className:"operator", begin:/[-+*/=%^~]|&&?|\|\|?|!=?|<(?:=>?|<|>)?|>[>=]?/,relevance:0}]}}})());hljs.registerLanguage("perl",(()=>{"use strict";function e(e){ return e?"string"==typeof e?e:e.source:null}function n(...n){ return n.map((n=>e(n))).join("")}function t(...n){ return"("+n.map((n=>e(n))).join("|")+")"}return e=>{ const r=/[dualxmsipngr]{0,12}/,s={$pattern:/[\w.]+/, keyword:"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" },i={className:"subst",begin:"[$@]\\{",end:"\\}",keywords:s},a={begin:/->\{/, end:/\}/},o={variants:[{begin:/\$\d/},{ begin:n(/[$%@](\^\w\b|#\w+(::\w+)*|\{\w+\}|\w+(::\w*)*)/,"(?![A-Za-z])(?![@$%])") },{begin:/[$%@][^\s\w{]/,relevance:0}] },c=[e.BACKSLASH_ESCAPE,i,o],g=[/!/,/\//,/\|/,/\?/,/'/,/"/,/#/],l=(e,t,s="\\1")=>{ const i="\\1"===s?s:n(s,t) ;return n(n("(?:",e,")"),t,/(?:\\.|[^\\\/])*?/,i,/(?:\\.|[^\\\/])*?/,s,r) },d=(e,t,s)=>n(n("(?:",e,")"),t,/(?:\\.|[^\\\/])*?/,s,r),p=[o,e.HASH_COMMENT_MODE,e.COMMENT(/^=\w/,/=cut/,{ endsWithParent:!0}),a,{className:"string",contains:c,variants:[{ begin:"q[qwxr]?\\s*\\(",end:"\\)",relevance:5},{begin:"q[qwxr]?\\s*\\[", end:"\\]",relevance:5},{begin:"q[qwxr]?\\s*\\{",end:"\\}",relevance:5},{ begin:"q[qwxr]?\\s*\\|",end:"\\|",relevance:5},{begin:"q[qwxr]?\\s*<",end:">", relevance:5},{begin:"qw\\s+q",end:"q",relevance:5},{begin:"'",end:"'", contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"'},{begin:"`",end:"`", contains:[e.BACKSLASH_ESCAPE]},{begin:/\{\w+\}/,relevance:0},{ begin:"-?\\w+\\s*=>",relevance:0}]},{className:"number", begin:"(\\b0[0-7_]+)|(\\b0x[0-9a-fA-F_]+)|(\\b[1-9][0-9_]*(\\.[0-9_]+)?)|[0_]\\b", relevance:0},{ begin:"(\\/\\/|"+e.RE_STARTERS_RE+"|\\b(split|return|print|reverse|grep)\\b)\\s*", keywords:"split return print reverse grep",relevance:0, contains:[e.HASH_COMMENT_MODE,{className:"regexp",variants:[{ begin:l("s|tr|y",t(...g))},{begin:l("s|tr|y","\\(","\\)")},{ begin:l("s|tr|y","\\[","\\]")},{begin:l("s|tr|y","\\{","\\}")}],relevance:2},{ className:"regexp",variants:[{begin:/(m|qr)\/\//,relevance:0},{ begin:d("(?:m|qr)?",/\//,/\//)},{begin:d("m|qr",t(...g),/\1/)},{ begin:d("m|qr",/\(/,/\)/)},{begin:d("m|qr",/\[/,/\]/)},{ begin:d("m|qr",/\{/,/\}/)}]}]},{className:"function",beginKeywords:"sub", end:"(\\s*\\(.*?\\))?[;{]",excludeEnd:!0,relevance:5,contains:[e.TITLE_MODE]},{ begin:"-\\w\\b",relevance:0},{begin:"^__DATA__$",end:"^__END__$", subLanguage:"mojolicious",contains:[{begin:"^@@.*",end:"$",className:"comment"}] }];return i.contains=p,a.contains=p,{name:"Perl",aliases:["pl","pm"],keywords:s, contains:p}}})());hljs.registerLanguage("csharp",(()=>{"use strict";return e=>{const n={ keyword:["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"]), built_in:["bool","byte","char","decimal","delegate","double","dynamic","enum","float","int","long","nint","nuint","object","sbyte","short","string","ulong","uint","ushort"], literal:["default","false","null","true"]},a=e.inherit(e.TITLE_MODE,{ begin:"[a-zA-Z](\\.?\\w)*"}),i={className:"number",variants:[{ begin:"\\b(0b[01']+)"},{ begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)(u|U|l|L|ul|UL|f|F|b|B)"},{ begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" }],relevance:0},s={className:"string",begin:'@"',end:'"',contains:[{begin:'""'}] },t=e.inherit(s,{illegal:/\n/}),r={className:"subst",begin:/\{/,end:/\}/, keywords:n},l=e.inherit(r,{illegal:/\n/}),c={className:"string",begin:/\$"/, end:'"',illegal:/\n/,contains:[{begin:/\{\{/},{begin:/\}\}/ },e.BACKSLASH_ESCAPE,l]},o={className:"string",begin:/\$@"/,end:'"',contains:[{ begin:/\{\{/},{begin:/\}\}/},{begin:'""'},r]},d=e.inherit(o,{illegal:/\n/, contains:[{begin:/\{\{/},{begin:/\}\}/},{begin:'""'},l]}) ;r.contains=[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.C_BLOCK_COMMENT_MODE], l.contains=[d,c,t,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,i,e.inherit(e.C_BLOCK_COMMENT_MODE,{ illegal:/\n/})];const g={variants:[o,c,s,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] },E={begin:"<",end:">",contains:[{beginKeywords:"in out"},a] },_=e.IDENT_RE+"(<"+e.IDENT_RE+"(\\s*,\\s*"+e.IDENT_RE+")*>)?(\\[\\])?",b={ begin:"@"+e.IDENT_RE,relevance:0};return{name:"C#",aliases:["cs","c#"], keywords:n,illegal:/::/,contains:[e.COMMENT("///","$",{returnBegin:!0, contains:[{className:"doctag",variants:[{begin:"///",relevance:0},{ begin:"\x3c!--|--\x3e"},{begin:"</?",end:">"}]}] }),e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"meta",begin:"#", end:"$",keywords:{ "meta-keyword":"if else elif endif define undef warning error line region endregion pragma checksum" }},g,i,{beginKeywords:"class interface",relevance:0,end:/[{;=]/, illegal:/[^\s:,]/,contains:[{beginKeywords:"where class" },a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{beginKeywords:"namespace", relevance:0,end:/[{;=]/,illegal:/[^\s:]/, contains:[a,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ beginKeywords:"record",relevance:0,end:/[{;=]/,illegal:/[^\s:]/, contains:[a,E,e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{className:"meta", begin:"^\\s*\\[",excludeBegin:!0,end:"\\]",excludeEnd:!0,contains:[{ className:"meta-string",begin:/"/,end:/"/}]},{ beginKeywords:"new return throw await else",relevance:0},{className:"function", begin:"("+_+"\\s+)+"+e.IDENT_RE+"\\s*(<.+>\\s*)?\\(",returnBegin:!0, end:/\s*[{;=]/,excludeEnd:!0,keywords:n,contains:[{ beginKeywords:"public private protected static internal protected abstract async extern override unsafe virtual new sealed partial", relevance:0},{begin:e.IDENT_RE+"\\s*(<.+>\\s*)?\\(",returnBegin:!0, contains:[e.TITLE_MODE,E],relevance:0},{className:"params",begin:/\(/,end:/\)/, excludeBegin:!0,excludeEnd:!0,keywords:n,relevance:0, contains:[g,i,e.C_BLOCK_COMMENT_MODE] },e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},b]}}})());hljs.registerLanguage("nginx",(()=>{"use strict";return e=>{const n={ className:"variable",variants:[{begin:/\$\d+/},{begin:/\$\{/,end:/\}/},{ begin:/[$@]/+e.UNDERSCORE_IDENT_RE}]},a={endsWithParent:!0,keywords:{ $pattern:"[a-z/_]+", literal:"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" },relevance:0,illegal:"=>",contains:[e.HASH_COMMENT_MODE,{className:"string", contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:/"/,end:/"/},{begin:/'/,end:/'/ }]},{begin:"([a-z]+):/",end:"\\s",endsWithParent:!0,excludeEnd:!0,contains:[n] },{className:"regexp",contains:[e.BACKSLASH_ESCAPE,n],variants:[{begin:"\\s\\^", end:"\\s|\\{|;",returnEnd:!0},{begin:"~\\*?\\s+",end:"\\s|\\{|;",returnEnd:!0},{ begin:"\\*(\\.[a-z\\-]+)+"},{begin:"([a-z\\-]+\\.)+\\*"}]},{className:"number", begin:"\\b\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}(:\\d{1,5})?\\b"},{ className:"number",begin:"\\b\\d+[kKmMgGdshdwy]*\\b",relevance:0},n]};return{ name:"Nginx config",aliases:["nginxconf"],contains:[e.HASH_COMMENT_MODE,{ begin:e.UNDERSCORE_IDENT_RE+"\\s+\\{",returnBegin:!0,end:/\{/,contains:[{ className:"section",begin:e.UNDERSCORE_IDENT_RE}],relevance:0},{ begin:e.UNDERSCORE_IDENT_RE+"\\s",end:";|\\{",returnBegin:!0,contains:[{ className:"attribute",begin:e.UNDERSCORE_IDENT_RE,starts:a}],relevance:0}], illegal:"[^\\s\\}]"}}})());hljs.registerLanguage("ruby",(()=>{"use strict";function e(...e){ return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n })).join("")}return n=>{ const a="([a-zA-Z_]\\w*[!?=]?|[-+~]@|<<|>>|=~|===?|<=>|[<>]=?|\\*\\*|[-/+%^&*~`|]|\\[\\]=?)",i={ keyword:"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__", built_in:"proc lambda",literal:"true false nil"},s={className:"doctag", begin:"@[A-Za-z]+"},r={begin:"#<",end:">"},b=[n.COMMENT("#","$",{contains:[s] }),n.COMMENT("^=begin","^=end",{contains:[s],relevance:10 }),n.COMMENT("^__END__","\\n$")],c={className:"subst",begin:/#\{/,end:/\}/, keywords:i},t={className:"string",contains:[n.BACKSLASH_ESCAPE,c],variants:[{ begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/`/,end:/`/},{begin:/%[qQwWx]?\(/, end:/\)/},{begin:/%[qQwWx]?\[/,end:/\]/},{begin:/%[qQwWx]?\{/,end:/\}/},{ begin:/%[qQwWx]?</,end:/>/},{begin:/%[qQwWx]?\//,end:/\//},{begin:/%[qQwWx]?%/, end:/%/},{begin:/%[qQwWx]?-/,end:/-/},{begin:/%[qQwWx]?\|/,end:/\|/},{ begin:/\B\?(\\\d{1,3})/},{begin:/\B\?(\\x[A-Fa-f0-9]{1,2})/},{ begin:/\B\?(\\u\{?[A-Fa-f0-9]{1,6}\}?)/},{ begin:/\B\?(\\M-\\C-|\\M-\\c|\\c\\M-|\\M-|\\C-\\M-)[\x20-\x7e]/},{ begin:/\B\?\\(c|C-)[\x20-\x7e]/},{begin:/\B\?\\?\S/},{ begin:/<<[-~]?'?(\w+)\n(?:[^\n]*\n)*?\s*\1\b/,returnBegin:!0,contains:[{ begin:/<<[-~]?'?/},n.END_SAME_AS_BEGIN({begin:/(\w+)/,end:/(\w+)/, contains:[n.BACKSLASH_ESCAPE,c]})]}]},g="[0-9](_?[0-9])*",d={className:"number", relevance:0,variants:[{ begin:`\\b([1-9](_?[0-9])*|0)(\\.(${g}))?([eE][+-]?(${g})|r)?i?\\b`},{ begin:"\\b0[dD][0-9](_?[0-9])*r?i?\\b"},{begin:"\\b0[bB][0-1](_?[0-1])*r?i?\\b" },{begin:"\\b0[oO][0-7](_?[0-7])*r?i?\\b"},{ begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*r?i?\\b"},{ begin:"\\b0(_?[0-7])+r?i?\\b"}]},l={className:"params",begin:"\\(",end:"\\)", endsParent:!0,keywords:i},o=[t,{className:"class",beginKeywords:"class module", end:"$|;",illegal:/=/,contains:[n.inherit(n.TITLE_MODE,{ begin:"[A-Za-z_]\\w*(::\\w+)*(\\?|!)?"}),{begin:"<\\s*",contains:[{ begin:"("+n.IDENT_RE+"::)?"+n.IDENT_RE,relevance:0}]}].concat(b)},{ className:"function",begin:e(/def\s+/,(_=a+"\\s*(\\(|;|$)",e("(?=",_,")"))), relevance:0,keywords:"def",end:"$|;",contains:[n.inherit(n.TITLE_MODE,{begin:a }),l].concat(b)},{begin:n.IDENT_RE+"::"},{className:"symbol", begin:n.UNDERSCORE_IDENT_RE+"(!|\\?)?:",relevance:0},{className:"symbol", begin:":(?!\\s)",contains:[t,{begin:a}],relevance:0},d,{className:"variable", begin:"(\\$\\W)|((\\$|@@?)(\\w+))(?=[^@$?])(?![A-Za-z])(?![@$?'])"},{ className:"params",begin:/\|/,end:/\|/,relevance:0,keywords:i},{ begin:"("+n.RE_STARTERS_RE+"|unless)\\s*",keywords:"unless",contains:[{ className:"regexp",contains:[n.BACKSLASH_ESCAPE,c],illegal:/\n/,variants:[{ begin:"/",end:"/[a-z]*"},{begin:/%r\{/,end:/\}[a-z]*/},{begin:"%r\\(", end:"\\)[a-z]*"},{begin:"%r!",end:"![a-z]*"},{begin:"%r\\[",end:"\\][a-z]*"}] }].concat(r,b),relevance:0}].concat(r,b);var _;c.contains=o,l.contains=o ;const E=[{begin:/^\s*=>/,starts:{end:"$",contains:o}},{className:"meta", begin:"^([>?]>|[\\w#]+\\(\\w+\\):\\d+:\\d+>|(\\w+-)?\\d+\\.\\d+\\.\\d+(p\\d+)?[^\\d][^>]+>)(?=[ ])", starts:{end:"$",contains:o}}];return b.unshift(r),{name:"Ruby", aliases:["rb","gemspec","podspec","thor","irb"],keywords:i,illegal:/\/\*/, contains:[n.SHEBANG({binary:"ruby"})].concat(E).concat(b).concat(o)}}})());hljs.registerLanguage("swift",(()=>{"use strict";function e(e){ return e?"string"==typeof e?e:e.source:null}function n(e){return a("(?=",e,")")} function a(...n){return n.map((n=>e(n))).join("")}function t(...n){ return"("+n.map((n=>e(n))).join("|")+")"} const 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"] ;return e=>{const p={match:/\s+/,relevance:0},h=e.COMMENT("/\\*","\\*/",{ contains:["self"]}),v=[e.C_LINE_COMMENT_MODE,h],N={className:"keyword", begin:a(/\./,n(t(...s,...u))),end:t(...s,...u),excludeBegin:!0},A={ match:a(/\./,t(...r)),relevance:0 },C=r.filter((e=>"string"==typeof e)).concat(["_|0"]),_={variants:[{ className:"keyword", match:t(...r.filter((e=>"string"!=typeof e)).concat(c).map(i),...u)}]},D={ $pattern:t(/\b\w+/,/#\w+/),keyword:C.concat(m),literal:o},B=[N,A,_],k=[{ match:a(/\./,t(...d)),relevance:0},{className:"built_in", match:a(/\b/,t(...d),/(?=\()/)}],M={match:/->/,relevance:0},S=[M,{ className:"operator",relevance:0,variants:[{match:b},{match:`\\.(\\.|${F})+`}] }],x="([0-9a-fA-F]_*)+",I={className:"number",relevance:0,variants:[{ match:"\\b(([0-9]_*)+)(\\.(([0-9]_*)+))?([eE][+-]?(([0-9]_*)+))?\\b"},{ match:`\\b0x(${x})(\\.(${x}))?([pP][+-]?(([0-9]_*)+))?\\b`},{ match:/\b0o([0-7]_*)+\b/},{match:/\b0b([01]_*)+\b/}]},O=(e="")=>({ className:"subst",variants:[{match:a(/\\/,e,/[0\\tnr"']/)},{ match:a(/\\/,e,/u\{[0-9a-fA-F]{1,8}\}/)}]}),T=(e="")=>({className:"subst", match:a(/\\/,e,/[\t ]*(?:[\r\n]|\r\n)/)}),L=(e="")=>({className:"subst", label:"interpol",begin:a(/\\/,e,/\(/),end:/\)/}),P=(e="")=>({begin:a(e,/"""/), end:a(/"""/,e),contains:[O(e),T(e),L(e)]}),$=(e="")=>({begin:a(e,/"/), end:a(/"/,e),contains:[O(e),L(e)]}),K={className:"string", variants:[P(),P("#"),P("##"),P("###"),$(),$("#"),$("##"),$("###")]},j={ match:a(/`/,w,/`/)},z=[j,{className:"variable",match:/\$\d+/},{ className:"variable",match:`\\$${f}+`}],q=[{match:/(@|#)available/, className:"keyword",starts:{contains:[{begin:/\(/,end:/\)/,keywords:E, contains:[...S,I,K]}]}},{className:"keyword",match:a(/@/,t(...g))},{ className:"meta",match:a(/@/,w)}],U={match:n(/\b[A-Z]/),relevance:0,contains:[{ className:"type", match:a(/(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)/,f,"+") },{className:"type",match:y,relevance:0},{match:/[?!]+/,relevance:0},{ match:/\.\.\./,relevance:0},{match:a(/\s+&\s+/,n(y)),relevance:0}]},Z={ begin:/</,end:/>/,keywords:D,contains:[...v,...B,...q,M,U]};U.contains.push(Z) ;const G={begin:/\(/,end:/\)/,relevance:0,keywords:D,contains:["self",{ match:a(w,/\s*:/),keywords:"_|0",relevance:0 },...v,...B,...k,...S,I,K,...z,...q,U]},H={beginKeywords:"func",contains:[{ className:"title",match:t(j.match,w,b),endsParent:!0,relevance:0},p]},R={ begin:/</,end:/>/,contains:[...v,U]},V={begin:/\(/,end:/\)/,keywords:D, contains:[{begin:t(n(a(w,/\s*:/)),n(a(w,/\s+/,w,/\s*:/))),end:/:/,relevance:0, contains:[{className:"keyword",match:/\b_\b/},{className:"params",match:w}] },...v,...B,...S,I,K,...q,U,G],endsParent:!0,illegal:/["']/},W={ className:"function",match:n(/\bfunc\b/),contains:[H,R,V,p],illegal:[/\[/,/%/] },X={className:"function",match:/\b(subscript|init[?!]?)\s*(?=[<(])/,keywords:{ keyword:"subscript init init? init!",$pattern:/\w+[?!]?/},contains:[R,V,p], illegal:/\[|%/},J={beginKeywords:"operator",end:e.MATCH_NOTHING_RE,contains:[{ className:"title",match:b,endsParent:!0,relevance:0}]},Q={ beginKeywords:"precedencegroup",end:e.MATCH_NOTHING_RE,contains:[{ className:"title",match:y,relevance:0},{begin:/{/,end:/}/,relevance:0, endsParent:!0,keywords:[...l,...o],contains:[U]}]};for(const e of K.variants){ const n=e.contains.find((e=>"interpol"===e.label));n.keywords=D ;const a=[...B,...k,...S,I,K,...z];n.contains=[...a,{begin:/\(/,end:/\)/, contains:["self",...a]}]}return{name:"Swift",keywords:D,contains:[...v,W,X,{ className:"class",beginKeywords:"struct protocol class extension enum", end:"\\{",excludeEnd:!0,keywords:D,contains:[e.inherit(e.TITLE_MODE,{ begin:/[A-Za-z$_][\u00C0-\u02B80-9A-Za-z$_]*/}),...B]},J,Q,{ beginKeywords:"import",end:/$/,contains:[...v],relevance:0 },...B,...k,...S,I,K,...z,...q,U,G]}}})());hljs.registerLanguage("ini",(()=>{"use strict";function e(e){ return e?"string"==typeof e?e:e.source:null}function n(...n){ return n.map((n=>e(n))).join("")}return s=>{const a={className:"number", relevance:0,variants:[{begin:/([+-]+)?[\d]+_[\d_]+/},{begin:s.NUMBER_RE}] },i=s.COMMENT();i.variants=[{begin:/;/,end:/$/},{begin:/#/,end:/$/}];const t={ className:"variable",variants:[{begin:/\$[\w\d"][\w\d_]*/},{begin:/\$\{(.*?)\}/ }]},r={className:"literal",begin:/\bon|off|true|false|yes|no\b/},l={ className:"string",contains:[s.BACKSLASH_ESCAPE],variants:[{begin:"'''", end:"'''",relevance:10},{begin:'"""',end:'"""',relevance:10},{begin:'"',end:'"' },{begin:"'",end:"'"}]},c={begin:/\[/,end:/\]/,contains:[i,r,t,l,a,"self"], relevance:0 },g="("+[/[A-Za-z0-9_-]+/,/"(\\"|[^"])*"/,/'[^']*'/].map((n=>e(n))).join("|")+")" ;return{name:"TOML, also INI",aliases:["toml"],case_insensitive:!0,illegal:/\S/, contains:[i,{className:"section",begin:/\[+/,end:/\]+/},{ begin:n(g,"(\\s*\\.\\s*",g,")*",n("(?=",/\s*=\s*[^#\s]/,")")),className:"attr", starts:{end:/$/,contains:[i,c,r,t,l,a]}}]}}})());hljs.registerLanguage("coffeescript",(()=>{"use strict" ;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"]) ;return r=>{const t={ keyword:e.concat(["then","unless","until","loop","by","when","and","or","is","isnt","not"]).filter((i=["var","const","let","function","static"], e=>!i.includes(e))),literal:n.concat(["yes","no","on","off"]), built_in:a.concat(["npm","print"])};var i;const s="[A-Za-z$_][0-9A-Za-z$_]*",o={ className:"subst",begin:/#\{/,end:/\}/,keywords:t },c=[r.BINARY_NUMBER_MODE,r.inherit(r.C_NUMBER_MODE,{starts:{end:"(\\s*/)?", relevance:0}}),{className:"string",variants:[{begin:/'''/,end:/'''/, contains:[r.BACKSLASH_ESCAPE]},{begin:/'/,end:/'/,contains:[r.BACKSLASH_ESCAPE] },{begin:/"""/,end:/"""/,contains:[r.BACKSLASH_ESCAPE,o]},{begin:/"/,end:/"/, contains:[r.BACKSLASH_ESCAPE,o]}]},{className:"regexp",variants:[{begin:"///", end:"///",contains:[o,r.HASH_COMMENT_MODE]},{begin:"//[gim]{0,3}(?=\\W)", relevance:0},{begin:/\/(?![ *]).*?(?![\\]).\/[gim]{0,3}(?=\W)/}]},{begin:"@"+s },{subLanguage:"javascript",excludeBegin:!0,excludeEnd:!0,variants:[{ begin:"```",end:"```"},{begin:"`",end:"`"}]}];o.contains=c ;const l=r.inherit(r.TITLE_MODE,{begin:s}),d="(\\(.*\\)\\s*)?\\B[-=]>",g={ className:"params",begin:"\\([^\\(]",returnBegin:!0,contains:[{begin:/\(/, end:/\)/,keywords:t,contains:["self"].concat(c)}]};return{name:"CoffeeScript", aliases:["coffee","cson","iced"],keywords:t,illegal:/\/\*/, contains:c.concat([r.COMMENT("###","###"),r.HASH_COMMENT_MODE,{ className:"function",begin:"^\\s*"+s+"\\s*=\\s*"+d,end:"[-=]>",returnBegin:!0, contains:[l,g]},{begin:/[:\(,=]\s*/,relevance:0,contains:[{className:"function", begin:d,end:"[-=]>",returnBegin:!0,contains:[g]}]},{className:"class", beginKeywords:"class",end:"$",illegal:/[:="\[\]]/,contains:[{ beginKeywords:"extends",endsWithParent:!0,illegal:/[:="\[\]]/,contains:[l]},l] },{begin:s+":",end:":",returnBegin:!0,returnEnd:!0,relevance:0}])}}})());hljs.registerLanguage("xml",(()=>{"use strict";function e(e){ return e?"string"==typeof e?e:e.source:null}function n(e){return a("(?=",e,")")} function a(...n){return n.map((n=>e(n))).join("")}function s(...n){ return"("+n.map((n=>e(n))).join("|")+")"}return e=>{ const t=a(/[A-Z_]/,a("(",/[A-Z0-9_.-]*:/,")?"),/[A-Z0-9_.-]*/),i={ className:"symbol",begin:/&[a-z]+;|&#[0-9]+;|&#x[a-f0-9]+;/},r={begin:/\s/, contains:[{className:"meta-keyword",begin:/#?[a-z_][a-z1-9_-]+/,illegal:/\n/}] },c=e.inherit(r,{begin:/\(/,end:/\)/}),l=e.inherit(e.APOS_STRING_MODE,{ className:"meta-string"}),g=e.inherit(e.QUOTE_STRING_MODE,{ className:"meta-string"}),m={endsWithParent:!0,illegal:/</,relevance:0, contains:[{className:"attr",begin:/[A-Za-z0-9._:-]+/,relevance:0},{begin:/=\s*/, relevance:0,contains:[{className:"string",endsParent:!0,variants:[{begin:/"/, end:/"/,contains:[i]},{begin:/'/,end:/'/,contains:[i]},{begin:/[^\s"'=<>`]+/}]}] }]};return{name:"HTML, XML", aliases:["html","xhtml","rss","atom","xjb","xsd","xsl","plist","wsf","svg"], case_insensitive:!0,contains:[{className:"meta",begin:/<![a-z]/,end:/>/, relevance:10,contains:[r,g,l,c,{begin:/\[/,end:/\]/,contains:[{className:"meta", begin:/<![a-z]/,end:/>/,contains:[r,c,g,l]}]}]},e.COMMENT(/<!--/,/-->/,{ relevance:10}),{begin:/<!\[CDATA\[/,end:/\]\]>/,relevance:10},i,{ className:"meta",begin:/<\?xml/,end:/\?>/,relevance:10},{className:"tag", begin:/<style(?=\s|>)/,end:/>/,keywords:{name:"style"},contains:[m],starts:{ end:/<\/style>/,returnEnd:!0,subLanguage:["css","xml"]}},{className:"tag", begin:/<script(?=\s|>)/,end:/>/,keywords:{name:"script"},contains:[m],starts:{ end:/<\/script>/,returnEnd:!0,subLanguage:["javascript","handlebars","xml"]}},{ className:"tag",begin:/<>|<\/>/},{className:"tag", begin:a(/</,n(a(t,s(/\/>/,/>/,/\s/)))),end:/\/?>/,contains:[{className:"name", begin:t,relevance:0,starts:m}]},{className:"tag",begin:a(/<\//,n(a(t,/>/))), contains:[{className:"name",begin:t,relevance:0},{begin:/>/,relevance:0, endsParent:!0}]}]}}})());hljs.registerLanguage("scss",(()=>{"use strict" ;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() ;return a=>{const n=(e=>({IMPORTANT:{className:"meta",begin:"!important"}, HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"}, ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/, illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]} }))(a),l=o,s=i,d="@[a-z-]+",c={className:"variable", begin:"(\\$[a-zA-Z-][a-zA-Z0-9_-]*)\\b"};return{name:"SCSS",case_insensitive:!0, illegal:"[=/|']",contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,{ className:"selector-id",begin:"#[A-Za-z0-9_-]+",relevance:0},{ className:"selector-class",begin:"\\.[A-Za-z0-9_-]+",relevance:0 },n.ATTRIBUTE_SELECTOR_MODE,{className:"selector-tag", begin:"\\b("+e.join("|")+")\\b",relevance:0},{className:"selector-pseudo", begin:":("+s.join("|")+")"},{className:"selector-pseudo", begin:"::("+l.join("|")+")"},c,{begin:/\(/,end:/\)/,contains:[a.CSS_NUMBER_MODE] },{className:"attribute",begin:"\\b("+r.join("|")+")\\b"},{ begin:"\\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" },{begin:":",end:";", contains:[c,n.HEXCOLOR,a.CSS_NUMBER_MODE,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,n.IMPORTANT] },{begin:"@(page|font-face)",lexemes:d,keywords:"@page @font-face"},{begin:"@", end:"[{;]",returnBegin:!0,keywords:{$pattern:/[a-z-]+/, keyword:"and or not only",attribute:t.join(" ")},contains:[{begin:d, className:"keyword"},{begin:/[a-z-]+(?=:)/,className:"attribute" },c,a.QUOTE_STRING_MODE,a.APOS_STRING_MODE,n.HEXCOLOR,a.CSS_NUMBER_MODE]}]}} })());hljs.registerLanguage("kotlin",(()=>{"use strict" ;var e="\\.([0-9](_*[0-9])*)",n="[0-9a-fA-F](_*[0-9a-fA-F])*",a={ className:"number",variants:[{ begin:`(\\b([0-9](_*[0-9])*)((${e})|\\.)?|(${e}))[eE][+-]?([0-9](_*[0-9])*)[fFdD]?\\b` },{begin:`\\b([0-9](_*[0-9])*)((${e})[fFdD]?\\b|\\.([fFdD]\\b)?)`},{ begin:`(${e})[fFdD]?\\b`},{begin:"\\b([0-9](_*[0-9])*)[fFdD]\\b"},{ begin:`\\b0[xX]((${n})\\.?|(${n})?\\.(${n}))[pP][+-]?([0-9](_*[0-9])*)[fFdD]?\\b` },{begin:"\\b(0|[1-9](_*[0-9])*)[lL]?\\b"},{begin:`\\b0[xX](${n})[lL]?\\b`},{ begin:"\\b0(_*[0-7])*[lL]?\\b"},{begin:"\\b0[bB][01](_*[01])*[lL]?\\b"}], relevance:0};return e=>{const n={ keyword:"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", built_in:"Byte Short Char Int Long Boolean Float Double Void Unit Nothing", literal:"true false null"},i={className:"symbol",begin:e.UNDERSCORE_IDENT_RE+"@" },s={className:"subst",begin:/\$\{/,end:/\}/,contains:[e.C_NUMBER_MODE]},t={ className:"variable",begin:"\\$"+e.UNDERSCORE_IDENT_RE},r={className:"string", variants:[{begin:'"""',end:'"""(?=[^"])',contains:[t,s]},{begin:"'",end:"'", illegal:/\n/,contains:[e.BACKSLASH_ESCAPE]},{begin:'"',end:'"',illegal:/\n/, contains:[e.BACKSLASH_ESCAPE,t,s]}]};s.contains.push(r);const l={ className:"meta", begin:"@(?:file|property|field|get|set|receiver|param|setparam|delegate)\\s*:(?:\\s*"+e.UNDERSCORE_IDENT_RE+")?" },c={className:"meta",begin:"@"+e.UNDERSCORE_IDENT_RE,contains:[{begin:/\(/, end:/\)/,contains:[e.inherit(r,{className:"meta-string"})]}] },o=a,b=e.COMMENT("/\\*","\\*/",{contains:[e.C_BLOCK_COMMENT_MODE]}),E={ variants:[{className:"type",begin:e.UNDERSCORE_IDENT_RE},{begin:/\(/,end:/\)/, contains:[]}]},d=E;return d.variants[1].contains=[E],E.variants[1].contains=[d], {name:"Kotlin",aliases:["kt","kts"],keywords:n, contains:[e.COMMENT("/\\*\\*","\\*/",{relevance:0,contains:[{className:"doctag", begin:"@[A-Za-z]+"}]}),e.C_LINE_COMMENT_MODE,b,{className:"keyword", begin:/\b(break|continue|return|this)\b/,starts:{contains:[{className:"symbol", begin:/@\w+/}]}},i,l,c,{className:"function",beginKeywords:"fun",end:"[(]|$", returnBegin:!0,excludeEnd:!0,keywords:n,relevance:5,contains:[{ begin:e.UNDERSCORE_IDENT_RE+"\\s*\\(",returnBegin:!0,relevance:0, contains:[e.UNDERSCORE_TITLE_MODE]},{className:"type",begin:/</,end:/>/, keywords:"reified",relevance:0},{className:"params",begin:/\(/,end:/\)/, endsParent:!0,keywords:n,relevance:0,contains:[{begin:/:/,end:/[=,\/]/, endsWithParent:!0,contains:[E,e.C_LINE_COMMENT_MODE,b],relevance:0 },e.C_LINE_COMMENT_MODE,b,l,c,r,e.C_NUMBER_MODE]},b]},{className:"class", beginKeywords:"class interface trait",end:/[:\{(]|$/,excludeEnd:!0, illegal:"extends implements",contains:[{ beginKeywords:"public protected internal private constructor" },e.UNDERSCORE_TITLE_MODE,{className:"type",begin:/</,end:/>/,excludeBegin:!0, excludeEnd:!0,relevance:0},{className:"type",begin:/[,:]\s*/,end:/[<\(,]|$/, excludeBegin:!0,returnEnd:!0},l,c]},r,{className:"meta",begin:"^#!/usr/bin/env", end:"$",illegal:"\n"},o]}}})());hljs.registerLanguage("makefile",(()=>{"use strict";return e=>{const i={ className:"variable",variants:[{begin:"\\$\\("+e.UNDERSCORE_IDENT_RE+"\\)", contains:[e.BACKSLASH_ESCAPE]},{begin:/\$[@%<?\^\+\*]/}]},a={className:"string", begin:/"/,end:/"/,contains:[e.BACKSLASH_ESCAPE,i]},n={className:"variable", begin:/\$\([\w-]+\s/,end:/\)/,keywords:{ built_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" },contains:[i]},s={begin:"^"+e.UNDERSCORE_IDENT_RE+"\\s*(?=[:+?]?=)"},r={ className:"section",begin:/^[^\s]+:/,end:/$/,contains:[i]};return{ name:"Makefile",aliases:["mk","mak","make"],keywords:{$pattern:/[\w-]+/, keyword:"define endef undefine ifdef ifndef ifeq ifneq else endif include -include sinclude override export unexport private vpath" },contains:[e.HASH_COMMENT_MODE,i,a,n,s,{className:"meta",begin:/^\.PHONY:/, end:/$/,keywords:{$pattern:/[\.\w]+/,"meta-keyword":".PHONY"}},r]}}})());hljs.registerLanguage("c",(()=>{"use strict";function e(e){ return((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(",e,")?") }return t=>{const n=t.COMMENT("//","$",{contains:[{begin:/\\\n/}] }),r="[a-zA-Z_]\\w*::",a="(decltype\\(auto\\)|"+e(r)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",i={ className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},s={className:"string", variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n", contains:[t.BACKSLASH_ESCAPE]},{ begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", end:"'",illegal:"."},t.END_SAME_AS_BEGIN({ begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={ className:"number",variants:[{begin:"\\b(0b[01']+)"},{ begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" },{ begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" }],relevance:0},c={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ "meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" },contains:[{begin:/\\\n/,relevance:0},t.inherit(s,{className:"meta-string"}),{ className:"meta-string",begin:/<.*?>/},n,t.C_BLOCK_COMMENT_MODE]},l={ className:"title",begin:e(r)+t.IDENT_RE,relevance:0 },d=e(r)+t.IDENT_RE+"\\s*\\(",u={ keyword:"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", built_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", literal:"true false nullptr NULL"},m=[c,i,n,t.C_BLOCK_COMMENT_MODE,o,s],p={ variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{ beginKeywords:"new throw return else",end:/;/}],keywords:u,contains:m.concat([{ begin:/\(/,end:/\)/,keywords:u,contains:m.concat(["self"]),relevance:0}]), relevance:0},_={className:"function",begin:"("+a+"[\\*&\\s]+)+"+d, returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:u,illegal:/[^\w\s\*&:<>.]/, contains:[{begin:"decltype\\(auto\\)",keywords:u,relevance:0},{begin:d, returnBegin:!0,contains:[l],relevance:0},{className:"params",begin:/\(/, end:/\)/,keywords:u,relevance:0,contains:[n,t.C_BLOCK_COMMENT_MODE,s,o,i,{ begin:/\(/,end:/\)/,keywords:u,relevance:0, contains:["self",n,t.C_BLOCK_COMMENT_MODE,s,o,i]}] },i,n,t.C_BLOCK_COMMENT_MODE,c]};return{name:"C",aliases:["h"],keywords:u, disableAutodetect:!0,illegal:"</",contains:[].concat(p,_,m,[c,{ begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<", end:">",keywords:u,contains:["self",i]},{begin:t.IDENT_RE+"::",keywords:u},{ className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/, contains:[{beginKeywords:"final class struct"},t.TITLE_MODE]}]),exports:{ preprocessor:c,strings:s,keywords:u}}}})());hljs.registerLanguage("http",(()=>{"use strict";function e(...e){ return e.map((e=>{return(n=e)?"string"==typeof n?n:n.source:null;var n })).join("")}return n=>{const a="HTTP/(2|1\\.[01])",s={className:"attribute", begin:e("^",/[A-Za-z][A-Za-z0-9-]*/,"(?=\\:\\s)"),starts:{contains:[{ className:"punctuation",begin:/: /,relevance:0,starts:{end:"$",relevance:0}}]} },t=[s,{begin:"\\n\\n",starts:{subLanguage:[],endsWithParent:!0}}];return{ name:"HTTP",aliases:["https"],illegal:/\S/,contains:[{begin:"^(?="+a+" \\d{3})", end:/$/,contains:[{className:"meta",begin:a},{className:"number", begin:"\\b\\d{3}\\b"}],starts:{end:/\b\B/,illegal:/\S/,contains:t}},{ begin:"(?=^[A-Z]+ (.*?) "+a+"$)",end:/$/,contains:[{className:"string", begin:" ",end:" ",excludeBegin:!0,excludeEnd:!0},{className:"meta",begin:a},{ className:"keyword",begin:"[A-Z]+"}],starts:{end:/\b\B/,illegal:/\S/,contains:t} },n.inherit(s,{relevance:0})]}}})());hljs.registerLanguage("markdown",(()=>{"use strict";function n(...n){ return n.map((n=>{return(e=n)?"string"==typeof e?e:e.source:null;var e })).join("")}return e=>{const a={begin:/<\/?[A-Za-z_]/,end:">", subLanguage:"xml",relevance:0},i={variants:[{begin:/\[.+?\]\[.*?\]/,relevance:0 },{begin:/\[.+?\]\(((data|javascript|mailto):|(?:http|ftp)s?:\/\/).*?\)/, relevance:2},{begin:n(/\[.+?\]\(/,/[A-Za-z][A-Za-z0-9+.-]*/,/:\/\/.*?\)/), relevance:2},{begin:/\[.+?\]\([./?&#].*?\)/,relevance:1},{ begin:/\[.+?\]\(.*?\)/,relevance:0}],returnBegin:!0,contains:[{ className:"string",relevance:0,begin:"\\[",end:"\\]",excludeBegin:!0, returnEnd:!0},{className:"link",relevance:0,begin:"\\]\\(",end:"\\)", excludeBegin:!0,excludeEnd:!0},{className:"symbol",relevance:0,begin:"\\]\\[", end:"\\]",excludeBegin:!0,excludeEnd:!0}]},s={className:"strong",contains:[], variants:[{begin:/_{2}/,end:/_{2}/},{begin:/\*{2}/,end:/\*{2}/}]},c={ className:"emphasis",contains:[],variants:[{begin:/\*(?!\*)/,end:/\*/},{ begin:/_(?!_)/,end:/_/,relevance:0}]};s.contains.push(c),c.contains.push(s) ;let t=[a,i] ;return s.contains=s.contains.concat(t),c.contains=c.contains.concat(t), t=t.concat(s,c),{name:"Markdown",aliases:["md","mkdown","mkd"],contains:[{ className:"section",variants:[{begin:"^#{1,6}",end:"$",contains:t},{ begin:"(?=^.+?\\n[=-]{2,}$)",contains:[{begin:"^[=-]*$"},{begin:"^",end:"\\n", contains:t}]}]},a,{className:"bullet",begin:"^[ \t]*([*+-]|(\\d+\\.))(?=\\s+)", end:"\\s+",excludeEnd:!0},s,c,{className:"quote",begin:"^>\\s+",contains:t, end:"$"},{className:"code",variants:[{begin:"(`{3,})[^`](.|\\n)*?\\1`*[ ]*"},{ begin:"(~{3,})[^~](.|\\n)*?\\1~*[ ]*"},{begin:"```",end:"```+[ ]*$"},{ begin:"~~~",end:"~~~+[ ]*$"},{begin:"`.+?`"},{begin:"(?=^( {4}|\\t))", contains:[{begin:"^( {4}|\\t)",end:"(\\n)$"}],relevance:0}]},{ begin:"^[-\\*]{3,}",end:"$"},i,{begin:/^\[[^\n]+\]:/,returnBegin:!0,contains:[{ className:"symbol",begin:/\[/,end:/\]/,excludeBegin:!0,excludeEnd:!0},{ className:"link",begin:/:\s*/,end:/$/,excludeBegin:!0}]}]}}})());hljs.registerLanguage("apache",(()=>{"use strict";return e=>{const n={ className:"number",begin:/\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}(:\d{1,5})?/} ;return{name:"Apache config",aliases:["apacheconf"],case_insensitive:!0, contains:[e.HASH_COMMENT_MODE,{className:"section",begin:/<\/?/,end:/>/, contains:[n,{className:"number",begin:/:\d{1,5}/ },e.inherit(e.QUOTE_STRING_MODE,{relevance:0})]},{className:"attribute", begin:/\w+/,relevance:0,keywords:{ nomarkup:"order deny allow setenv rewriterule rewriteengine rewritecond documentroot sethandler errordocument loadmodule options header listen serverroot servername" },starts:{end:/$/,relevance:0,keywords:{literal:"on off all deny allow"}, contains:[{className:"meta",begin:/\s\[/,end:/\]$/},{className:"variable", begin:/[\$%]\{/,end:/\}/,contains:["self",{className:"number",begin:/[$%]\d+/}] },n,{className:"number",begin:/\d+/},e.QUOTE_STRING_MODE]}}],illegal:/\S/}} })());hljs.registerLanguage("rust",(()=>{"use strict";return e=>{ const 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!" ;return{name:"Rust",aliases:["rs"],keywords:{$pattern:e.IDENT_RE+"!?", keyword:"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", literal:"true false Some None Ok Err",built_in:t},illegal:"</", contains:[e.C_LINE_COMMENT_MODE,e.COMMENT("/\\*","\\*/",{contains:["self"] }),e.inherit(e.QUOTE_STRING_MODE,{begin:/b?"/,illegal:null}),{ className:"string",variants:[{begin:/r(#*)"(.|\n)*?"\1(?!#)/},{ begin:/b?'\\?(x\w{2}|u\w{4}|U\w{8}|.)'/}]},{className:"symbol", begin:/'[a-zA-Z_][a-zA-Z0-9_]*/},{className:"number",variants:[{ begin:"\\b0b([01_]+)"+n},{begin:"\\b0o([0-7_]+)"+n},{ begin:"\\b0x([A-Fa-f0-9_]+)"+n},{ begin:"\\b(\\d[\\d_]*(\\.[0-9_]+)?([eE][+-]?[0-9_]+)?)"+n}],relevance:0},{ className:"function",beginKeywords:"fn",end:"(\\(|<)",excludeEnd:!0, contains:[e.UNDERSCORE_TITLE_MODE]},{className:"meta",begin:"#!?\\[",end:"\\]", contains:[{className:"meta-string",begin:/"/,end:/"/}]},{className:"class", beginKeywords:"type",end:";",contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{ endsParent:!0})],illegal:"\\S"},{className:"class", beginKeywords:"trait enum struct union",end:/\{/, contains:[e.inherit(e.UNDERSCORE_TITLE_MODE,{endsParent:!0})],illegal:"[\\w\\d]" },{begin:e.IDENT_RE+"::",keywords:{built_in:t}},{begin:"->"}]}}})());hljs.registerLanguage("php",(()=>{"use strict";return e=>{const r={ className:"variable", begin:"\\$+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*(?![A-Za-z0-9])(?![$])"},t={ className:"meta",variants:[{begin:/<\?php/,relevance:10},{begin:/<\?[=]?/},{ begin:/\?>/}]},a={className:"subst",variants:[{begin:/\$\w+/},{begin:/\{\$/, end:/\}/}]},n=e.inherit(e.APOS_STRING_MODE,{illegal:null }),i=e.inherit(e.QUOTE_STRING_MODE,{illegal:null, contains:e.QUOTE_STRING_MODE.contains.concat(a)}),o=e.END_SAME_AS_BEGIN({ begin:/<<<[ \t]*(\w+)\n/,end:/[ \t]*(\w+)\b/, contains:e.QUOTE_STRING_MODE.contains.concat(a)}),l={className:"string", contains:[e.BACKSLASH_ESCAPE,t],variants:[e.inherit(n,{begin:"b'",end:"'" }),e.inherit(i,{begin:'b"',end:'"'}),i,n,o]},s={className:"number",variants:[{ begin:"\\b0b[01]+(?:_[01]+)*\\b"},{begin:"\\b0o[0-7]+(?:_[0-7]+)*\\b"},{ begin:"\\b0x[\\da-f]+(?:_[\\da-f]+)*\\b"},{ begin:"(?:\\b\\d+(?:_\\d+)*(\\.(?:\\d+(?:_\\d+)*))?|\\B\\.\\d+)(?:e[+-]?\\d+)?" }],relevance:0},c={ keyword:"__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", literal:"false null true", built_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" };return{aliases:["php3","php4","php5","php6","php7","php8"], case_insensitive:!0,keywords:c, contains:[e.HASH_COMMENT_MODE,e.COMMENT("//","$",{contains:[t] }),e.COMMENT("/\\*","\\*/",{contains:[{className:"doctag",begin:"@[A-Za-z]+"}] }),e.COMMENT("__halt_compiler.+?;",!1,{endsWithParent:!0, keywords:"__halt_compiler"}),t,{className:"keyword",begin:/\$this\b/},r,{ begin:/(::|->)+[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/},{className:"function", relevance:0,beginKeywords:"fn function",end:/[;{]/,excludeEnd:!0, illegal:"[$%\\[]",contains:[{beginKeywords:"use"},e.UNDERSCORE_TITLE_MODE,{ begin:"=>",endsParent:!0},{className:"params",begin:"\\(",end:"\\)", excludeBegin:!0,excludeEnd:!0,keywords:c, contains:["self",r,e.C_BLOCK_COMMENT_MODE,l,s]}]},{className:"class",variants:[{ beginKeywords:"enum",illegal:/[($"]/},{beginKeywords:"class interface trait", illegal:/[:($"]/}],relevance:0,end:/\{/,excludeEnd:!0,contains:[{ beginKeywords:"extends implements"},e.UNDERSCORE_TITLE_MODE]},{ beginKeywords:"namespace",relevance:0,end:";",illegal:/[.']/, contains:[e.UNDERSCORE_TITLE_MODE]},{beginKeywords:"use",relevance:0,end:";", contains:[e.UNDERSCORE_TITLE_MODE]},l,s]}}})());hljs.registerLanguage("php-template",(()=>{"use strict";return n=>({ name:"PHP template",subLanguage:"xml",contains:[{begin:/<\?(php|=)?/,end:/\?>/, subLanguage:"php",contains:[{begin:"/\\*",end:"\\*/",skip:!0},{begin:'b"', end:'"',skip:!0},{begin:"b'",end:"'",skip:!0},n.inherit(n.APOS_STRING_MODE,{ illegal:null,className:null,contains:null,skip:!0 }),n.inherit(n.QUOTE_STRING_MODE,{illegal:null,className:null,contains:null, skip:!0})]}]})})());hljs.registerLanguage("cpp",(()=>{"use strict";function e(e){ return t("(",e,")?")}function t(...e){return e.map((e=>{ return(t=e)?"string"==typeof t?t:t.source:null;var t})).join("")}return n=>{ const r=n.COMMENT("//","$",{contains:[{begin:/\\\n/}] }),a="[a-zA-Z_]\\w*::",i="(decltype\\(auto\\)|"+e(a)+"[a-zA-Z_]\\w*"+e("<[^<>]+>")+")",s={ className:"keyword",begin:"\\b[a-z\\d_]*_t\\b"},c={className:"string", variants:[{begin:'(u8?|U|L)?"',end:'"',illegal:"\\n", contains:[n.BACKSLASH_ESCAPE]},{ begin:"(u8?|U|L)?'(\\\\(x[0-9A-Fa-f]{2}|u[0-9A-Fa-f]{4,8}|[0-7]{3}|\\S)|.)", end:"'",illegal:"."},n.END_SAME_AS_BEGIN({ begin:/(?:u8?|U|L)?R"([^()\\ ]{0,16})\(/,end:/\)([^()\\ ]{0,16})"/})]},o={ className:"number",variants:[{begin:"\\b(0b[01']+)"},{ begin:"(-?)\\b([\\d']+(\\.[\\d']*)?|\\.[\\d']+)((ll|LL|l|L)(u|U)?|(u|U)(ll|LL|l|L)?|f|F|b|B)" },{ begin:"(-?)(\\b0[xX][a-fA-F0-9']+|(\\b[\\d']+(\\.[\\d']*)?|\\.[\\d']+)([eE][-+]?[\\d']+)?)" }],relevance:0},l={className:"meta",begin:/#\s*[a-z]+\b/,end:/$/,keywords:{ "meta-keyword":"if else elif endif define undef warning error line pragma _Pragma ifdef ifndef include" },contains:[{begin:/\\\n/,relevance:0},n.inherit(c,{className:"meta-string"}),{ className:"meta-string",begin:/<.*?>/},r,n.C_BLOCK_COMMENT_MODE]},d={ className:"title",begin:e(a)+n.IDENT_RE,relevance:0 },u=e(a)+n.IDENT_RE+"\\s*\\(",m={ keyword:"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", built_in:"_Bool _Complex _Imaginary", _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"], literal:"true false nullptr NULL"},p={className:"function.dispatch",relevance:0, keywords:m, begin:t(/\b/,/(?!decltype)/,/(?!if)/,/(?!for)/,/(?!while)/,n.IDENT_RE,(_=/\s*\(/, t("(?=",_,")")))};var _;const g=[p,l,s,r,n.C_BLOCK_COMMENT_MODE,o,c],b={ variants:[{begin:/=/,end:/;/},{begin:/\(/,end:/\)/},{ beginKeywords:"new throw return else",end:/;/}],keywords:m,contains:g.concat([{ begin:/\(/,end:/\)/,keywords:m,contains:g.concat(["self"]),relevance:0}]), relevance:0},f={className:"function",begin:"("+i+"[\\*&\\s]+)+"+u, returnBegin:!0,end:/[{;=]/,excludeEnd:!0,keywords:m,illegal:/[^\w\s\*&:<>.]/, contains:[{begin:"decltype\\(auto\\)",keywords:m,relevance:0},{begin:u, returnBegin:!0,contains:[d],relevance:0},{begin:/::/,relevance:0},{begin:/:/, endsWithParent:!0,contains:[c,o]},{className:"params",begin:/\(/,end:/\)/, keywords:m,relevance:0,contains:[r,n.C_BLOCK_COMMENT_MODE,c,o,s,{begin:/\(/, end:/\)/,keywords:m,relevance:0,contains:["self",r,n.C_BLOCK_COMMENT_MODE,c,o,s] }]},s,r,n.C_BLOCK_COMMENT_MODE,l]};return{name:"C++", aliases:["cc","c++","h++","hpp","hh","hxx","cxx"],keywords:m,illegal:"</", classNameAliases:{"function.dispatch":"built_in"}, contains:[].concat(b,f,p,g,[l,{ begin:"\\b(deque|list|queue|priority_queue|pair|stack|vector|map|set|bitset|multiset|multimap|unordered_map|unordered_set|unordered_multiset|unordered_multimap|array)\\s*<", end:">",keywords:m,contains:["self",s]},{begin:n.IDENT_RE+"::",keywords:m},{ className:"class",beginKeywords:"enum class struct union",end:/[{;:<>=]/, contains:[{beginKeywords:"final class struct"},n.TITLE_MODE]}]),exports:{ preprocessor:l,strings:c,keywords:m}}}})());hljs.registerLanguage("json",(()=>{"use strict";return n=>{const e={ literal:"true false null" },i=[n.C_LINE_COMMENT_MODE,n.C_BLOCK_COMMENT_MODE],a=[n.QUOTE_STRING_MODE,n.C_NUMBER_MODE],l={ end:",",endsWithParent:!0,excludeEnd:!0,contains:a,keywords:e},t={begin:/\{/, end:/\}/,contains:[{className:"attr",begin:/"/,end:/"/, contains:[n.BACKSLASH_ESCAPE],illegal:"\\n"},n.inherit(l,{begin:/:/ })].concat(i),illegal:"\\S"},s={begin:"\\[",end:"\\]",contains:[n.inherit(l)], illegal:"\\S"};return a.push(t,s),i.forEach((n=>{a.push(n)})),{name:"JSON", contains:a,keywords:e,illegal:"\\S"}}})());hljs.registerLanguage("objectivec",(()=>{"use strict";return e=>{ const n=/[a-zA-Z@][a-zA-Z0-9_]*/,_={$pattern:n, keyword:"@interface @class @protocol @implementation"};return{ name:"Objective-C",aliases:["mm","objc","obj-c","obj-c++","objective-c++"], keywords:{$pattern:n, keyword:"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", literal:"false true FALSE TRUE nil YES NO NULL", built_in:"BOOL dispatch_once_t dispatch_queue_t dispatch_sync dispatch_async dispatch_once" },illegal:"</",contains:[{className:"built_in", begin:"\\b(AV|CA|CF|CG|CI|CL|CM|CN|CT|MK|MP|MTK|MTL|NS|SCN|SK|UI|WK|XC)\\w+" },e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,e.C_NUMBER_MODE,e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{ className:"string",variants:[{begin:'@"',end:'"',illegal:"\\n", contains:[e.BACKSLASH_ESCAPE]}]},{className:"meta",begin:/#\s*[a-z]+\b/,end:/$/, keywords:{ "meta-keyword":"if else elif endif define undef warning error line pragma ifdef ifndef include" },contains:[{begin:/\\\n/,relevance:0},e.inherit(e.QUOTE_STRING_MODE,{ className:"meta-string"}),{className:"meta-string",begin:/<.*?>/,end:/$/, illegal:"\\n"},e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE]},{ className:"class",begin:"("+_.keyword.split(" ").join("|")+")\\b",end:/(\{|$)/, excludeEnd:!0,keywords:_,contains:[e.UNDERSCORE_TITLE_MODE]},{ begin:"\\."+e.UNDERSCORE_IDENT_RE,relevance:0}]}}})());hljs.registerLanguage("less",(()=>{"use strict" ;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) ;return a=>{const s=(e=>({IMPORTANT:{className:"meta",begin:"!important"}, HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"}, ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/, illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]} }))(a),l=r,d="([\\w-]+|@\\{[\\w-]+\\})",c=[],g=[],b=e=>({className:"string", begin:"~?"+e+".*?"+e}),m=(e,t,i)=>({className:e,begin:t,relevance:i}),u={ $pattern:/[a-z-]+/,keyword:"and or not only",attribute:t.join(" ")},p={ begin:"\\(",end:"\\)",contains:g,keywords:u,relevance:0} ;g.push(a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,b("'"),b('"'),a.CSS_NUMBER_MODE,{ begin:"(url|data-uri)\\(",starts:{className:"string",end:"[\\)\\n]", excludeEnd:!0} },s.HEXCOLOR,p,m("variable","@@?[\\w-]+",10),m("variable","@\\{[\\w-]+\\}"),m("built_in","~?`[^`]*?`"),{ className:"attribute",begin:"[\\w-]+\\s*:",end:":",returnBegin:!0,excludeEnd:!0 },s.IMPORTANT);const f=g.concat({begin:/\{/,end:/\}/,contains:c}),h={ beginKeywords:"when",endsWithParent:!0,contains:[{beginKeywords:"and not" }].concat(g)},w={begin:d+"\\s*:",returnBegin:!0,end:/[;}]/,relevance:0, contains:[{begin:/-(webkit|moz|ms|o)-/},{className:"attribute", begin:"\\b("+n.join("|")+")\\b",end:/(?=:)/,starts:{endsWithParent:!0, illegal:"[<=$]",relevance:0,contains:g}}]},v={className:"keyword", begin:"@(import|media|charset|font-face|(-[a-z]+-)?keyframes|supports|document|namespace|page|viewport|host)\\b", starts:{end:"[;{}]",keywords:u,returnEnd:!0,contains:g,relevance:0}},y={ className:"variable",variants:[{begin:"@[\\w-]+\\s*:",relevance:15},{ begin:"@[\\w-]+"}],starts:{end:"[;}]",returnEnd:!0,contains:f}},k={variants:[{ begin:"[\\.#:&\\[>]",end:"[;{}]"},{begin:d,end:/\{/}],returnBegin:!0, returnEnd:!0,illegal:"[<='$\"]",relevance:0, contains:[a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,h,m("keyword","all\\b"),m("variable","@\\{[\\w-]+\\}"),{ begin:"\\b("+e.join("|")+")\\b",className:"selector-tag" },m("selector-tag",d+"%?",0),m("selector-id","#"+d),m("selector-class","\\."+d,0),m("selector-tag","&",0),s.ATTRIBUTE_SELECTOR_MODE,{ className:"selector-pseudo",begin:":("+i.join("|")+")"},{ className:"selector-pseudo",begin:"::("+o.join("|")+")"},{begin:"\\(",end:"\\)", contains:f},{begin:"!important"}]},E={begin:`[\\w-]+:(:)?(${l.join("|")})`, returnBegin:!0,contains:[k]} ;return c.push(a.C_LINE_COMMENT_MODE,a.C_BLOCK_COMMENT_MODE,v,y,E,w,k),{ name:"Less",case_insensitive:!0,illegal:"[=>'/<($\"]",contains:c}}})());hljs.registerLanguage("go",(()=>{"use strict";return e=>{const n={ keyword:"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", literal:"true false iota nil", built_in:"append cap close complex copy imag len make new panic print println real recover delete" };return{name:"Go",aliases:["golang"],keywords:n,illegal:"</", contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,{className:"string", variants:[e.QUOTE_STRING_MODE,e.APOS_STRING_MODE,{begin:"`",end:"`"}]},{ className:"number",variants:[{begin:e.C_NUMBER_RE+"[i]",relevance:1 },e.C_NUMBER_MODE]},{begin:/:=/},{className:"function",beginKeywords:"func", end:"\\s*(\\{|$)",excludeEnd:!0,contains:[e.TITLE_MODE,{className:"params", begin:/\(/,end:/\)/,keywords:n,illegal:/["']/}]}]}}})());hljs.registerLanguage("diff",(()=>{"use strict";return e=>({name:"Diff", aliases:["patch"],contains:[{className:"meta",relevance:10,variants:[{ begin:/^@@ +-\d+,\d+ +\+\d+,\d+ +@@/},{begin:/^\*\*\* +\d+,\d+ +\*\*\*\*$/},{ begin:/^--- +\d+,\d+ +----$/}]},{className:"comment",variants:[{begin:/Index: /, end:/$/},{begin:/^index/,end:/$/},{begin:/={3,}/,end:/$/},{begin:/^-{3}/,end:/$/ },{begin:/^\*{3} /,end:/$/},{begin:/^\+{3}/,end:/$/},{begin:/^\*{15}$/},{ begin:/^diff --git/,end:/$/}]},{className:"addition",begin:/^\+/,end:/$/},{ className:"deletion",begin:/^-/,end:/$/},{className:"addition",begin:/^!/, end:/$/}]})})());hljs.registerLanguage("python",(()=>{"use strict";return e=>{const n={ $pattern:/[A-Za-z]\w+|__\w+__/, keyword:["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"], built_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"], literal:["__debug__","Ellipsis","False","None","NotImplemented","True"], type:["Any","Callable","Coroutine","Dict","List","Literal","Generic","Optional","Sequence","Set","Tuple","Type","Union"] },a={className:"meta",begin:/^(>>>|\.\.\.) /},i={className:"subst",begin:/\{/, end:/\}/,keywords:n,illegal:/#/},s={begin:/\{\{/,relevance:0},t={ className:"string",contains:[e.BACKSLASH_ESCAPE],variants:[{ begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?'''/,end:/'''/, contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{ begin:/([uU]|[bB]|[rR]|[bB][rR]|[rR][bB])?"""/,end:/"""/, contains:[e.BACKSLASH_ESCAPE,a],relevance:10},{ begin:/([fF][rR]|[rR][fF]|[fF])'''/,end:/'''/, contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/([fF][rR]|[rR][fF]|[fF])"""/, end:/"""/,contains:[e.BACKSLASH_ESCAPE,a,s,i]},{begin:/([uU]|[rR])'/,end:/'/, relevance:10},{begin:/([uU]|[rR])"/,end:/"/,relevance:10},{ begin:/([bB]|[bB][rR]|[rR][bB])'/,end:/'/},{begin:/([bB]|[bB][rR]|[rR][bB])"/, end:/"/},{begin:/([fF][rR]|[rR][fF]|[fF])'/,end:/'/, contains:[e.BACKSLASH_ESCAPE,s,i]},{begin:/([fF][rR]|[rR][fF]|[fF])"/,end:/"/, contains:[e.BACKSLASH_ESCAPE,s,i]},e.APOS_STRING_MODE,e.QUOTE_STRING_MODE] },r="[0-9](_?[0-9])*",l=`(\\b(${r}))?\\.(${r})|\\b(${r})\\.`,b={ className:"number",relevance:0,variants:[{ begin:`(\\b(${r})|(${l}))[eE][+-]?(${r})[jJ]?\\b`},{begin:`(${l})[jJ]?`},{ begin:"\\b([1-9](_?[0-9])*|0+(_?0)*)[lLjJ]?\\b"},{ begin:"\\b0[bB](_?[01])+[lL]?\\b"},{begin:"\\b0[oO](_?[0-7])+[lL]?\\b"},{ begin:"\\b0[xX](_?[0-9a-fA-F])+[lL]?\\b"},{begin:`\\b(${r})[jJ]\\b`}]},o={ className:"comment", begin:(d=/# type:/,((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",d,")")), end:/$/,keywords:n,contains:[{begin:/# type:/},{begin:/#/,end:/\b\B/, endsWithParent:!0}]},c={className:"params",variants:[{className:"", begin:/\(\s*\)/,skip:!0},{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0, keywords:n,contains:["self",a,b,t,e.HASH_COMMENT_MODE]}]};var d ;return i.contains=[t,b,a],{name:"Python",aliases:["py","gyp","ipython"], keywords:n,illegal:/(<\/|->|\?)|=>/,contains:[a,b,{begin:/\bself\b/},{ beginKeywords:"if",relevance:0},t,o,e.HASH_COMMENT_MODE,{variants:[{ className:"function",beginKeywords:"def"},{className:"class", beginKeywords:"class"}],end:/:/,illegal:/[${=;\n,]/, contains:[e.UNDERSCORE_TITLE_MODE,c,{begin:/->/,endsWithParent:!0,keywords:n}] },{className:"meta",begin:/^[\t ]*@/,end:/(?=#)|$/,contains:[b,c,t]}]}}})());hljs.registerLanguage("python-repl",(()=>{"use strict";return s=>({ aliases:["pycon"],contains:[{className:"meta",starts:{end:/ |$/,starts:{end:"$", subLanguage:"python"}},variants:[{begin:/^>>>(?=[ ]|$)/},{ begin:/^\.\.\.(?=[ ]|$)/}]}]})})());hljs.registerLanguage("css",(()=>{"use strict" ;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() ;return n=>{const a=(e=>({IMPORTANT:{className:"meta",begin:"!important"}, HEXCOLOR:{className:"number",begin:"#([a-fA-F0-9]{6}|[a-fA-F0-9]{3})"}, ATTRIBUTE_SELECTOR_MODE:{className:"selector-attr",begin:/\[/,end:/\]/, illegal:"$",contains:[e.APOS_STRING_MODE,e.QUOTE_STRING_MODE]} }))(n),l=[n.APOS_STRING_MODE,n.QUOTE_STRING_MODE];return{name:"CSS", case_insensitive:!0,illegal:/[=|'\$]/,keywords:{keyframePosition:"from to"}, classNameAliases:{keyframePosition:"selector-tag"}, contains:[n.C_BLOCK_COMMENT_MODE,{begin:/-(webkit|moz|ms|o)-(?=[a-z])/ },n.CSS_NUMBER_MODE,{className:"selector-id",begin:/#[A-Za-z0-9_-]+/,relevance:0 },{className:"selector-class",begin:"\\.[a-zA-Z-][a-zA-Z0-9_-]*",relevance:0 },a.ATTRIBUTE_SELECTOR_MODE,{className:"selector-pseudo",variants:[{ begin:":("+i.join("|")+")"},{begin:"::("+o.join("|")+")"}]},{ className:"attribute",begin:"\\b("+r.join("|")+")\\b"},{begin:":",end:"[;}]", contains:[a.HEXCOLOR,a.IMPORTANT,n.CSS_NUMBER_MODE,...l,{ begin:/(url|data-uri)\(/,end:/\)/,relevance:0,keywords:{built_in:"url data-uri" },contains:[{className:"string",begin:/[^)]/,endsWithParent:!0,excludeEnd:!0}] },{className:"built_in",begin:/[\w-]+(?=\()/}]},{ begin:(s=/@/,((...e)=>e.map((e=>(e=>e?"string"==typeof e?e:e.source:null)(e))).join(""))("(?=",s,")")), end:"[{;]",relevance:0,illegal:/:/,contains:[{className:"keyword", begin:/@-?\w[\w]*(-\w+)*/},{begin:/\s/,endsWithParent:!0,excludeEnd:!0, relevance:0,keywords:{$pattern:/[a-z-]+/,keyword:"and or not only", attribute:t.join(" ")},contains:[{begin:/[a-z-]+(?=:)/,className:"attribute" },...l,n.CSS_NUMBER_MODE]}]},{className:"selector-tag", begin:"\\b("+e.join("|")+")\\b"}]};var s}})());hljs.registerLanguage("lua",(()=>{"use strict";return e=>{ const t="\\[=*\\[",a="\\]=*\\]",n={begin:t,end:a,contains:["self"] },o=[e.COMMENT("--(?!\\[=*\\[)","$"),e.COMMENT("--\\[=*\\[",a,{contains:[n], relevance:10})];return{name:"Lua",keywords:{$pattern:e.UNDERSCORE_IDENT_RE, literal:"true false nil", keyword:"and break do else elseif end for goto if in local not or repeat return then until while", built_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" },contains:o.concat([{className:"function",beginKeywords:"function",end:"\\)", contains:[e.inherit(e.TITLE_MODE,{ begin:"([_a-zA-Z]\\w*\\.)*([_a-zA-Z]\\w*:)?[_a-zA-Z]\\w*"}),{className:"params", begin:"\\(",endsWithParent:!0,contains:o}].concat(o) },e.C_NUMBER_MODE,e.APOS_STRING_MODE,e.QUOTE_STRING_MODE,{className:"string", begin:t,end:a,contains:[n],relevance:5}])}}})());hljs.registerLanguage("d",(()=>{"use strict";return e=>{const a={ $pattern:e.UNDERSCORE_IDENT_RE, keyword:"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__", built_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", literal:"false null true" },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={ className:"number",begin:"\\b"+d+"(L|u|U|Lu|LU|uL|UL)?",relevance:0},_={ className:"number", begin:"\\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))", relevance:0},r={className:"string",begin:"'("+n+"|.)",end:"'",illegal:"."},i={ className:"string",begin:'"',contains:[{begin:n,relevance:0}],end:'"[cwd]?' },s=e.COMMENT("\\/\\+","\\+\\/",{contains:["self"],relevance:10});return{ name:"D",keywords:a,contains:[e.C_LINE_COMMENT_MODE,e.C_BLOCK_COMMENT_MODE,s,{ className:"string",begin:'x"[\\da-fA-F\\s\\n\\r]*"[cwd]?',relevance:10},i,{ className:"string",begin:'[rq]"',end:'"[cwd]?',relevance:5},{className:"string", begin:"`",end:"`[cwd]?"},{className:"string",begin:'q"\\{',end:'\\}"'},_,t,r,{ className:"meta",begin:"^#!",end:"$",relevance:5},{className:"meta", begin:"#(line)",end:"$",relevance:5},{className:"keyword", begin:"@[a-zA-Z_][a-zA-Z_\\d]*"}]}}})());hljs.registerLanguage("typescript",(()=>{"use strict" ;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"]) ;function t(e){return r("(?=",e,")")}function r(...e){return e.map((e=>{ return(n=e)?"string"==typeof n?n:n.source:null;var n})).join("")}return i=>{ const c={$pattern:e, keyword:n.concat(["type","namespace","typedef","interface","public","private","protected","implements","declare","abstract","readonly"]), literal:a, built_in:s.concat(["any","void","number","boolean","string","object","never","enum"]) },o={className:"meta",begin:"@[A-Za-z$_][0-9A-Za-z$_]*"},l=(e,n,a)=>{ const s=e.contains.findIndex((e=>e.label===n)) ;if(-1===s)throw Error("can not find mode to replace");e.contains.splice(s,1,a) },b=(i=>{const c=e,o={begin:/<[A-Za-z0-9\\._:-]+/, end:/\/[A-Za-z0-9\\._:-]+>|\/>/,isTrulyOpeningTag:(e,n)=>{ const a=e[0].length+e.index,s=e.input[a];"<"!==s?">"===s&&(((e,{after:n})=>{ const a="</"+e[0].slice(1);return-1!==e.input.indexOf(a,n)})(e,{after:a })||n.ignoreMatch()):n.ignoreMatch()}},l={$pattern:e,keyword:n,literal:a, built_in:s},b="\\.([0-9](_?[0-9])*)",d="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",g={ className:"number",variants:[{ begin:`(\\b(${d})((${b})|\\.)?|(${b}))[eE][+-]?([0-9](_?[0-9])*)\\b`},{ begin:`\\b(${d})\\b((${b})\\b|\\.)?|(${b})\\b`},{ begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{ begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{ begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{ begin:"\\b0[0-7]+n?\\b"}],relevance:0},u={className:"subst",begin:"\\$\\{", end:"\\}",keywords:l,contains:[]},E={begin:"html`",end:"",starts:{end:"`", returnEnd:!1,contains:[i.BACKSLASH_ESCAPE,u],subLanguage:"xml"}},m={ begin:"css`",end:"",starts:{end:"`",returnEnd:!1, contains:[i.BACKSLASH_ESCAPE,u],subLanguage:"css"}},y={className:"string", begin:"`",end:"`",contains:[i.BACKSLASH_ESCAPE,u]},_={className:"comment", variants:[i.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{ className:"doctag",begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{", end:"\\}",relevance:0},{className:"variable",begin:c+"(?=\\s*(-)|$)", endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}] }),i.C_BLOCK_COMMENT_MODE,i.C_LINE_COMMENT_MODE] },p=[i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,E,m,y,g,i.REGEXP_MODE] ;u.contains=p.concat({begin:/\{/,end:/\}/,keywords:l,contains:["self"].concat(p) });const N=[].concat(_,u.contains),f=N.concat([{begin:/\(/,end:/\)/,keywords:l, contains:["self"].concat(N)}]),A={className:"params",begin:/\(/,end:/\)/, excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f};return{name:"Javascript", aliases:["js","jsx","mjs","cjs"],keywords:l,exports:{PARAMS_CONTAINS:f}, illegal:/#(?![$_A-z])/,contains:[i.SHEBANG({label:"shebang",binary:"node", relevance:5}),{label:"use_strict",className:"meta",relevance:10, begin:/^\s*['"]use (strict|asm)['"]/ },i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,E,m,y,_,g,{ begin:r(/[{,\n]\s*/,t(r(/(((\/\/.*$)|(\/\*(\*[^/]|[^*])*\*\/))\s*)*/,c+"\\s*:"))), relevance:0,contains:[{className:"attr",begin:c+t("\\s*:"),relevance:0}]},{ begin:"("+i.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", keywords:"return throw case",contains:[_,i.REGEXP_MODE,{className:"function", begin:"(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+i.UNDERSCORE_IDENT_RE+")\\s*=>", returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{ begin:i.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0 },{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f}]}] },{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{ variants:[{begin:"<>",end:"</>"},{begin:o.begin,"on:begin":o.isTrulyOpeningTag, end:o.end}],subLanguage:"xml",contains:[{begin:o.begin,end:o.end,skip:!0, contains:["self"]}]}],relevance:0},{className:"function", beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:l, contains:["self",i.inherit(i.TITLE_MODE,{begin:c}),A],illegal:/%/},{ beginKeywords:"while if switch catch for"},{className:"function", begin:i.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", returnBegin:!0,contains:[A,i.inherit(i.TITLE_MODE,{begin:c})]},{variants:[{ begin:"\\."+c},{begin:"\\$"+c}],relevance:0},{className:"class", beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{ beginKeywords:"extends"},i.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/, end:/[{;]/,excludeEnd:!0,contains:[i.inherit(i.TITLE_MODE,{begin:c}),"self",A] },{begin:"(get|set)\\s+(?="+c+"\\()",end:/\{/,keywords:"get set", contains:[i.inherit(i.TITLE_MODE,{begin:c}),{begin:/\(\)/},A]},{begin:/\$[(.]/}] }})(i) ;return Object.assign(b.keywords,c),b.exports.PARAMS_CONTAINS.push(o),b.contains=b.contains.concat([o,{ beginKeywords:"namespace",end:/\{/,excludeEnd:!0},{beginKeywords:"interface", end:/\{/,excludeEnd:!0,keywords:"interface extends" }]),l(b,"shebang",i.SHEBANG()),l(b,"use_strict",{className:"meta",relevance:10, begin:/^\s*['"]use strict['"]/ }),b.contains.find((e=>"function"===e.className)).relevance=0,Object.assign(b,{ name:"TypeScript",aliases:["ts","tsx"]}),b}})());hljs.registerLanguage("javascript",(()=>{"use strict" ;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"]) ;function r(e){return t("(?=",e,")")}function t(...e){return e.map((e=>{ return(n=e)?"string"==typeof n?n:n.source:null;var n})).join("")}return i=>{ const c=e,o={begin:/<[A-Za-z0-9\\._:-]+/,end:/\/[A-Za-z0-9\\._:-]+>|\/>/, isTrulyOpeningTag:(e,n)=>{const a=e[0].length+e.index,s=e.input[a] ;"<"!==s?">"===s&&(((e,{after:n})=>{const a="</"+e[0].slice(1) ;return-1!==e.input.indexOf(a,n)})(e,{after:a })||n.ignoreMatch()):n.ignoreMatch()}},l={$pattern:e,keyword:n,literal:a, built_in:s},g="\\.([0-9](_?[0-9])*)",b="0|[1-9](_?[0-9])*|0[0-7]*[89][0-9]*",d={ className:"number",variants:[{ begin:`(\\b(${b})((${g})|\\.)?|(${g}))[eE][+-]?([0-9](_?[0-9])*)\\b`},{ begin:`\\b(${b})\\b((${g})\\b|\\.)?|(${g})\\b`},{ begin:"\\b(0|[1-9](_?[0-9])*)n\\b"},{ begin:"\\b0[xX][0-9a-fA-F](_?[0-9a-fA-F])*n?\\b"},{ begin:"\\b0[bB][0-1](_?[0-1])*n?\\b"},{begin:"\\b0[oO][0-7](_?[0-7])*n?\\b"},{ begin:"\\b0[0-7]+n?\\b"}],relevance:0},E={className:"subst",begin:"\\$\\{", end:"\\}",keywords:l,contains:[]},u={begin:"html`",end:"",starts:{end:"`", returnEnd:!1,contains:[i.BACKSLASH_ESCAPE,E],subLanguage:"xml"}},_={ begin:"css`",end:"",starts:{end:"`",returnEnd:!1, contains:[i.BACKSLASH_ESCAPE,E],subLanguage:"css"}},m={className:"string", begin:"`",end:"`",contains:[i.BACKSLASH_ESCAPE,E]},y={className:"comment", variants:[i.COMMENT(/\/\*\*(?!\/)/,"\\*/",{relevance:0,contains:[{ className:"doctag",begin:"@[A-Za-z]+",contains:[{className:"type",begin:"\\{", end:"\\}",relevance:0},{className:"variable",begin:c+"(?=\\s*(-)|$)", endsParent:!0,relevance:0},{begin:/(?=[^\n])\s/,relevance:0}]}] }),i.C_BLOCK_COMMENT_MODE,i.C_LINE_COMMENT_MODE] },N=[i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,u,_,m,d,i.REGEXP_MODE] ;E.contains=N.concat({begin:/\{/,end:/\}/,keywords:l,contains:["self"].concat(N) });const A=[].concat(y,E.contains),f=A.concat([{begin:/\(/,end:/\)/,keywords:l, contains:["self"].concat(A)}]),p={className:"params",begin:/\(/,end:/\)/, excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f};return{name:"Javascript", aliases:["js","jsx","mjs","cjs"],keywords:l,exports:{PARAMS_CONTAINS:f}, illegal:/#(?![$_A-z])/,contains:[i.SHEBANG({label:"shebang",binary:"node", relevance:5}),{label:"use_strict",className:"meta",relevance:10, begin:/^\s*['"]use (strict|asm)['"]/ },i.APOS_STRING_MODE,i.QUOTE_STRING_MODE,u,_,m,y,d,{ begin:t(/[{,\n]\s*/,r(t(/(((\/\/.*$)|(\/\*(\*[^/]|[^*])*\*\/))\s*)*/,c+"\\s*:"))), relevance:0,contains:[{className:"attr",begin:c+r("\\s*:"),relevance:0}]},{ begin:"("+i.RE_STARTERS_RE+"|\\b(case|return|throw)\\b)\\s*", keywords:"return throw case",contains:[y,i.REGEXP_MODE,{className:"function", begin:"(\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)|"+i.UNDERSCORE_IDENT_RE+")\\s*=>", returnBegin:!0,end:"\\s*=>",contains:[{className:"params",variants:[{ begin:i.UNDERSCORE_IDENT_RE,relevance:0},{className:null,begin:/\(\s*\)/,skip:!0 },{begin:/\(/,end:/\)/,excludeBegin:!0,excludeEnd:!0,keywords:l,contains:f}]}] },{begin:/,/,relevance:0},{className:"",begin:/\s/,end:/\s*/,skip:!0},{ variants:[{begin:"<>",end:"</>"},{begin:o.begin,"on:begin":o.isTrulyOpeningTag, end:o.end}],subLanguage:"xml",contains:[{begin:o.begin,end:o.end,skip:!0, contains:["self"]}]}],relevance:0},{className:"function", beginKeywords:"function",end:/[{;]/,excludeEnd:!0,keywords:l, contains:["self",i.inherit(i.TITLE_MODE,{begin:c}),p],illegal:/%/},{ beginKeywords:"while if switch catch for"},{className:"function", begin:i.UNDERSCORE_IDENT_RE+"\\([^()]*(\\([^()]*(\\([^()]*\\)[^()]*)*\\)[^()]*)*\\)\\s*\\{", returnBegin:!0,contains:[p,i.inherit(i.TITLE_MODE,{begin:c})]},{variants:[{ begin:"\\."+c},{begin:"\\$"+c}],relevance:0},{className:"class", beginKeywords:"class",end:/[{;=]/,excludeEnd:!0,illegal:/[:"[\]]/,contains:[{ beginKeywords:"extends"},i.UNDERSCORE_TITLE_MODE]},{begin:/\b(?=constructor)/, end:/[{;]/,excludeEnd:!0,contains:[i.inherit(i.TITLE_MODE,{begin:c}),"self",p] },{begin:"(get|set)\\s+(?="+c+"\\()",end:/\{/,keywords:"get set", contains:[i.inherit(i.TITLE_MODE,{begin:c}),{begin:/\(\)/},p]},{begin:/\$[(.]/}] }}})());hljs.registerLanguage("r",(()=>{"use strict";function e(...e){return e.map((e=>{ return(a=e)?"string"==typeof a?a:a.source:null;var a})).join("")}return a=>{ const n=/(?:(?:[a-zA-Z]|\.[._a-zA-Z])[._a-zA-Z0-9]*)|\.(?!\d)/;return{name:"R", illegal:/->/,keywords:{$pattern:n, keyword:"function if in break next repeat else for while", literal:"NULL NA TRUE FALSE Inf NaN NA_integer_|10 NA_real_|10 NA_character_|10 NA_complex_|10", built_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" },compilerExtensions:[(a,n)=>{if(!a.beforeMatch)return ;if(a.starts)throw Error("beforeMatch cannot be used with starts") ;const i=Object.assign({},a);Object.keys(a).forEach((e=>{delete a[e] })),a.begin=e(i.beforeMatch,e("(?=",i.begin,")")),a.starts={relevance:0, contains:[Object.assign(i,{endsParent:!0})]},a.relevance=0,delete i.beforeMatch }],contains:[a.COMMENT(/#'/,/$/,{contains:[{className:"doctag", begin:"@examples",starts:{contains:[{begin:/\n/},{begin:/#'\s*(?=@[a-zA-Z]+)/, endsParent:!0},{begin:/#'/,end:/$/,excludeBegin:!0}]}},{className:"doctag", begin:"@param",end:/$/,contains:[{className:"variable",variants:[{begin:n},{ begin:/`(?:\\.|[^`\\])+`/}],endsParent:!0}]},{className:"doctag", begin:/@[a-zA-Z]+/},{className:"meta-keyword",begin:/\\[a-zA-Z]+/}] }),a.HASH_COMMENT_MODE,{className:"string",contains:[a.BACKSLASH_ESCAPE], variants:[a.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\(/,end:/\)(-*)"/ }),a.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\{/,end:/\}(-*)"/ }),a.END_SAME_AS_BEGIN({begin:/[rR]"(-*)\[/,end:/\](-*)"/ }),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\(/,end:/\)(-*)'/ }),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\{/,end:/\}(-*)'/ }),a.END_SAME_AS_BEGIN({begin:/[rR]'(-*)\[/,end:/\](-*)'/}),{begin:'"',end:'"', relevance:0},{begin:"'",end:"'",relevance:0}]},{className:"number",relevance:0, beforeMatch:/([^a-zA-Z0-9._])/,variants:[{ match:/0[xX][0-9a-fA-F]+\.[0-9a-fA-F]*[pP][+-]?\d+i?/},{ match:/0[xX][0-9a-fA-F]+([pP][+-]?\d+)?[Li]?/},{ match:/(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?[Li]?/}]},{begin:"%",end:"%"},{ begin:e(/[a-zA-Z][a-zA-Z_0-9]*/,"\\s+<-\\s+")},{begin:"`",end:"`",contains:[{ begin:/\\./}]}]}}})());hljs.registerLanguage("yaml",(()=>{"use strict";return e=>{ var n="true false yes no null",a="[\\w#;/?:@&=+$,.~*'()[\\]]+",s={ className:"string",relevance:0,variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/ },{begin:/\S+/}],contains:[e.BACKSLASH_ESCAPE,{className:"template-variable", variants:[{begin:/\{\{/,end:/\}\}/},{begin:/%\{/,end:/\}/}]}]},i=e.inherit(s,{ variants:[{begin:/'/,end:/'/},{begin:/"/,end:/"/},{begin:/[^\s,{}[\]]+/}]}),l={ end:",",endsWithParent:!0,excludeEnd:!0,keywords:n,relevance:0},t={begin:/\{/, end:/\}/,contains:[l],illegal:"\\n",relevance:0},g={begin:"\\[",end:"\\]", contains:[l],illegal:"\\n",relevance:0},b=[{className:"attr",variants:[{ begin:"\\w[\\w :\\/.-]*:(?=[ \t]|$)"},{begin:'"\\w[\\w :\\/.-]*":(?=[ \t]|$)'},{ begin:"'\\w[\\w :\\/.-]*':(?=[ \t]|$)"}]},{className:"meta",begin:"^---\\s*$", relevance:10},{className:"string", begin:"[\\|>]([1-9]?[+-])?[ ]*\\n( +)[^ ][^\\n]*\\n(\\2[^\\n]+\\n?)*"},{ begin:"<%[%=-]?",end:"[%-]?%>",subLanguage:"ruby",excludeBegin:!0,excludeEnd:!0, relevance:0},{className:"type",begin:"!\\w+!"+a},{className:"type", begin:"!<"+a+">"},{className:"type",begin:"!"+a},{className:"type",begin:"!!"+a },{className:"meta",begin:"&"+e.UNDERSCORE_IDENT_RE+"$"},{className:"meta", begin:"\\*"+e.UNDERSCORE_IDENT_RE+"$"},{className:"bullet",begin:"-(?=[ ]|$)", relevance:0},e.HASH_COMMENT_MODE,{beginKeywords:n,keywords:{literal:n}},{ className:"number", begin:"\\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" },{className:"number",begin:e.C_NUMBER_RE+"\\b",relevance:0},t,g,s],r=[...b] ;return r.pop(),r.push(i),l.contains=r,{name:"YAML",case_insensitive:!0, aliases:["yml"],contains:b}}})());hljs.registerLanguage("vbnet",(()=>{"use strict";function e(e){ return e?"string"==typeof e?e:e.source:null}function n(...n){ return n.map((n=>e(n))).join("")}function t(...n){ return"("+n.map((n=>e(n))).join("|")+")"}return e=>{ const 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={ className:"literal",variants:[{begin:n(/# */,t(i,a),/ *#/)},{ begin:n(/# */,r,/ *#/)},{begin:n(/# */,s,/ *#/)},{ begin:n(/# */,t(i,a),/ +/,t(s,r),/ *#/)}]},l=e.COMMENT(/'''/,/$/,{contains:[{ className:"doctag",begin:/<\/?/,end:/>/}]}),c=e.COMMENT(null,/$/,{variants:[{ begin:/'/},{begin:/([\t ]|^)REM(?=\s)/}]});return{name:"Visual Basic .NET", aliases:["vb"],case_insensitive:!0,classNameAliases:{label:"symbol"},keywords:{ keyword:"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", built_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", type:"boolean byte char date decimal double integer long object sbyte short single string uinteger ulong ushort", literal:"true false nothing"}, illegal:"//|\\{|\\}|endif|gosub|variant|wend|^\\$ ",contains:[{ className:"string",begin:/"(""|[^/n])"C\b/},{className:"string",begin:/"/, end:/"/,illegal:/\n/,contains:[{begin:/""/}]},o,{className:"number",relevance:0, variants:[{begin:/\b\d[\d_]*((\.[\d_]+(E[+-]?[\d_]+)?)|(E[+-]?[\d_]+))[RFD@!#]?/ },{begin:/\b\d[\d_]*((U?[SIL])|[%&])?/},{begin:/&H[\dA-F_]+((U?[SIL])|[%&])?/},{ begin:/&O[0-7_]+((U?[SIL])|[%&])?/},{begin:/&B[01_]+((U?[SIL])|[%&])?/}]},{ className:"label",begin:/^\w+:/},l,c,{className:"meta", begin:/[\t ]*#(const|disable|else|elseif|enable|end|externalsource|if|region)\b/, end:/$/,keywords:{ "meta-keyword":"const disable else elseif enable end externalsource if region then" },contains:[c]}]}}})()); ================================================ FILE: site-defaults/web/highlight-js/styles/vs.css ================================================ /* Visual Studio-like style based on original C# coloring by Jason Diamond <jason@diamond.name> */ .hljs { display: block; overflow-x: auto; padding: 0.5em; background: white; color: black; } .hljs-comment, .hljs-quote, .hljs-variable { color: #008000; } .hljs-keyword, .hljs-selector-tag, .hljs-built_in, .hljs-name, .hljs-tag { color: #00f; } .hljs-string, .hljs-title, .hljs-section, .hljs-attribute, .hljs-literal, .hljs-template-tag, .hljs-template-variable, .hljs-type, .hljs-addition { color: #a31515; } .hljs-deletion, .hljs-selector-attr, .hljs-selector-pseudo, .hljs-meta { color: #2b91af; } .hljs-doctag { color: #808080; } .hljs-attr { color: #f00; } .hljs-symbol, .hljs-bullet, .hljs-link { color: #00b0e8; } .hljs-emphasis { font-style: italic; } .hljs-strong { font-weight: bold; } ================================================ FILE: site-defaults/web/skel.htt ================================================ <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title><?title?> - Forum
================================================ FILE: site-defaults/web/static/css/dfeed.css ================================================ /* * DFeed Forum Stylesheet * * Forum-specific styles that work alongside style.css (base styles). * The first section contains overrides for base styles. */ /*************** Base style adjustments ***************/ table, table th { border-style: none; } div#content { line-height: inherit; text-align: left; min-height: 0; } table th, table caption { text-align: center; } table td { text-align: left; } table td, table th, table caption { vertical-align: inherit; padding: 0; } h1 { margin-bottom: 0.5em; } div#footernav { clear: none; } div#copyright { margin-top: 1em; } pre { background: inherit; border: none; padding: 0; border-radius: 0; } table td { border-bottom: none; } table td:not(:last-child), table th:not(:last-child) { padding-right: 0; } /* Navigation menu */ .subnav, .subnav-helper { width: 7em; } .subnav > ul > li { margin-top: 0.6em; } .subnav + #content { margin-left: 10em; } /*************** Navigation toggle ***************/ body.navhidden #top, body.navhidden .subnav, body.navhidden .subnav-helper { display: none; } body.navhidden #content { margin-left: 0; } /*************** General layout ***************/ #top { position: relative; } #tools { overflow: hidden; } #forum-tools-left { float: left; white-space: pre; max-width: 100%; overflow: hidden; text-overflow: ellipsis; } #forum-tools-right { float: right; } #content { overflow: hidden; } #content > h1 { display: none; } /*************** General rules ***************/ #forum-content > table { position: relative; } #forum-content table { width: 100%; border-spacing: 0; border-collapse: collapse; } #forum-content table tr.table-fixed-dummy, #forum-content table tr.table-fixed-dummy td { margin : 0 !important; padding: 0 !important; border : 0 !important; } #forum-content input[type=submit] { margin: 0; /* for Chrome */ } .temphide { display: none !important; } .nowrap { white-space: nowrap; } .avoid-wrap { display: inline-block; } span.truncated { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: top; } a span.truncated { text-decoration: underline; } div.truncated { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; } a.secretlink, a.secretlink:hover, a.secretlink:active, a.secretlink:visited { color: inherit; text-decoration: none; } /* A forum-table has a gray header and borders */ .forum-table { border: 2px solid #E6E6E6; table-layout: fixed; } .forum-table > tbody > tr > th { background-color: #E6E6E6; } .forum-table > tbody > tr.subheader > th { background-color: #F5F5F5; } .forum-table > tbody > tr > td, .forum-table > tbody > tr > th { padding: 0.33em; border: 1px solid #E6E6E6; overflow: hidden; } .number-column { text-align: center; } .forum-postsummary-author { color: #777; } a.forum-postsummary-author { color: inherit; text-decoration: none; } a.forum-postsummary-author:hover { text-decoration: underline; } #forum-content a img { border: none; } img.post-gravatar { display: block; } /* Read/unread messages */ .forum-unread, .forum-unread:visited { font-weight: bold; } .forum-read, .forum-read:visited { font-weight: normal; } .forum-unread:visited, .forum-read:visited { color: #723D39; } /* Use a thinner font for the split-mode index */ .viewmode-horizontal-split { font-family: 'Open Sans', Tahoma, 'Deja Vu', 'Bitstream Vera Sans', sans-serif; } /* "Create thread" button */ .header-tools { position: absolute; top: 3px; right: 3px; } .header-tools .img { display: none; margin: 4px; } /* Pager */ .pager-row { padding: 0 !important; } .pager { font-weight: normal; display: flex; justify-content: space-between; align-items: center; padding: 5px 0.33em; } .viewmode-narrow-index .pager { padding: 5px 0; } .pager-left { flex: 0 1 auto; margin-right: 5px; text-align: left; white-space: nowrap; } .pager-numbers { flex: 1 1 auto; margin: 0 5px; text-align: center; } .pager-right { flex: 0 1 auto; margin-left: 5px; text-align: right; white-space: nowrap; } .disabled-link { color: #999; } /* Forms */ .forum-form input, .forum-form textarea { max-width: 100%; box-sizing: border-box; } .forum-form textarea { /* We are working relative to the default UA font-size */ font-size: 120%; } .post-form > label, .forum-form input[type=submit] { margin-top: 0.75em !important; display: block; } .post-form textarea { display: block; } .post-form input[type=submit] { display: inline-block; } .form-error { border: 2px dotted red; color: red; padding: 0.75em; margin-bottom: 0.75em; } .forum-form .form-error input[type=submit], .forum-form .forum-notice input[type=submit] { margin: 0 !important; } .form-error .lint-description { color: #333; } /* Notice */ .forum-notice { border: 2px solid #E0E0A0; background-color: #FFFFD8; padding: 0.5em; margin-bottom: 1em; text-align: center; } /* Footer */ div#footernav { margin-top: 2em; text-align: center; } div#footernav a { white-space: nowrap; } /*************** Discussion index ***************/ #forum-index-header { margin: 0 0.5em 0.5em 0.5em; } #forum-index > tbody > tr > td:nth-child(even) { background-color: #FCFCFC; } #forum-index > tbody > tr > td:nth-child(1) { width: 60%; } #forum-index > tbody > tr > td:nth-child(2) { width: 40%; } #forum-index > tbody > tr > td:nth-child(3) { width: 5em; } #forum-index > tbody > tr > td:nth-child(4) { width: 5em; } #forum-index > tbody > tr > td:nth-child(5) { width: 6.2em; } .forum-index-description { color: #777; line-height: 1.2em; height: 2.4em; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; } .forum-index-description, .forum-index-col-lastpost { } .forum-postsummary-time { display: block; text-align: right; } #forum-index .focused { outline: 1px solid #F99 !important; background-color: #FEE; } #forum-index .focused > td { background: none !important; } /*************** Group index ***************/ #group-index > tbody > tr > td:nth-child(even) { background-color: #FCFCFC; } #group-index > tbody > tr > td:nth-child(1) { width: 75%; } #group-index > tbody > tr > td:nth-child(2) { width: 25%; } #group-index > tbody > tr > td:nth-child(3) { width: 5.5em; } .forum-postsummary-time { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .group-index-col-first { width: 70%; } .group-index-col-last { text-align: right; } #group-index .focused { outline: 1px solid #F99 !important; background-color: #FEE; } #group-index .focused > td { background: none !important; } #group-index a.forum-postsummary-gravatar { float: left; margin-right: 0.5em; display: block; } #group-index a.forum-postsummary-gravatar img.post-gravatar { width : 2.2rem; height: 2.2rem; border-width: 0.3rem; margin: 0; overflow: hidden; font-size: 6pt; text-align: center; } /*************** Thread view, individual posts ***************/ /* Thread overview */ #thread-overview { margin-bottom: 0.75em; } #thread-overview .group-index-header > th { overflow: visible; font-weight: normal; text-align: left; } #thread-overview .group-index-header > th > a { color: black; } #thread-overview .group-index-header > th > a:hover { text-decoration: underline; } .thread-overview-pager { float: right; position: relative; } .thread-overview-pager-expanded { position: absolute; top: 100%; z-index: 5; white-space: pre; right: 0; background-color: #E6E6E6; padding: 0.5em; border: 2px solid #CCC; box-shadow: 0 0 10px rgba(128,128,128,0.25); } .thread-overview-pager-pageno { width: 4em; } /* Thread overview - expandos */ body.have-javascript .forum-expand-container .forum-expand-toggle::after { content: " \f0d7"; /* caret down */ font-family: FontAwesome; } body.have-javascript .forum-expand-container.open#thread-overview th > a.forum-expand-toggle::after, body.have-javascript .forum-expand-container.open.thread-overview-pager .forum-expand-toggle::after { content: " \f0d8"; /* caret up */ } body.have-javascript .forum-expand-container .forum-expand-content, .thread-overview-pager-expanded { display: none; } body.have-javascript .forum-expand-container .forum-expand-toggle, body.have-javascript .forum-expand-container.open#thread-overview > tbody > tr.forum-expand-content, body.have-javascript .forum-expand-container.open.thread-overview-pager .forum-expand-content { display: block; } /* Posts */ #thread-posts > .post-wrapper:first-child > .post { margin-top: 0; } .post-wrapper { position: relative; } .post { margin-top: 0.75em; } .post > tbody > tr > td { vertical-align: top; padding: 0.75em; } .post > tbody > tr > td:nth-child(1) { width: 10em; } /* Header */ .post-header > th { text-align: left; } .post-header a { text-decoration: none; color: inherit; } .post-header a:hover { text-decoration: underline; } .post-time { float: right; font-weight: normal; } /* Info */ .post-info { width: 20%; background-color: #F5F5F5; font-size: 85%; word-wrap: break-word; } .post-author { font-weight: bold; font-size: 130%; } #forum-content a img.post-gravatar { margin: 0.35em; border: 5px solid #E6E6E6; } .post-info > hr { border: 1px solid #E6E6E6; border-bottom: none; margin: 0.5em 0; } .post-info-bit { margin-bottom: 1em; } ul.post-info-parts { margin: 0; padding-left: 0; list-style-position: inside; } .post-actions { position: absolute; bottom: 1em; } .actionlink { display: block; white-space: pre; } .actionlink img { vertical-align: middle; margin-right: 5px; } /* Horizontal info (responsive view) */ tr.mini-post-info-cell > td { padding: 0 !important; } /* Body */ .post-error { color: red; } .post-text { margin: 0; font-family: Consolas, Lucida Console, Menlo, monospace; font-size: 1em; white-space: pre; /* old browser compat */ white-space: pre-wrap; word-wrap: break-word; } .forum-quote, .forum-signature { color: #666; } .forum-quote { display: block; border-left: 4px solid #E6E6E6; padding-left: 4px; /* HACK: 0.6 (instead of 0.5) fixes Chrome without affecting Firefox. */ border-left: 0.6ch solid #E6E6E6; padding-left: 1.5ch; } .forum-quote-prefix { /* Make it so the quote prefix is invisible and has zero width, but is still copied when the user selects and copies text from the web page. https://thejh.net/misc/website-terminal-copy-paste */ position: absolute; left: -1000px; top: -1000px; } .forcewrap { word-break: break-all; display: inline-block; } a > .forcewrap { text-decoration: underline; } /* Body (Markdown) */ .post-text.markdown { line-height: normal; white-space: normal; margin: -15px 0; font-family: "Roboto Slab", sans-serif; font-size: 16px; overflow-wrap: anywhere; } .post-text.markdown pre { font-family: Consolas, Lucida Console, Menlo, monospace; font-size: inherit; background-color: #F5F5F5; outline: 1px solid #E6E6E6; padding: 4px; } .post-text.markdown code { font-family: Consolas, Lucida Console, Menlo, monospace; background-color: #F5F5F5; outline: 1px solid #E6E6E6; padding: 0 1px; margin: 0 1px; } .post-text.markdown pre code { font-family: unset; background-color: unset; outline: unset; padding: 0; margin: 0; tab-size: 4; -moz-tab-size: 4; } #forum-content .post-text.markdown table { border-spacing: initial; margin: 16px 0; } #forum-content .post-text.markdown th { background-color: #F5F5F5; } #forum-content .post-text.markdown th, #forum-content .post-text.markdown td { border: 1px solid #E6E6E6; padding: 0.1em 0.3em; } #forum-content .post-text.markdown h1, #forum-content .post-text.markdown h2, #forum-content .post-text.markdown h3, #forum-content .post-text.markdown h4, #forum-content .post-text.markdown h5, #forum-content .post-text.markdown h6, #forum-content .post-text.markdown h7 { /* TODO: replace with "all: revert;" when that becomes more available */ margin: 16px 0; font-weight: bold; } #forum-content .post-text.markdown img { max-width: 100%; } /* Threading */ .post-nester { border-collapse: collapse; width: 100%; } .post-nester > tbody > tr > td { padding: 0; } .post-nester-bar { position: relative; width: 10px; vertical-align: top; background: url('/images/nestgrad.png'); } .post-nester-bar a { display: block; position: absolute; width: 10px; top: 0; bottom: 0; border-bottom: 1px solid #E6E6E6; } /* Pager */ .post-pager { margin-top: 10px; } /* Keyboard navigation */ .post.focused { border-left-color: #F99; } .linknav:after { content: "[" attr(data-num) "]"; vertical-align: top; font-size: 75%; /* position: absolute; margin-left: -1.5em; display: inline-block; background-color: #FEA; border: 1px solid #DC8; */ } /*************** Threaded group view ***************/ .forum-table > tbody > tr > td.group-threads-cell { padding: 2px 0 0 0; } #group-index-threaded > tbody > tr > th { width: 100%; } .group-threads table { table-layout: fixed; } .group-threads > table { width: 100%; } .group-threads > table > tbody > tr > td { border-bottom: none; padding-bottom: 1px; padding-left: 2px !important; padding-right: 2px; } .thread-start { border: 2px solid #E6E6E6; margin-bottom: 1px; } .thread-start > tbody > tr > th { background-color: #E6E6E6; } .thread-start > tbody > tr > td > div { padding-right: 2px; } .thread-start > tbody > tr:nth-child(odd) { background-color: #F5F5F5; } .thread-start > tbody > tr:nth-child(even) { background-color: white; } .thread-start td { text-align: left; } .thread-post-row div { overflow: hidden; } .thread-post-time { float: right; } #thread-index .focused > td > div, #group-index-threaded .focused > td > div, #group-posts-vsplit .focused { outline: 1px solid #F99 !important; background-color: #FEE; } #thread-index .selected > td > div, #group-index-threaded .selected > td > div, #group-posts-vsplit .selected { background-color: #D55; color: white; } #thread-index .selected a, #group-index-threaded .selected a, #group-posts-vsplit .selected a { color: inherit; } #thread-index .selected .thread-post-time span, #group-index-threaded .selected .thread-post-time span, #group-posts-vsplit .selected .thread-post-time span { color: inherit !important; } #thread-index { margin-top: 20px; } table.viewmode-threaded > tbody > tr > th { width: 100%; } /*************** Horizontal split view ***************/ #group-split { table-layout: fixed; } #group-split-list { width: 25em; vertical-align: top; } #group-split-list > div { position: relative; } #group-split #group-split-message { padding-left: 4px; vertical-align: top; overflow: auto; } #group-split-message .nojs { margin-top: 10px; } .viewmode-horizontal-split .group-threads { height: 400px; overflow-x: hidden; overflow-y: auto; } /* The post itself */ .split-post { table-layout: fixed; } .split-post .post-body { overflow: auto; padding: 0; border-left: none; border-right: none; border-bottom: none; display: block; } .split-post .post-text { margin: 10px; } .split-post { } /* Horizontal info (full view) */ .post-info-avatar { padding-left: 5px; padding-bottom: 2px; border-right: none !important; width: 1px; } .horizontal-post-info { border-left: none !important; background-color: #F5F5F5; font-size: 0.9em; } .horizontal-post-info-name { font-weight: bold; white-space: pre; } table td.horizontal-post-info-name { text-align: right; padding: 0 10px; width: 6em; } .horizontal-post-info-value .forum-unread { /* this just looks confusing and distracting */ font-weight: inherit; } .post-info-actions { vertical-align: bottom; text-align: right; } /* Layout of scrollable section */ table.post-layout { width: 100%; height: 100%; } table.post-layout > tbody > tr > td { vertical-align: top; } tr.post-layout-header, tr.post-layout-footer { height: 1px; /* force shrink to fit */ } /* Horizontal info (responsive view) */ table.mini-post-info { display: none; } table.mini-post-info a img.post-gravatar { border-width: 3px !important; margin: 0 0.5em; } table.mini-post-info .post-info-actions { padding: 0.5em; } table.mini-post-info > tbody > tr > td:first-child { width: 1px; /* force shrink to fit */ } table.mini-post-info { background-color: #F5F5F5; } .split-post table.mini-post-info { border-bottom: 2px solid #E6E6E6; } /* Footer (responsive view only) */ table.post-footer { background-color: #F5F5F5; border-top: 2px solid #E6E6E6; display: none; } table.post-footer > tbody > tr > td { margin: 0; } td.post-footer-info { padding: 0.5em 0.75em; } td.post-footer-nav { background-color: #E6E6E6; text-align: center; } td.post-footer-nav a { display: block; height: 100%; padding: 1em; margin: 0; } /* Placeholder */ .group-split-message-none { text-align: center; vertical-align: middle !important; overflow: hidden; width: 100%; } .keyboardhelp { width: auto !important; margin: 0 auto; } .keyboardhelp td { padding-top: 10px; } .keyboardhelp td:first-child { text-align: right; padding-right: 10px; } .keyboardhelp td:last-child { text-align: left; } .keyboardhelp th { display: none; } kbd { /* stolen from http://en.wikipedia.org/wiki/Template:Key_press */ border: 1px solid; border-color: #ddd #bbb #bbb #ddd; border-bottom-width: 2px; -moz-border-radius: 3px; -webkit-border-radius: 3px; border-radius: 3px; background-color: #f9f9f9; padding: 1px 3px; font-family: inherit; font-size: 0.9em; white-space: nowrap; display: inline-block; text-align: center; min-width: 1em; margin: 0 1px; background-color: white; font-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"; } .keyboardhelp-popup { position: fixed; background-color: #F5F5F5; width: 35em; height: 20em; left: 50%; top: 50%; margin-left: -20em; margin-top: -12.5em; border: 0.25em solid #CCC; padding: 2.5em; box-shadow: 0px 0px 20px #000; } .keyboardhelp-popup .keyboardhelp th { display: table-cell; } .keyboardhelp-popup-closetext { text-align: center; position: absolute; width: 100%; bottom: 1.5em; left: 0; color: #999; font-size: 0.8em; } /*************** Vertical split view ***************/ #group-posts-vsplit { font-size: 90%; } #group-index-vsplit > tbody > tr > th:first-child { width: 100%; } #group-posts-vsplit > tbody > tr > td:nth-child(1) { width: 70%; } #group-posts-vsplit > tbody > tr > td:nth-child(2) { width: 30%; } #group-posts-vsplit > tbody > tr > td:nth-child(3) { width: 8.5em; } #group-posts-vsplit > tbody > tr > td { white-space: pre; overflow: hidden; text-overflow: ellipsis; } #group-index-vsplit div.group-threads { height: 30em; overflow: auto; } #group-vsplit .post-header { display: none; } /*************** Posting ***************/ #postform-captcha { margin-top: 10px; } .formdoc #content { /* display: inline-block; margin-left: 0 !important; */ } #postform { display: block; max-width: 50em; } #postform textarea, #postform-subject { box-sizing: border-box; width: 100%; } .postform-action-left { float: left; } .postform-action-left label { padding-left: 1em; } .postform-action-left input[type=checkbox] { position: relative; top: 0.1em; } .postform-action-right { float: right; } .autosave-notice { margin-left: 1em; } /*************** Login ***************/ .forum-form.loginform { width: 320px; margin: 70px auto 0 auto; } .forum-form.loginform input[type=checkbox] { margin-top: 1em; } .loginform-error { margin: 0 !important; } .loginform-cell { padding: 30px 80px !important; } .loginform-info { text-align: center; } /*************** Misc views ***************/ .forum-no-data { text-align: center; } .forum-table-message { text-align: center; padding: 50px 0 !important; } .forum-table-message pre { text-align: left; font-size: 8pt; margin: 50px 30px 0 30px; } /*************** Settings ***************/ table#subscriptions td:first-child { width: 100%; } select[name=trigger-content-groups] { } #trigger-content input[type=checkbox] { position: relative; top: 0.1em; } table#account-settings input { width: 100%; } table#account-settings td { padding: 0.25em 0; } #settings-form tr:hover { background-color: #f5f5f5; } hr { border: 0.15em solid #CCC; } /*************** Responsive overrides ***************/ body { -webkit-text-size-adjust: none; -moz-text-size-adjust: none; -ms-text-size-adjust: none; text-size-adjust: none; } @media only screen and (max-width: 66em) { /* Narrow layout stage 1 */ .subnav { width: auto; } .subnav > ul { -moz-column-width: 9em; -webkit-column-width: 9em; column-width: 9em; -webkit-column-gap: 1em; -moz-column-gap: 1em; column-gap: 1em; max-width: 29em; /* = 3 * column-width + 2 * column-gap */ } .subnav > ul > li { /* avoid column breaks inside li */ -webkit-column-break-inside: avoid; /* Chrome, Safari, Opera */ page-break-inside: avoid; /* Firefox */ break-inside: avoid; /* IE 10+ */ } .subnav > ul > li:first-child { margin-top: 0; } .subnav + #content { margin-left: 0; } } @media only screen and (max-width: 60em) { .viewmode-horizontal-split .thread-post-time { display: none; } .viewmode-horizontal-split .pager-numbers { display: none; } .viewmode-horizontal-split .header-tools .btn { display: none; } .viewmode-horizontal-split .header-tools .img { display: inline-block; } body #group-split-list { width: 15em; } } @media only screen and (max-width: 50em) { /* remove padding around content */ body > .container { padding-left: 0; padding-right: 0; } /* ... but add it back to non-table top-level elements */ #tools, #forum-index-header { margin-left: 1em; margin-right: 1em; } .subnav { margin-left: 0; margin-right: 0; } .forum-form, #settings-form, #subscription-form, #postform { max-width: 100%; } #forum-index > tbody > tr > *:nth-child(3), #forum-index > tbody > tr > *:nth-child(4), #forum-index > tbody > tr > *:nth-child(5) { display: none; } div#footernav, div#copyright { display: none; } #content { padding-bottom: 0; } .horizontal-post-info { display: none; } .split-post table.mini-post-info, table.post-footer { display: table; } } @media only screen and (max-width: 30em) { #forum-content .post > tbody > tr.mini-post-info-cell > td > table.mini-post-info { display: table !important; } #forum-content .post > tbody > tr > td.post-info, #forum-content .post > tbody > tr:first-child > td:first-child { display: none; } #forum-content .post > tbody > tr:first-child > td:nth-child(2) { width: 100%; } #group-posts-vsplit > tbody > tr > td:nth-child(3) { width: 0; } #group-index > tbody > tr > td:nth-child(2) { width: 7em; } #group-index > tbody > tr > td:nth-child(3) { width: 4.5em; white-space: pre; padding: 0; } #group-index a.forum-postsummary-gravatar { display: none; } } /*************** Frame view ***************/ body.frame { font-size: 90%; } body.frame:before { display: none; } body.frame > div, body.frame > div.container > *, body.frame div#tools { display: none; } body.frame > div.container, body.frame div#content { display: block; margin: 0; padding: 0; } body.frame div#content { display: block; margin: 0; max-width: none; border: 0; padding: 0; background: none; min-height: 0; font-size: 12px; line-height: 16px; } body.frame .forum-table > tbody { display: block; max-height: 162px; overflow-y: auto; } body.frame .forum-table > tbody, body.frame .forum-table > tbody > tr, body.frame .forum-table > tbody > tr > td { /* HACK */ width: 249px; } body.frame .forum-table > thead > tr > th, body.frame .forum-table > tbody > tr > td, body.frame .forum-table { border: 1px solid #CCC; background: none; } body.frame .forum-table > thead > tr > th, body.frame .forum-table > tbody > tr > td { border-left: 0; border-right: 0; border-top: 0; } body.frame .forum-table > thead > tr > th { font-size: 14px; /* copy Twitter CSS */ text-align: left; padding: 10px; } body.frame .forum-table > thead > tr > th .feed-icon { display: block; float: right; } body.frame #forum-content table.forum-table { -moz-border-radius: 4px; -webkit-border-radius: 4px; border-radius: 4px; border-collapse: separate; background-color: white; color: #999; } body.frame a { color: #333; text-decoration: none; } body.frame .forum-postsummary-subject { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: top; width: 180px; } body.frame .forum-postsummary-author { color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: inline-block; vertical-align: top; max-width: 100px; } body.frame div.forum-postsummary-info { display: block; float: right; color: #999; } body.frame .forum-unread { font-weight: 600; } body.frame .forum-postsummary-gravatar { float: left; } body.frame .post-gravatar { /* border: 2px solid #444 !important; margin: -2px 5px -2px 0; */ border: 0 !important; margin: 0 5px 0 0 !important; width: 32px; height: 32px; display: block; -moz-border-radius: 5px; -webkit-border-radius: 5px; border-radius: 5px; } .viewmode-narrow-index a { text-decoration-line: none; } .viewmode-narrow-index a * { text-decoration-line: inherit; } .viewmode-narrow-index a:hover { text-decoration-line: underline; } .viewmode-narrow-index a:hover * { text-decoration-line: inherit; } .viewmode-narrow-index .group-index-header { display: flex; align-items: center; line-height: 1.5em; height: 4em; border-bottom: 1px solid lightgray; justify-content: space-between; } @media only screen and (max-width: 50em) { .viewmode-narrow-index { padding: 0 10px; } } .viewmode-narrow-index .group-index-header .title { font-weight: bold; } .viewmode-narrow-index .group-index-header .create-thread { text-align: right; } .viewmode-narrow-index .group-index-header .create-thread img { height: 12px; } .viewmode-narrow-index .group-index-header a.button { display: inline-block; padding: 2px 5px; color: initial; background-color: rgb(240, 240, 240); text-decoration: none; border-radius: 5px; border: 1px solid lightgray; } .viewmode-narrow-index .group-index-header a.button:hover { background-color: rgb(224, 224, 224); text-decoration: none; } .viewmode-narrow-index .thread { display: grid; grid-template-columns: auto minmax(0, 1fr) 10ch 25ch; grid-template-rows: auto; grid-template-areas: "firstpost-author-image thread-title replies lastpost" "firstpost-author-image firstpost replies lastpost"; border-bottom: 1px solid lightgray; padding: 5px 0; } .viewmode-narrow-index .firstpost-time { display: none; } @media only screen and (max-width: 50em) { .viewmode-narrow-index .thread { grid-template-columns: auto minmax(0, 0.925fr) minmax(0, 1fr) minmax(0, 1.075fr); grid-template-rows: auto; grid-template-areas: "firstpost-author-image thread-title thread-title thread-title" "firstpost-author-image firstpost replies lastpost"; } .viewmode-narrow-index .thread .firstpost-time { display: block; } } .viewmode-narrow-index .thread > * { align-self: center; } .viewmode-narrow-index .thread .title { grid-area: thread-title; margin-bottom: .25em; font-size: 1em; } .viewmode-narrow-index .thread :not(:first-child) { font-size: small; } .viewmode-narrow-index .thread .firstpost { grid-area: firstpost; } .viewmode-narrow-index .thread .firstpost-time .short { display: none; } .viewmode-narrow-index .thread .firstpost-time .long { display: inline-block; } .viewmode-narrow-index .thread .firstpost-author-image { grid-area: firstpost-author-image; width: 3em; height: 3em; border-radius: 50%; margin-top: 0.75ch; margin-right: 10px; align-self: start; } .viewmode-narrow-index .thread .firstpost-author-name { grid-area: firstpost-author-name; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: gray; } .viewmode-narrow-index .thread .replies { grid-area: replies; } .viewmode-narrow-index .thread .replies-total { text-align: center; } .viewmode-narrow-index .thread .replies-total .short { display: none; } .viewmode-narrow-index .thread .replies-total .long { display: inline; } .viewmode-narrow-index .thread .replies-new { text-align: center; } .viewmode-narrow-index .thread .replies-new .short { display: none; } .viewmode-narrow-index .thread .replies-new .long { display: inline; } @media only screen and (max-width: 40em) { .viewmode-narrow-index .thread .replies-total .short { display: inline; } .viewmode-narrow-index .thread .replies-total .long { display: none; } .viewmode-narrow-index .thread .replies-new .short { display: inline; } .viewmode-narrow-index .thread .replies-new .long { display: none; } } .viewmode-narrow-index .thread .lastpost { grid-area: lastpost; } .viewmode-narrow-index .thread .lastpost-time { text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .viewmode-narrow-index .thread .lastpost-time .short { display: none; } .viewmode-narrow-index .thread .lastpost-time .long { display: inline; } @media only screen and (max-width: 50em) { .viewmode-narrow-index .thread .firstpost-time .short { display: inline; } .viewmode-narrow-index .thread .firstpost-time .long { display: none; } .viewmode-narrow-index .thread .lastpost-time .short { display: inline; } .viewmode-narrow-index .thread .lastpost-time .long { display: none; } } .viewmode-narrow-index .thread .lastpost-time .last { display: none; } .viewmode-narrow-index .thread .lastpost-time .last-post { display: inline; } @media only screen and (max-width: 40em) { .viewmode-narrow-index .thread .lastpost-time .last { display: inline; } .viewmode-narrow-index .thread .lastpost-time .last-post { display: none; } } .viewmode-narrow-index .thread .lastpost { grid-area: lastpost; } .viewmode-narrow-index .thread .lastpost-author { text-align: right; display: flex; align-items: center; justify-content: right; color: gray; } .viewmode-narrow-index .thread .lastpost-author-image { width: 1.5em; height: 1.5em; border-radius: 50%; margin-right: 0.5ch; } .viewmode-narrow-index .thread .lastpost-author-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } /*************** User Profile ***************/ .user-profile { margin-bottom: 1em; } .user-profile-header { display: flex; align-items: flex-start; gap: 1.5em; } .user-profile-info h1 { margin-top: 0; margin-bottom: 0.5em; } .user-profile-actions { margin-left: 0.35em; margin-right: 0.35em; } .user-profile-stats { margin-bottom: 1em; } .user-profile-stats td:first-child { color: #666; padding-right: 1em; } .user-profile-stats td:last-child { font-weight: 500; } .user-profile-seealso { margin-top: 1.5em; } .user-profile-seealso h2 { margin-bottom: 0.5em; } .user-profile-seealso a.forum-postsummary-gravatar { float: left; margin-right: 0.5em; display: block; } #forum-content .user-profile-seealso a.forum-postsummary-gravatar img.post-gravatar { width: 2.2rem; height: 2.2rem; border-width: 0.3rem; margin: 0; overflow: hidden; } .seealso-lastseen { text-align: center; white-space: nowrap; } .user-profile-posts { margin-top: 1.5em; } .user-profile-posts h2 { margin-bottom: 0.5em; } .user-profile-posts .forum-table { width: 100%; } .user-profile-posts .forum-table th { text-align: left; padding: 0.3em 0.5em; background: #f5f5f5; border-bottom: 1px solid #ddd; } .user-profile-posts .forum-table td { padding: 0.3em 0.5em; border-bottom: 1px solid #eee; } ================================================ FILE: site-defaults/web/static/css/style.css ================================================ /* * DFeed Default Stylesheet * * Provides basic styling for a default DFeed installation. * Site-specific deployments can override this with their own style.css. */ /* ============== Base Typography ============== */ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; font-size: 15px; line-height: 1.5; color: #333; background: #fff; margin: 0; padding: 0; } pre, code, .tt { font-family: Consolas, "Liberation Mono", Menlo, Courier, monospace; font-size: 14px; } a { color: #0066cc; text-decoration: none; } a:hover { text-decoration: underline; } a:visited { color: #551a8b; } h1, h2, h3, h4, h5, h6 { margin: 0.5em 0; font-weight: 600; } h1 { font-size: 1.75em; } h2 { font-size: 1.5em; } h3 { font-size: 1.25em; } /* ============== Top Header Bar ============== */ #top { background: #4a76a8; border-bottom: 1px solid #3a5a88; padding: 0; } .site-header { max-width: 76em; margin: 0 auto; padding: 0.5em 1em; display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5em; } .site-header .logo a { color: white; font-size: 1.5em; font-weight: bold; text-decoration: none; } .site-header .logo a:hover { text-decoration: none; opacity: 0.9; } /* ============== Search Box ============== */ #search-box { display: flex; gap: 0.25em; } #search-box input, #search-box select, #search-box button { padding: 0.4em 0.6em; border: 1px solid #ccc; border-radius: 3px; font-size: 14px; } #search-box input#q { width: 200px; } #search-box button { background: #f5f5f5; color: #333; cursor: pointer; } #search-box button:hover { background: #e6e6e6; color: #333; } /* ============== Main Layout ============== */ .container { max-width: 76em; margin: 0 auto; padding: 1em; display: flex; gap: 2em; } /* ============== Side Navigation ============== */ .subnav { flex: 0 0 180px; min-width: 0; } .subnav .head h2 { font-size: 1.1em; margin: 0 0 0.25em 0; } .subnav .head p { margin: 0 0 1em 0; } .subnav ul { list-style: none; margin: 0; padding: 0; } .subnav > ul > li { margin-bottom: 1em; } .subnav > ul > li > ul { margin-left: 0.5em; padding-left: 0.5em; border-left: 2px solid #e6e6e6; } .subnav li { margin: 0.25em 0; } .subnav li.active > a { font-weight: bold; } /* ============== Content Area ============== */ #content { flex: 1; min-width: 0; overflow: hidden; } #forum-content { margin-bottom: 1.5em; } /* ============== Footer ============== */ #footernav { margin-top: 2em; padding-top: 1em; border-top: 1px solid #e6e6e6; color: #666; font-size: 0.9em; } #copyright { margin-top: 0.5em; color: #999; font-size: 0.85em; } .smallprint { font-size: 0.9em; color: #666; } /* ============== Tables ============== */ table { border-collapse: collapse; } table th, table td { padding: 0.5em; text-align: left; vertical-align: top; } /* ============== Forms ============== */ input[type="text"], input[type="email"], input[type="password"], input[type="search"], textarea, select { padding: 0.5em; border: 1px solid #ccc; border-radius: 3px; font-family: inherit; font-size: inherit; } input[type="text"]:focus, input[type="email"]:focus, input[type="password"]:focus, input[type="search"]:focus, textarea:focus, select:focus { outline: none; border-color: #4a76a8; box-shadow: 0 0 3px rgba(74, 118, 168, 0.3); } button, input[type="submit"], input[type="button"] { padding: 0.5em 1em; background: #4a76a8; color: white; border: none; border-radius: 3px; cursor: pointer; font-family: inherit; font-size: inherit; } button:hover, input[type="submit"]:hover, input[type="button"]:hover { background: #3a5a88; } /* ============== Notices and Errors ============== */ .forum-notice { padding: 0.75em 1em; margin: 0.5em 0; background: #fff3cd; border: 1px solid #ffc107; border-radius: 3px; color: #856404; } .form-error { padding: 0.75em 1em; margin: 0.5em 0; background: #f8d7da; border: 1px solid #f5c6cb; border-radius: 3px; color: #721c24; } /* ============== Utility Classes ============== */ .tip { font-size: 0.85em; color: #666; margin: 0.5em 0; } /* ============== Tools Bar ============== */ #tools { overflow: hidden; margin-bottom: 1em; } #forum-tools-left { float: left; } #forum-tools-right { float: right; } #forum-tools-right .tip { display: inline; margin: 0 0 0 1em; } /* ============== Responsive ============== */ @media (max-width: 768px) { .container { flex-direction: column; gap: 1em; } .subnav { flex: none; width: 100%; } .subnav > ul { display: flex; flex-wrap: wrap; gap: 0.5em 1em; } .subnav > ul > li { margin: 0; } .subnav > ul > li > ul { display: none; } .site-header { flex-direction: column; text-align: center; } #search-box { width: 100%; justify-content: center; } #search-box input#q { flex: 1; max-width: 300px; } } ================================================ FILE: site-defaults/web/static/js/dfeed.js ================================================ function _(s) { if (s in localization) return localization[s]; console.log('Unlocalized string: ' + JSON.stringify(s)); return s; } $(document).ready(function() { if (enableKeyNav) { // Chrome does not pass Ctrl+keys to keypress - but in many // other browsers keydown does not repeat $(document).keydown (onKeyDown ); $(document).keypress(onKeyPress); } if ($('#group-split').length || $('#group-vsplit').length) initSplitView(); if ($('#postform').length) initPosting(); if ('localStorage' in window && localStorage.getItem('usingKeyNav')) { initKeyNav(); localStorage.removeItem('usingKeyNav'); } if ($('#thread-posts').length) { initThreadUrlFixer(); } $('.forum-expand-toggle').click(function(e) { var container = $(this).closest('.forum-expand-container'); container.toggleClass('open'); return false; }); syntaxHighlight($(document)); }); // ************************************************************************** // Thread view var updateUrlOnFocus = false; function initThreadUrlFixer() { if ('history' in window && 'replaceState' in window.history) { updateUrlOnFocus = true; if (document.location.hash.length) { var el = document.getElementById(document.location.hash.substr(1)); if (el) { $post = $(el).filter('.post'); if ($post.length) { // Chrome is not scrolling to the hash if // we call replaceState inside the load event. setTimeout(focusRow, 0, $post, FocusScroll.none, false); } } } $('.post').live('click', function() { if (!$(this).is('.focused')) focusRow($(this), FocusScroll.none, false); return true; }); } } // ************************************************************************** // Split view function initSplitView() { $('.postlink').live('click', function() { var path = $(this).attr('href'); return !selectMessage(path); }); $('tr.thread-post-row').live('mousedown', function(e) { if (e.which == 1) return selectRow($(this)); return true; }).css('cursor', 'default'); $(window).resize(onResize); updateSize(true); focusRow($('tr.thread-post-row').last(), FocusScroll.scroll, false); $(window).bind('popstate', onPopState); onPopState(); toolsTemplate = $('') .attr('href', 'javascript:toggleNav()') .text(_('Toggle navigation')) [0].outerHTML + ' ' + toolsTemplate; updateTools(); showNav(localStorage.getItem('navhidden') == 'true'); } function selectMessage(path) { var id = idFromPath(path); if (id && findInTree(path)) { window.history.replaceState(null, id, path); onPopState(); return true; } return false; } function idFromPath(path) { if (path.substr(0, 6) == '/post/') return path.substr(6); return null; } function findInTree(path) { var a = $('.group-threads').find('a[href="'+path+'"]'); if (a.length) return a.first().closest('tr.thread-post-row'); else return null; } function getPath() { var path = window.location.pathname; // Work around Opera bug? if (path.substr(0, 6) == '/post/') path = path.substr(0, 6) + path.substr(6).replace(/\//g, '%2F'); return path; } var currentRequest = null; var currentID = null; function onPopState() { var path = getPath(); var id = idFromPath(path); var $row = findInTree(path); if (id && id == currentID && $row.find('.forum-unread').length==0) return; else if (id && $row) { if (currentRequest) { currentRequest.abort(); currentRequest = null; } $('.group-threads .selected').removeClass('selected'); $row.addClass('selected'); focusRow($row, FocusScroll.withMargin, false); currentID = id; showText(_('Loading message')+'\n<'+id+'> ...'); //var resource = $('#group-vsplit').length ? '/vsplit-post/' : '/split-post/'; var resource = '/split-post/'; currentRequest = $.get(resource + id, function(result) { currentRequest = null; $row.find('.forum-unread').removeClass('forum-unread').addClass('forum-read'); showPost(result); }); currentRequest.error(function(jqXHR, textStatus, errorThrown) { currentRequest = null; showText('XHR ' + textStatus + (errorThrown ? ': ' + errorThrown : '')); }); } else { if (window.history.pushState) showHtml(keyboardHelp); else showHtml(_('Your browser does not support HTML5 pushState.')); } updateTools(); } function updateTools() { $('#forum-tools-right').html(toolsTemplate.replace(/__URL__/g, encodeURIComponent(document.location.href))); } function showPost(postHtml) { var $container = $('#group-split-message'); $container .html(postHtml) .removeClass('group-split-message-none'); updateSize(false); addLinkNavigation(); syntaxHighlight($container); } function showText(text) { $('#group-split-message') .addClass('group-split-message-none') .html( $('') .text(text) ); updateSize(false); } function showHtml(text) { $('#group-split-message') .html(text) .addClass('group-split-message-none'); } // ************************************************************************** // Navigation toggle function toggleNav() { var hidden = localStorage.getItem('navhidden') == 'true'; hidden = !hidden; localStorage.setItem('navhidden', hidden); showNav(hidden); } function showNav(hidden) { $('body').toggleClass('navhidden', hidden); updateSize(true); } // ************************************************************************** // Resizing // This *might* be possible with just CSS, but so far all my attempts failed. var resizeTimeout = null; function updateSize(resized) { resizeTimeout = null; var vertical = $('#group-vsplit').length; if (!resized && !vertical && $.browser.mozilla) { // Firefox speed hack var $outer = $('#group-split-message > *'); var $inner = $('.split-post .post-body'); var $posts = $('#group-split-list'); $outer.css('height', ''); $inner.height(100); $inner.height($posts.height() - ($outer.height() - 100)); return; } var $focused = $('.focused'); var wasFocusedInView = false; if ($focused.length) wasFocusedInView = isRowInView($focused); var $container = getSelectablesContainer(); if ($container.length) var containerScrollTop = $container.scrollTop(); var resizees = vertical ? [ { $outer : $('#group-vsplit-list > div'), $inner : $('.group-threads')}, { $outer : $('#group-split-message' ), $inner : $('.split-post .post-body, .group-split-message-none')}, ] : [ { $outer : $('#group-split-list > div'), $inner : $('.group-threads')}, { $outer : $('#group-split-message > *' ), $inner : $('.split-post .post-body')}, ] ; var verticalSplit = [0.35, 0.65]; for (var i in resizees) resizees[i].$outer.css('height', ''); var $bottommost = $('#content'); var totalWindowSpace = $(window).height(); function getFreeSpace() { var usedWindowSpace = $bottommost.offset().top + $bottommost.outerHeight(true); usedWindowSpace = Math.floor(usedWindowSpace); var freeWindowSpace = totalWindowSpace - usedWindowSpace; return freeWindowSpace - 1 /*pixel fraction*/ ; } function getFreeSpaceFor(fDoShow) { for (var i in resizees) if (fDoShow(i)) resizees[i].$outer.show(); else resizees[i].$outer.hide(); return getFreeSpace(); } var dummyHeight = 300; for (var i in resizees) resizees[i].$inner.height(dummyHeight); // Shrink content to a fixed height, so we can calculate how much space we have to grow. var growSpace = []; for (var i in resizees) growSpace.push(getFreeSpaceFor(function(j) { return i==j; })); // var growSpaceAll = getFreeSpaceFor(function(j) { return true; }); var growSpaceNone = getFreeSpaceFor(function(j) { return false; }); // var growSpaceMin = Math.min.apply(null, growSpace); // var growSpaceMax = Math.max.apply(null, growSpace); var heights = []; for (var i in resizees) heights.push(growSpaceNone - growSpace[i]); for (var i in resizees) resizees[i].$outer.show(); //var obj = {}; ['growSpace', 'heights', 'growSpaceAll', 'growSpaceNone', 'growSpaceMax', 'growSpaceMax'].forEach(function(n) { obj[n]=eval(n); }); console.log(JSON.stringify(obj)); for (var i in resizees) { var newHeight = dummyHeight; if (vertical) newHeight = growSpaceNone * verticalSplit[i] - heights[i] + dummyHeight; else newHeight += growSpace[i]; resizees[i].$inner.height(newHeight); //console.log(i, ':', newHeight); } if ($container.length) $container.scrollTop(containerScrollTop); if ($focused.length && wasFocusedInView) focusRow($focused, FocusScroll.withMargin, false); } function onResize() { if (resizeTimeout) clearTimeout(resizeTimeout); resizeTimeout = setTimeout(updateSize, 10, true); } // ************************************************************************** // Utility function nestedOffset(element, container) { if (element === document.body) element = window; if (element.offsetParent === container) return element.offsetTop; else if (element.offsetParent === container.offsetParent) return 0; else return element.offsetTop + nestedOffset(element.offsetParent, container); } function isInView($element, $container) { var containerTop = $container.scrollTop(); var containerHeight = $container.height(); var containerBottom = containerTop + containerHeight; var elemTop = nestedOffset($element[0], $container[0]); var elemBottom = elemTop + $element.height(); return elemTop > containerTop && elemBottom < containerBottom; } function scrollIntoView($element, $container, withMargin) { var containerTop = $container.scrollTop(); var containerHeight = $container.height(); var containerBottom = containerTop + containerHeight; //var elemTop = element.offsetTop; var elemTop = nestedOffset($element[0], $container[0]); var elemBottom = elemTop + $element.height(); var scrollMargin = withMargin ? containerHeight/4 : 10; if (elemTop < containerTop) { $container.scrollTop(Math.max(0, elemTop - scrollMargin)); //$container.scrollTo(elemTop, 200) } else if (elemBottom > containerBottom) { $container.scrollTop(elemBottom - containerHeight + scrollMargin); //$container.scrollTo(elemBottom - $container.height(), 200) } } function syntaxHighlight($root) { if (hljs === undefined) return; $root.find('.post-text.markdown pre code').each(function () { if (/\bhljs\b/.exec(this.className)) return; var match = /(?:^|\s)language-([^\s]*)(?:$|\s)/.exec(this.className); if (match) { var language = match[1]; try { this.innerHTML = hljs.highlight(this.textContent, { language : language, ignoreIllegals : true }).value; } catch (e) { console.log('Error highlighting', this, ':', e); } } }); } // ************************************************************************** // Keyboard navigation function isRowInView($row) { return isInView($row, getSelectablesContainer()); } var FocusScroll = { none:0, scroll:1, withMargin:2 }; function focusRow($row, focusScroll, byKeyboard) { $('.focused').removeClass('focused'); $row.addClass('focused'); if (focusScroll) scrollIntoView($row, getSelectablesContainer(), focusScroll == FocusScroll.withMargin); if (byKeyboard && $('#group-split').length == 0 && $('#group-vsplit').length == 0) addLinkNavigation(); if (updateUrlOnFocus) window.history.replaceState(null, '', $row.find('.permalink').attr('href')); } function selectRow(row) { return getSelectableLink(row)[0].click(); } function getSelectables() { if ($('#group-split').length || $('#group-vsplit').length) { return $('tr.thread-post-row'); } else if ($('#forum-index').length) { return $('#forum-index > tbody > tr.group-row'); } else if ($('#group-index').length) { return $('#group-index > tbody > tr.thread-row'); } else if ($('#group-index-threaded').length) { return $('#group-index-threaded tr.thread-post-row'); } else if ($('#thread-index').length) { return $('#thread-index tr.thread-post-row'); } else if ($('.post').length) { return $('.post'); } else { return []; } } function getSelectedPost() { if ($('.split-post').length) { return $('.split-post .post-text'); } else if ($('.focused.post').length) { return $('.focused.post .post-text'); } else { return null; } } function getSelectablesContainer() { if ($('#group-split').length || $('#group-vsplit').length) { return $('.group-threads'); } else /*if ($('#forum-index').length)*/ { return $(window); } } function getSelectableLink(row) { if ($('#group-split').length) { return row.find('a.postlink'); } else if ($('#group-index').length) { return row.find('a.forum-postsummary-subject'); } else { return row.find('a').first(); } } function getReplyLink() { if ($('#group-split').length || $('#group-vsplit').length || $('.viewmode-threaded').length) { return $('a.replylink'); } else { return $('.focused a.replylink'); } } function getPostScrollable() { if ($('#group-split').length || $('#group-vsplit').length) { return $('.post-body'); } return null; } function isAutoOpenApplicable() { return $('#group-index-threaded').length || $('#thread-index').length || $('#group-split').length || $('#group-vsplit').length; } var autoOpenTimer = 0; function focusNext(offset, onlyUnread) { if (autoOpenTimer) clearTimeout(autoOpenTimer); if (autoOpen && isAutoOpenApplicable()) autoOpenTimer = setTimeout(selectFocused, 500); if (typeof onlyUnread == 'undefined') onlyUnread = false; var $all = getSelectables(); var count = $all.length; var $current = $('.focused'); var index; if ($current.length == 0) { index = offset>0 ? offset-1 : count-offset; } else if (Math.abs(offset) == Infinity) { index = offset>0 ? count-1 : 0; } else { index = $all.index($current); if (index < 0) index = 0; else if (!onlyUnread) index = (index + offset + count) % count; } for (var i=0; i 0; if (!onlyUnread || isUnread) { //row.mousedown(); focusRow($row, FocusScroll.scroll, true); return true; } index = (index + offset + count) % count; } return false; } function selectFocused() { if (autoOpenTimer) clearTimeout(autoOpenTimer); var focused = $('.focused'); if (focused.length) { selectRow(focused); return true; } return false; } function markUnread() { var focused = $('.focused'); if (focused.length && focused.find('.forum-read').length > 0) { var path = focused.find('a.postlink, a.permalink').attr('href').replace("/post/", "/mark-unread/"); $.get(path, function(result) { if (result == "OK") focused.find('.forum-read').removeClass('forum-read').addClass('forum-unread'); }); return true; } return false; } // Show keyboard navigation UI immediately if we got to this page via keynav function initKeyNav() { addLinkNavigation(); if ($('.focused').length == 0) focusNext(+1); } function addLinkNavigation() { if (!enableKeyNav) return; $post = getSelectedPost(); if (!$post) return; $('.linknav').remove(); var counter = 1; $post.find('a').each(function() { if (counter > 9) return; $(this).after( $('') .addClass('linknav') .attr('data-num', counter++) ); }); } function followLink(n) { var url = $('.linknav[data-num='+n+']').prev('a').attr('href'); if (url) { window.open(url, '_blank'); return true; } return false; } var keyboardHelp = '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
' + _('Keyboard shortcuts') + '
j / ' + _('Ctrl') + '' + _('Select next message') + '
k / ' + _('Ctrl') + '' + _('Select previous message') + '
l / ' + _('Ctrl') + '' + _('Next page') + '
h / ' + _('Ctrl') + '' + _('Previous page') + '
' + _('Open selected message') + '
n' + _('Create thread') + '
r' + _('Reply') + '
u' + _('Mark as unread') + '
1 ··· 9' + _('Open link') + ' [1] … [9]
 ' + _('Scroll message / Open next unread message') + '
'; function showHelp() { $('
') .html(keyboardHelp + '
' + _('(press any key or click to close)') + '
') .click(closeHelp) .appendTo($('body')) .hide() .fadeIn(); return true; } function closeHelp() { $('.keyboardhelp-popup').fadeOut(function() { $('.keyboardhelp-popup').remove(); }); } function onKeyDown(e) { var result = onKey(e, true); if (result && 'localStorage' in window) localStorage.setItem('usingKeyNav', 'true'); return !result; // event handlers return "false" if the event was handled } function onKeyPress(e) { return !onKey(e, false); } // Return true if the event was handled, // and false if it wasn't and it should be processed by the browser. function onKey(e, keyDown) { if ($(e.target).is('input, textarea')) { return false; } if ($('.keyboardhelp-popup').length) { closeHelp(); return true; } var c = String.fromCharCode(e.which); if ($.browser.webkit) c = c.toLowerCase(); var pageSize = $('.group-threads').height() / $('.thread-post-row').eq(0).height(); if (!keyDown && !e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { switch (c) { case 'j': return focusNext(+1); case 'k': return focusNext(-1); case '\x0D': return selectFocused(); case ' ': { var p = getPostScrollable(); if (!p || !p.length) return false; var dest = p.scrollTop()+p.height(); if (dest < p[0].scrollHeight) { p.animate({scrollTop : dest}, 200); return true; } return focusNext(+1, true) && selectFocused(); } case 'n': { var $form = $('form[name=new-post-form]'); $form.submit(); return $form.length > 0; } case 'r': { var replyLink = getReplyLink(); if (replyLink.length) { document.location.href = replyLink.attr('href'); return true; } return false; } case 'u': return markUnread(); case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': return followLink(c); case 'h': return pagePrev(); case 'l': return pageNext(); } } if (!keyDown && !e.ctrlKey && e.shiftKey && !e.altKey && !e.metaKey) { switch (c.toUpperCase()) { case 'J': return focusNext(+1) && selectFocused(); case 'K': return focusNext(-1) && selectFocused(); case '?': return showHelp(); } } if (keyDown && e.ctrlKey && !e.shiftKey && !e.altKey && !e.metaKey) { switch (e.keyCode) { case 13: // ctrl+enter == enter return selectFocused(); case 38: // up arrow return focusNext(-1); case 40: // down arrow return focusNext(+1); case 37: // left arrow return pagePrev(); case 39: // right arrow return pageNext(); case 33: // page up return focusNext(-pageSize); case 34: // page down return focusNext(+pageSize); case 36: // home return focusNext(-Infinity); case 35: // end return focusNext(+Infinity); } } return false; } /* These are linked to in responsive horizontal-split post footer */ function navPrev() { focusNext(-1) && selectFocused(); } function navNext() { focusNext(+1) && selectFocused(); } function pagePrev() { var $link = $('.pager-left a').last(); if ($link.length) { window.location.href = $link.attr('href'); return true; } return false; } function pageNext() { var $link = $('.pager-right a').first(); if ($link.length) { window.location.href = $link.attr('href'); return true; } return false; } // ************************************************************************** // Posting // http://stackoverflow.com/a/4716021/21501 function moveCaretToEnd(el) { el.focus(); if (typeof el.selectionStart == "number") { el.selectionStart = el.selectionEnd = el.value.length; } else if (typeof el.createTextRange != "undefined") { var range = el.createTextRange(); range.collapse(false); range.select(); } } function initPosting() { initAutoSave(); var textarea = $('#postform textarea')[0]; if (textarea.value.length) moveCaretToEnd(textarea); else $('#postform-subject').focus(); } function initAutoSave() { var autoSaveCooldown = 2500; var $textarea = $('#postform textarea'); var oldValue = $textarea.val(); var timer = 0; function autoSave() { timer = 0; $('.autosave-notice').remove(); $.post('/auto-save', $('#postform').serialize(), function(data, status, xhr) { $('') .text(xhr.status == 200 ? _('Draft saved.') : _('Error auto-saving draft.')) .addClass('autosave-notice') .insertAfter($('#postform input[name=action-send]').parent().children().last()) .fadeOut(autoSaveCooldown) }); } $textarea.bind('input propertychange', function() { var value = $textarea.val(); if (value != oldValue) { oldValue = value; $('.autosave-notice').remove(); if (timer) clearTimeout(timer); timer = setTimeout(autoSave, autoSaveCooldown); } }); } ================================================ FILE: site-defaults/web/static/robots_private.txt ================================================ User-agent: * Disallow: / ================================================ FILE: site-defaults/web/static/robots_public.txt ================================================ User-agent: * Disallow: /discussion/set Disallow: /discussion/newpost Disallow: /discussion/reply Disallow: /set Disallow: /newpost Disallow: /reply Disallow: /source/ Disallow: /search ================================================ FILE: src/dfeed/backup.d ================================================ /* Copyright (C) 2011, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.backup; import std.datetime; import std.exception; import std.file; import std.process; import ae.net.shutdown; import ae.sys.file; import ae.sys.log; import ae.sys.timing; import ae.utils.time; import dfeed.database; class Backup { static struct Config { int hour, minute; } immutable Config config; Logger log; this(Config config) { this.config = config; log = createLogger("Backup"); auto backupTask = setInterval(&checkBackup, 1.minutes); addShutdownHandler((scope const(char)[] reason) { backupTask.cancel(); }); } void checkBackup() { auto now = Clock.currTime(); if (now.hour == config.hour && now.minute == config.minute) runBackup(); } enum backupDir = "data/backup/"; enum dataFile = "data/db/dfeed.s3db"; enum lastFile = dataFile ~ ".last"; enum thisFile = dataFile ~ ".this"; enum baseFile = backupDir ~ "base.s3db"; void runBackup() { if (transactionDepth) { log("Transaction in progress, delaying backup."); setTimeout(&runBackup, 1.minutes); return; } log("Starting backup."); if (!baseFile.exists) { log("Creating base backup."); ensurePathExists(baseFile); atomicCopy(dataFile, baseFile); } else { auto base = lastFile.exists ? lastFile : baseFile; log("Using " ~ base ~ " as base file."); log("Copying database"); // No locks required as this will run in the main thread and block DB access. copy(dataFile, thisFile); auto deltaFile = backupDir ~ "dfeed-" ~ Clock.currTime.formatTime!`Ymd-His` ~ ".vcdiff"; log("Creating delta file: " ~ deltaFile); auto deltaTmpFile = deltaFile ~ ".tmp"; auto pid = spawnProcess(["xdelta3", "-e", "-s", base, thisFile, deltaTmpFile]); enforce(pid.wait() == 0, "xdelta3 failed."); log("Delta file created."); rename(deltaTmpFile, deltaFile); rename(thisFile, lastFile); } log("Backup complete."); } } Backup backup; static this() { import dfeed.common : createService; backup = createService!Backup("backup"); } ================================================ FILE: src/dfeed/bayes.d ================================================ /* Copyright (C) 2015, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Common code for the naive Bayes classifier. module dfeed.bayes; struct BayesModel { struct Word { ulong spamCount, hamCount; } Word[string] words; ulong spamPosts, hamPosts; } auto splitWords(string s) { import std.algorithm.iteration; import std.algorithm.sorting; import std.array; import std.conv; import std.string; import std.uni; return s .map!(c => dchar(isAlpha(c) ? toLower(c) : ' ')) .array .split() .map!(to!string) .array .sort .uniq; } void train(R)(ref BayesModel model, R words, bool isSpam, int weight = 1) { foreach (word; words) { auto pWord = word in model.words; if (!pWord) { model.words[word] = BayesModel.Word(); pWord = word in model.words; } if (isSpam) pWord.spamCount += weight; else pWord.hamCount += weight; } if (isSpam) model.spamPosts += weight; else model.hamPosts += weight; } double checkMessage(in ref BayesModel model, string s) { if (model.spamPosts == 0 || model.hamPosts == 0) return 0.5; import std.math; debug(bayes) import std.stdio; // Adapted from https://github.com/rejectedsoftware/antispam/blob/master/source/antispam/filters/bayes.d double plsum = 0; auto bias = 1 / double(model.spamPosts + model.hamPosts + 1); foreach (w; s.splitWords) if (auto pWord = w in model.words) { auto p_w_s = (pWord.spamCount + bias) / model.spamPosts; auto p_w_h = (pWord.hamCount + bias) / model.hamPosts; auto p_w_t = p_w_s + p_w_h; if (p_w_t == 0) continue; auto prob = p_w_s / p_w_t; plsum += log(1 - prob) - log(prob); debug(bayes) writefln("%s: %s (%d/%d vs. %d/%d)", w, prob, pWord.spamCount, model.spamPosts, pWord.hamCount, model.hamPosts ); } else debug(bayes) writefln("%s: unknown word", w); auto prob = 1 / (1 + exp(plsum)); debug(bayes) writefln("---- final probability %s (%s)", prob, plsum); return prob; } ================================================ FILE: src/dfeed/bitly.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.bitly; import std.uri; import std.file; import std.string; import std.exception; import ae.net.http.client; import ae.sys.log; void shortenURL(string url, void delegate(string) handler) { if (urlShortener) urlShortener.shorten(url, handler); else handler(url); } // ************************************************************************** private: class UrlShortener { abstract void shorten(string url, void delegate(string) handler); } UrlShortener urlShortener; class Bitly : UrlShortener { static struct Config { string login, apiKey; } immutable Config config; this(Config config) { this.config = config; } Logger log; override void shorten(string url, void delegate(string) handler) { httpGet( format( "http://api.bitly.com/v3/shorten?login=%s&apiKey=%s&longUrl=%s&format=txt&domain=j.mp", config.login, config.apiKey, std.uri.encodeComponent(url) ), (string shortened) { shortened = shortened.strip(); enforce(shortened.startsWith("http://"), "Unexpected bit.ly output: " ~ shortened); handler(shortened); }, (string error) { if (!log) log = createLogger("bitly"); log("Error while shortening " ~ url ~ ": " ~ error); handler(url); }); } } static this() { import dfeed.common : createService; urlShortener = createService!Bitly("apis/bitly"); } ================================================ FILE: src/dfeed/common.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2018, 2020, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.common; import std.datetime; public import std.typecons; import ae.sys.log; import ae.net.shutdown; alias quiet = ae.sys.log.quiet; // *************************************************************************** abstract class Post { /// Asynchronously summarise this post to a single line, ready to be sent to IRC. abstract void formatForIRC(void delegate(string) handler); enum Importance { /// Never announce. none, /// Replies to threads, general activity. /// Should only be shown in "all activity" feeds. low, /// Suitable to be announced on general feeds. normal, /// Project announcements and other important posts. high, } Importance getImportance() { return Importance.normal; } this() { time = Clock.currTime(); } SysTime time; } abstract class NewsSource { this(string name) { this.name = name; log = createLogger(name).asyncLogger(); newsSources[name] = this; } abstract void start(); abstract void stop(); protected: Logger log; public: string name; } alias Fresh = Flag!q{Fresh}; abstract class NewsSink { this() { newsSinks ~= this; } abstract void handlePost(Post p, Fresh fresh); } interface ModerationSink { void handleModeration(Post p, Flag!"ban" ban); } private NewsSource[string] newsSources; private NewsSink[] newsSinks; void startNewsSources() { foreach (source; newsSources) source.start(); addShutdownHandler((scope const(char)[] reason){ foreach (source; newsSources) source.stop(); }); } void announcePost(Post p, Fresh fresh) { foreach (sink; newsSinks) sink.handlePost(p, fresh); } void handleModeration(Post p, Flag!"ban" ban) { foreach (sink; newsSinks) if (auto mod = cast(ModerationSink)sink) mod.handleModeration(p, ban); } // *************************************************************************** /// Some zero-width control/formatting sequence codes inserted into names /// to disrupt IRC highlight. enum ircHighlightBreaker = "\u200E"; // LEFT-TO-RIGHT MARK /// Filter a name in an announcement to avoid an IRC highlight. string filterIRCName(string name) { import std.algorithm; import std.array; import std.conv; import std.string; name = name .to!dstring .split(" "d) .map!(s => s.length < 2 ? s : s[0..$/2] ~ ircHighlightBreaker ~ s[$/2..$] ) .join(" "d) .to!string; // Split additional keywords foreach (word; ["Cyber"]) if (name.indexOf(word) >= 0) name = name.replace(word, word[0..$/2] ~ ircHighlightBreaker ~ word[$/2..$]); return name; } unittest { assert(filterIRCName("Vladimir Panteleev") == "Vlad" ~ ircHighlightBreaker ~ "imir Pant" ~ ircHighlightBreaker ~ "eleev"); assert(filterIRCName("CyberShadow") == "Cy" ~ ircHighlightBreaker ~ "ber" ~ ircHighlightBreaker ~ "Shadow"); assert(filterIRCName("Rémy") == "Ré" ~ ircHighlightBreaker ~ "my"); } // *************************************************************************** import ae.utils.sini; import std.file; import std.path; import dfeed.paths : resolveSiteFile, siteSearchPaths; template services(C) { C[string] services; } /// Create a Class instance if the corresponding .ini file exists. Class createService(Class)(string configName) { auto fn = resolveSiteFile("config/" ~ configName ~ ".ini"); if (fn.exists) return new Class(loadIni!(Class.Config)(fn)); return null; } /// Create one instance of Class for each .ini configuration file /// found in the specified config subdirectory. /// Searches both site/ and site-defaults/ directories, with site/ taking priority. void createServices(Class, Args...)(string configDir, Args args) { bool[string] loaded; // Track which configs have been loaded foreach (base; siteSearchPaths) { auto dir = buildPath(base, "config", configDir); if (!dir.exists) continue; foreach (de; dir.dirEntries("*.ini", SpanMode.breadth)) { auto name = de.baseName.stripExtension; if (name in loaded) continue; // Already loaded from higher-priority path auto config = loadIni!(Class.Config)(de.name); services!Class[name] = new Class(config, args); loaded[name] = true; } } } ================================================ FILE: src/dfeed/database.d ================================================ /* Copyright (C) 2011, 2015, 2016, 2017, 2018, 2020, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.database; import std.exception; import std.file : rename, exists; import ae.sys.file : ensurePathExists; import ae.sys.sqlite3 : SQLite; public import ae.sys.sqlite3 : SQLiteException; import ae.sys.database; SQLite.PreparedStatement query(string sql)() { return database.stmt!sql(); } SQLite.PreparedStatement query(string sql) { return database.stmt(sql); } alias selectValue = ae.sys.database.selectValue; @property SQLite db() { return database.db; } // *************************************************************************** private Database database; enum databasePath = "data/db/dfeed.s3db"; static this() { import std.file; enum oldDatabasePath = "data/dfeed.s3db"; if (!databasePath.exists && oldDatabasePath.exists) { ensurePathExists(databasePath); rename(oldDatabasePath, databasePath); version(Posix) symlink("db/dfeed.s3db", oldDatabasePath); } ensurePathExists(databasePath); database = Database(databasePath, [ // Initial version readText("schema_v1.sql"), // Add missing index q"SQL CREATE INDEX [SubscriptionUser] ON [Subscriptions] ([Username]); SQL", // Add covering index for COUNT(DISTINCT AuthorEmail) queries with Time filter q"SQL CREATE INDEX [PostTimeAuthorEmail] ON [Posts] ([Time] DESC, [AuthorEmail]); SQL", // Add index for user profile queries filtering by Author and AuthorEmail, with Time for ORDER BY q"SQL CREATE INDEX [PostAuthorAuthorEmailTime] ON [Posts] ([Author], [AuthorEmail], [Time] DESC); SQL", ]); // Enable WAL mode for better concurrency and performance on COW filesystems // Must be set outside of transactions, so done here rather than in migrations db.exec("PRAGMA journal_mode = WAL;"); // Set per-connection performance settings db.exec("PRAGMA synchronous = NORMAL;"); // Balance safety vs performance (1 fsync instead of 2) db.exec("PRAGMA cache_size = -50000;"); // 50MB cache (reduces disk I/O) } int transactionDepth; enum DB_TRANSACTION = q{ if (transactionDepth++ == 0) query!"BEGIN TRANSACTION".exec(); scope(failure) if (--transactionDepth == 0) query!"ROLLBACK TRANSACTION".exec(); scope(success) if (--transactionDepth == 0) query!"COMMIT TRANSACTION".exec(); }; bool flushTransactionEvery(int count) { static int calls = 0; assert(transactionDepth, "Not in a transaction"); if (count && ++calls % count == 0 && transactionDepth == 1) { query!"COMMIT TRANSACTION"; query!"BEGIN TRANSACTION"; return true; } else return false; } ================================================ FILE: src/dfeed/debugging.d ================================================ /* Copyright (C) 2023 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ // See https://github.com/CyberShadow/ShellUtils/commit/f033d102ce968b71ffa5c0d721922a99d41d302a module dfeed.debugging; import core.stdc.stdio; import dfeed.web.web.page; import dfeed.web.web.request; const(char)* _t15_getDebugInfo() @nogc { __gshared char[65536] buf = void; auto currentURL = currentRequest ? currentRequest.resource : "(no current request)"; snprintf( buf.ptr, buf.length, ( "Current URL: %.*s\n" ~ "html buffer size: %zu\n" ).ptr, cast(int)currentURL.length, currentURL.ptr, html.length, ); return buf.ptr; } ================================================ FILE: src/dfeed/groups.d ================================================ /* Copyright (C) 2015, 2017, 2018, 2021, 2024 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.groups; import std.exception; import std.string; struct Config { struct Set { string name, shortName; bool visible = true; } OrderedMap!(string, Set) sets; struct AlsoVia { string name, url; } struct Group { string internalName, publicName, navName, urlName, groupSet, description, postMessage, notice, sinkType, sinkName; string[] urlAliases; OrderedMap!(string, AlsoVia) alsoVia; bool subscriptionRequired = true; bool announce; string captcha; // Rate limiting: reject posts if threshold is met under time limit (in seconds) uint postThrottleRejectTime = 30; uint postThrottleRejectCount = 3; // Rate limiting: challenge with CAPTCHA if threshold is met under time limit (in seconds) uint postThrottleCaptchaTime = 180; // 3 minutes uint postThrottleCaptchaCount = 3; } OrderedMap!(string, Group) groups; } immutable Config config; import ae.utils.aa; import ae.utils.sini; import dfeed.paths : resolveSiteFile; shared static this() { config = cast(immutable)loadIni!Config(resolveSiteFile("config/groups.ini")); } struct GroupSet { Config.Set set; alias set this; immutable Config.Group[] groups; } immutable GroupSet[] groupHierarchy; alias GroupInfo = immutable(Config.Group)*; shared static this() { import std.algorithm; import std.range; groupHierarchy = config.sets.length.iota .map!(setIndex => GroupSet( config.sets.values[setIndex], config.groups.values.filter!(group => group.groupSet == config.sets.keys[setIndex]).array )).array; } GroupInfo getGroupInfoByField(string field, CaseSensitive cs=CaseSensitive.yes)(string value) { foreach (set; groupHierarchy) foreach (ref group; set.groups) { auto fieldValue = mixin("group." ~ field); if (cs ? fieldValue == value : icmp(fieldValue, value) == 0) return &group; } return null; } alias getGroupInfo = getGroupInfoByField!(q{internalName}, CaseSensitive.no); alias getGroupInfoByUrl = getGroupInfoByField!q{urlName}; alias getGroupInfoByPublicName = getGroupInfoByField!q{publicName}; ================================================ FILE: src/dfeed/loc/english.d ================================================ /* Copyright (C) 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.loc.english; enum languageName = "English"; enum languageCode = "en"; enum digitGroupingSeparator = ','; private string pluralOf(string unit) { switch (unit) { case "second": case "minute": case "hour": case "day": case "week": case "month": case "year": case "thread": case "post": case "forum post": case "subscription": case "unread post": case "registered user": case "visit": return unit ~ "s"; case "new reply": return "new replies"; case "user has created": return "users have created"; default: assert(false, "Unknown unit: " ~ unit); } } string plural(string unit)(long amount) { static immutable unitPlural = pluralOf(unit); return amount == 1 ? unit : unitPlural; } ================================================ FILE: src/dfeed/loc/package.d ================================================ /* Copyright (C) 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.loc; import std.algorithm.iteration; import std.algorithm.searching; import std.datetime; import std.string; import std.traits : staticMap, EnumMembers; import ae.utils.array; import ae.utils.json; import ae.utils.meta; import ae.utils.time.common; import ae.utils.time.format; static import dfeed.loc.english; static import dfeed.loc.turkish; enum Language { // English should be first english, // Sort rest alphabetically turkish, } Language currentLanguage; immutable string[enumLength!Language] languageNames = [ dfeed.loc.english.languageName, dfeed.loc.turkish.languageName, ]; immutable string[enumLength!Language] languageCodes = [ dfeed.loc.english.languageCode, dfeed.loc.turkish.languageCode, ]; immutable char[enumLength!Language] digitGroupingSeparators = [ dfeed.loc.english.digitGroupingSeparator, dfeed.loc.turkish.digitGroupingSeparator, ]; static immutable string[][4][enumLength!Language] timeStrings = [ [ ae.utils.time.common.WeekdayLongNames, ae.utils.time.common.MonthLongNames, ae.utils.time.common.WeekdayShortNames, ae.utils.time.common.MonthShortNames, ], [ dfeed.loc.turkish.WeekdayLongNames, dfeed.loc.turkish.MonthLongNames, dfeed.loc.turkish.WeekdayShortNames, dfeed.loc.turkish.MonthShortNames, ], ]; private template translate(string s, Language language) { static if (language == Language.english) enum translation = s; else static if (language == Language.turkish) enum translation = dfeed.loc.turkish.translate(s); else enum translation = string.init; static if (translation is null) { import std.conv : text; pragma(msg, "Untranslated ", text(language), " string: ", s); enum translate = s; } else enum translate = translation; } string _(string s)() { enum translation(Language language) = translate!(s, language); static string[enumLength!Language] translations = [staticMap!(translation, EnumMembers!Language)]; return translations[currentLanguage]; } enum pluralMany = 99; string plural(string unit)(long amount) { final switch (currentLanguage) { case Language.english: return dfeed.loc.english.plural!unit(amount); case Language.turkish: return dfeed.loc.turkish.plural!unit(amount); } } auto withLanguage(Language language) { struct WithLanguage { Language oldLanguage; @disable this(this); ~this() { currentLanguage = oldLanguage; } } auto oldLanguage = currentLanguage; currentLanguage = language; return WithLanguage(oldLanguage); } Language detectLanguage(string acceptLanguage) { foreach (pref; acceptLanguage.splitter(",")) { auto code = pref.findSplit(";")[0].findSplit("-")[0].strip; auto i = languageCodes[].countUntil(code); if (i >= 0) return cast(Language)i; } return Language.init; } string formatTimeLoc(string timeFormat)(SysTime time) { string s = time.formatTime!timeFormat(); if (!currentLanguage) return s; bool[4] needStrings; foreach (c; timeFormat) switch (c) { case TimeFormatElement.dayOfWeekName: needStrings[0] = true; break; case TimeFormatElement.monthName: needStrings[1] = true; break; case TimeFormatElement.dayOfWeekNameShort: needStrings[2] = true; break; case TimeFormatElement.monthNameShort: needStrings[3] = true; break; default: break; } string[] sourceStrings, targetStrings; foreach (i, b; needStrings) if (b) { sourceStrings ~= timeStrings[Language.init ][i]; targetStrings ~= timeStrings[currentLanguage][i]; } string result; mainLoop: while (s.length) { foreach (i, sourceString; sourceStrings) if (s.skipOver(sourceString)) { result ~= targetStrings[i]; continue mainLoop; } result ~= s.shift; } return result; } /// List of strings used in dfeed.js. immutable jsStrings = [ `Toggle navigation`, `Loading message`, `Your browser does not support HTML5 pushState.`, `Keyboard shortcuts`, `Ctrl`, `Down Arrow`, `Select next message`, `Up Arrow`, `Select previous message`, `Enter / Return`, `Open selected message`, `Create thread`, `Reply`, `Mark as unread`, `Open link`, `Space Bar`, `Scroll message / Open next unread message`, `(press any key or click to close)`, `Draft saved.`, `Error auto-saving draft.`, ]; string getJsStrings() { string[enumLength!Language] translations; if (!translations[currentLanguage]) { string[string] object; foreach (i; RangeTuple!(jsStrings.length)) object[jsStrings[i]] = _!(jsStrings[i]); translations[currentLanguage] = object.toJson(); } return translations[currentLanguage]; } ================================================ FILE: src/dfeed/loc/turkish.d ================================================ /* Copyright (C) 2020, 2021, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.loc.turkish; enum languageName = "Türkçe"; enum languageCode = "tr"; enum digitGroupingSeparator = '.'; string translate(string s) { switch (s) { case `Please enter a name`: return `Lütfen bir isim giriniz`; case `Please enter an email address`: return `Lütfen bir e-posta adresi giriniz`; case `Please enter a message subject`: return `Lütfen bir mesaj konusu giriniz`; case `Please enter a message`: return `Lütfen bir mesaj giriniz`; case `CAPTCHA error:`: return `CAPTCHA hatası:`; case `You can't post to this group.`: return `Bu gruba mesaj gönderemezsiniz.`; case `NNTP connection error:`: return `NNTP bağlantı hatası:`; case `NNTP error:`: return `NNTP hatası:`; case `Posting is disabled`: return `Gönderim hizmet dışı`; case `SMTP error:`: return `SMTP hatası:`; case `Post not found`: return `Gönderi bulunamadı`; case `Invalid attachment`: return `Geçersiz ek`; case `Guest`: return `Misafir`; case `Welcome back,`: return `Tekrar hoşgeldiniz,`; case `Welcome,`: return `Hoşgeldiniz,`; case `You have %d %s to %syour posts%s.`: return `%3$sGönderilerinize%4$s %1$d %2$s var.`; case `No new %sreplies%s to %syour posts%s.`: return `%3$sGönderilerinize%4$s yeni %1$syanıt yok%2$s.`; case `You have %d %s matching your %s%s subscription%s (%s).`: return `%3$s%4$s aboneliğinizle%5$s eşleşen %1$d %2$s var (%6$s).`; case `No new posts matching your %s%s%s.`: return `%s%s%s ile eşleşen yeni gönderi yok.`; case `If you %screate an account%s, you can track replies to %syour posts%s.`: return `%sHesap açtığınızda%s %sgönderilerinize%s gelen yanıtları izleyebilirsiniz.`; case `You can read and post on this forum without %screating an account%s, but creating an account offers %sa few benefits%s.`: return `Bu forumu hesap açmadan da kullanabilirsiniz. Ancak, %shesap açmanın%s bazı %syararları vardır%s.`; case `%d %s %-(%s and %)`: return `%d %s %-(%s ve %) oluşturuldu.`; case `No new forum activity`: return `Yeni forum etkinliği yok`; case `since your last visit (%s).`: return `(Son ziyaretinizden beri (%s)).`; case `in the last 24 hours.`: return `(son 24 saat içinde).`; case `There are %s %s, %s %s, and %s %s on this forum.`: return `Bu forumda %s %s, %s %s ve %s %s var.`; case `You have read a total of %s %s during your %s.`: return `%3$s sırasında toplam %1$s %2$s okudunuz.`; case `Random tip:`: return `Bilgi:`; case `This forum has several different
view modes. Try them to find one you like best. You can change the view mode in the settings.`: return `Bu forumun birkaç farklı görünümü bulunuyor. Kullanışlı bulduğunuz birini seçin. Görünüm seçeneğini ayarlardan değiştirebilirsiniz.`; case `This forum supports keyboard shortcuts. Press ? to view them.`: return `Bu forum klavye kısayolları kullanır. Görüntülemek için ? tuşuna basın.`; case `You can focus a message with j/k and press u to mark it as unread, to remind you to read it later.`: return `Bir mesajı j ve k tuşları ile seçebilir ve u tuşu ile okunmadı olarak işaretleyebilirsiniz.`; case `The avatars on this forum are provided by Gravatar, which allows associating a global avatar with an email address.`: return `Bu forumdaki avatarlar, global bir avatarın bir e-posta adresiyle ilişkilendirilmesini sağlayan Gravatar tarafından sağlanmaktadır.`; case `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.`: return `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.`; case `Much of this forum's content is also available via classic mailing lists or NNTP - see the "Also via" column on the forum index.`: return `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.`; case `If you create a Gravatar profile with the email address you post with, it will be accessible when clicking your avatar.`: return `Gönderdiğiniz e-posta adresiyle bir Gravatar profili oluşturursanız, avatarınıza tıkladığınızda erişilebilir olacaktır.`; case `To subscribe to a thread, click the "Subscribe" link on that thread's first post. You need to be logged in to create subscriptions.`: return `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.`; case `To search the forum, use the search widget at the top, or you can visit the search page directly.`: return `Forumda arama yapmak için üstteki arama olanağını kullabilirsiniz veya doğrudan arama sayfasına gidebilirsiniz.`; case `This forum is open-source! Read or fork the code on GitHub.`: return `Bu forum açık kaynaklıdır! Kaynak kodunu GitHub'da okuyun veya çatallayın.`; case `If you encounter a bug or need a missing feature, you can create an issue on GitHub.`: return `Farkettiğiniz hataları ve eksik özellikleri GitHub'da bildirebilirsiniz.`; case `Group`: return `Grup`; case `Last Post`: return `Son Gönderi`; case `Threads`: return `Konu`; case `Posts`: return `Gönderi`; case `Also via`: return `Ayrıca`; case `Create thread`: return `Yeni konu`; case `Invalid page`: return `Geçersiz sayfa`; case `by`: return ``; case `Thread / Thread Starter`: return `Konu / Konuyu Başlatan`; case `Replies`: return `Yanıt`; case `replies`: return `yanıt`; case `Loading...`: return `Yükleniyor...`; case `Sorry, this view requires JavaScript.`: return `Üzgünüz, bu görünüm JavaScript gerektirmektedir.`; case `Unknown group:`: return `Bilinmeyen grup:`; case `Can't find thread's page`: return `Konu sayfası bulunamadı`; case `Can't find post's page`: return `Gönderi sayfası bulunamadı`; case `The specified resource cannot be found on this server.`: return `Belirtilen kaynak bu sunucuda bulunamıyor.`; case `XSRF secret verification failed. Are your cookies enabled?`: return `XSRF gizli doğrulaması başarısız oldu. Çerezleriniz etkin mi?`; case `No action specified`: return `Eylem belirtilmedi`; case `Subscription undeleted.`: return `Aboneliğin silinmesi geri alındı.`; case `Subscription saved.`: return `Abonelik kaydedildi.`; case `Subscription created.`: return `Abonelik oluşturuldu.`; case `This subscription doesn't exist.`: return `Bu abonelik mevcut değil.`; case `Subscription deleted.`: return `Abonelik silindi.`; case `Unknown action:`: return `Bilinmeyen eylem:`; case `Settings`: return `Ayarlar`; case `User Interface`: return `Kullanıcı arayüzü`; case `Language:`: return `Dil:`; case `View mode:`: return `Görünüm:`; case `Enable keyboard shortcuts`: return `Klavye kısayollarını etkinleştir`; case `Automatically open messages after selecting them.`: return `Seçilen mesaj otomatik olarak açılır.`; case `Applicable to threaded, horizontal-split and vertical-split view modes.`: return `Gönderi listesi, yatay bölünmüş ve dikey bölünmüş görünümlere uygulanabilir.`; case `Focus follows message`: return `Mesaj otomatik açılsın`; case `Save`: return `Kaydet`; case `Cancel`: return `İptal et`; case `Subscriptions`: return `Konu abonelikleri`; case `Subscription`: return `Seçim`; case `Actions`: return `Eylemler`; case `View posts`: return `Gönderileri göster`; case `Get ATOM feed`: return `ATOM beslemesini indir`; case `Edit`: return `Düzenle`; case `Delete`: return `Sil`; case `You have no subscriptions.`: return `Aboneliğiniz yok.`; case `Create new content alert subscription`: return `Konu aboneliği oluştur`; case `Please %slog in%s to manage your subscriptions and account settings.`: return `Aboneliklerinizi ve hesap ayarlarınızı yönetebilmek için lütfen %sgiriş yapınız%s.`; case `Account settings`: return `Hesap ayarları`; case `Change the password used to log in to this account.`: return `Bu hesabın şifresini değiştir.`; case `Change password`: return `Şifre değiştir`; case `Download a file containing all data tied to this account.`: return `Bu hesaba bağlı tüm verileri içeren bir dosya indir.`; case `Export data`: return `Verileri dışa aktar`; case `Permanently delete this account.`: return `Bu hesabı kalıcı olarak sil.`; case `Delete account`: return `Hesabı sil`; case `Edit subscription`: return `Aboneliği düzenle`; case `Condition`: return `Seçim`; case `This action is only meaningful for logged-in users.`: return `Bu işlem yalnızca giriş yapmış kullanıcılar için anlamlıdır.`; case `Here you can change the password used to log in to this %s account.`: return `Burada, bu %s hesabının şifresini değiştirebilirsiniz.`; case `Please pick your new password carefully, as there are no password recovery options.`: return `Şifre kurtarma seçeneği olmadığından, lütfen yeni şifrenizi dikkatlice seçin.`; case `Current password:`: return `Mevcut şifre:`; case `New password:`: return `Yeni şifre:`; case `New password (confirm):`: return `Yeni şifre (onaylayın):`; case `XSRF secret verification failed`: return `XSRF gizli doğrulaması başarısız oldu`; case `The current password you entered is incorrect`: return `Girdiğiniz mevcut şifre yanlış`; case `New passwords do not match`: return `Yeni şifreler uyuşmuyor`; case `Password successfully changed.`: return `Şifre başarıyla değiştirildi.`; case `Export account data`: return `Hesap verilerini dışa aktar`; case `Here you can export the information regarding your account from the %s database.`: return `Burada hesabınızla ilgili bilgileri %s veritabanından dışa aktarabilirsiniz.`; case `Export`: return `Dışa aktar`; case `Here you can permanently delete your %s account and associated data from the database.`: return `Burada %s hesabınızı ve ilişkili verileri veritabanından kalıcı olarak silebilirsiniz.`; case `After deletion, the account username will become available for registration again.`: return `Silme işleminden sonra, hesap kullanıcı adı tekrar kayıt için uygun hale gelecektir.`; case `To confirm deletion, please enter your account username and password.`: return `Silme işlemini onaylamak için lütfen hesap kullanıcı adınızı ve şifrenizi giriniz.`; case `Account username:`: return `Hesap kullanıcı adı:`; case `Account password:`: return `Hesap şifresi:`; case `Delete this account`: return `Bu hesabı sil`; case `The username you entered does not match the current logged-in account`: return `Girdiğiniz kullanıcı adı, mevcut oturum açmış hesapla eşleşmiyor`; case `The password you entered is incorrect`: return `Girdiğiniz şifre yanlış`; case `Account successfully deleted!`: return `Hesap başarıyla silindi!`; case `Latest threads on %s`: return `%s üzerindeki son konular`; case `Latest posts on %s`: return `%s üzerindeki son gönderiler`; case `Latest threads`: return `Son konular`; case `Latest posts`: return `Son gönderiler`; case `%s subscription (%s)`: return `%s aboneliği (%s)`; case `No such subscription`: return `Böyle bir abonelik yok`; case `Not logged in`: return `Giriş yapmadınız`; case `No such user subscription`: return `Böyle bir kullanıcı aboneliği yok`; case `reply`: return `yanıt`; case "new": return "yeni"; case `Replies to your posts`: return `Gönderilerinize verilen yanıtlar`; case `%s replied to your post in the thread "%s"`: return `%s, "%s" konusundaki gönderinize yanıt verdi`; case `%s has just replied to your %s post in the thread titled "%s" in the %s group of %s.`: return `%1$s, %5$s sunucusunun %4$s grubundaki "%3$s" başlıklı konusundaki %2$s gönderinize az önce yanıt verdi.`; case `When someone replies to your posts:`: return `Birisi gönderilerinize yanıt verdiğinde:`; case `thread`: return `konu`; case `Replies to the thread`: return `Konusu:`; case `%s replied to the thread "%s"`: return `%s, "%s" konusuna yanıt verdi`; case `%s has just replied to a thread you have subscribed to titled "%s" in the %s group of %s.`: return `%1$s, %4$s sunucusunun %3$s grubundaki abone olduğunuz "%2$s" başlıklı konuya yanıt verdi.`; case `When someone posts a reply to the thread`: return `Birisi şu konuya yanıt gönderdiğinde:`; case `No such post`: return `Böyle bir gönderi yok`; case `content`: return `içerik`; case `New threads`: return `Yeni konu veya gönderi;`; case `New posts`: return `Yeni gönderi;`; case `in`: return `grup(lar):`; case `and`: return `ve`; case `more`: return `tane daha`; case `from`: return `göndereni:`; case `from email`: return `e-postası:`; case `titled`: return `konusu:`; case `containing`: return `içeriği:`; case `%s %s the thread "%s" in %s`: return `%1$s, %4$s grubunun "%3$s" konusunu %2$s`; case `replied to`: return `yanıtladı`; case `created`: return `oluşturdu`; case `%s matching %s`: return `%s ile eşleşen %s`; case "%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).": return "%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)."; case `This post`: return `Bu gönderi`; case `This thread`: return `Bu konu`; case `When someone`: return `Birisi`; case `posts or replies to a thread`: return `yeni konu açarsa veya yanıtlarsa`; case `posts a new thread`: return `yeni konu açarsa`; case `only in the groups:`: return `grup şunlardan birisiyse:
(birden fazla seçmek için Ctrl-tık):`; case `and when the`: return `ve eğer`; case `contains the string`: return `şu dizgiyi içerirse:`; case `matches the regular expression`: return `şu düzenli ifadeyle (regex) eşleşirse:`; case `case sensitive`: return `küçük/büyük harfe duyarlı`; case `author name`: return `yazar adı`; case `author email`: return `yazar e-postası`; case `subject`: return `konu başlığı`; case `message`: return `ileti`; case `No %s search term specified`: return `%s arama terimi belirtilmedi`; case "Invalid %s regex `%s`: %s": return "Geçersiz %s düzenli ifadesi `%s`: %s"; case `No groups selected`: return `Grup seçilmedi`; case `Unknown subscription trigger type:`: return `Bilinmeyen abonelik tetikleyici türü:`; case `Send a private message to`: return `Şu kişiye özel mesaj gönder:`; case `on the`: return `sunucusu üzerinde`; case `IRC network`: return `IRC ağı`; case `No nickname indicated`: return `Takma ad belirtilmedi`; case `Invalid character in nickname.`: return `Takma adda geçersiz karakter.`; case `Send an email to`: return `Şu adrese e-posta gönder:`; case `Error:`: return `Hata:`; case `Howdy %1$s,`: return `Merhaba %1$s,`; case `This %3$s is located at:`: return `Bu %3$:`; case `Here is the message that has just been posted:`: return `Yeni gönderi:`; case `To reply to this message, please visit this page:`: return `Bu gönderiyi yanıtlamak için lütfen şu sayfayı ziyaret edin:`; case `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:`: return `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:`; case `All the best,`: return `Herşey gönlünüzce olsun,`; case `Unsubscription information:`: return `Abonelik iptali bilgileri:`; case `To stop receiving emails for this subscription, please visit this page:`: return `Bu abonelik için e-posta almayı durdurmak için lütfen şu sayfayı ziyaret edin:`; case `Or, visit your settings page to edit your subscriptions:`: return `Veya aboneliklerinizi düzenlemek için ayarlar sayfanızı ziyaret edin:`; case `post`: return `İleti`; case `Invalid email address`: return `Geçersiz e-posta adresi`; case `Additionally, you can %ssubscribe to an ATOM feed of matched posts%s, or %sread them online%s.`: return `Ek olarak, seçilen gönderiler için bir %sATOM feed aboneliği başlatabilir%s veya gönderileri %sburadan okuyabilirsiniz%s.`; case `No such post:`: return `Böyle bir gönderi yok:`; case `Post #%d of thread %s not found`: return `#%d numaralı gönderi %s numaralı konuda bulunamadı`; case `Jump to page:`: return `Sayfaya atla:`; case `Page`: return `Sayfa`; case `Go`: return `Git`; case `Thread overview`: return `Konuya genel bakış`; case `Thread not found`: return `Konu bulunamadı`; case `Permalink`: return `Kalıcı bağlantı`; case `Canonical link to this post. See "Canonical links" on the Help page for more information.`: return `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.`; case `Reply`: return `Yanıtla`; case `Reply to this post`: return `Bu gönderiyi yanıtla`; case `Subscribe`: return `Abone ol`; case `Subscribe to this thread`: return `Bu konuya abone ol`; case `Flag`: return `Bildir`; case `Flag this post for moderator intervention`: return `Bu gönderiyi yönetici müdahalesi için işaretleyin`; case `Source`: return `Kaynak`; case `View this message's source code`: return `Bu mesajın kaynak kodunu görüntüle`; case `Moderate`: return `Yönet`; case `Perform moderation actions on this post`: return `Bu gönderi üzerinde denetim işlemleri gerçekleştirin`; case `%s's Gravatar profile`: return `%s kullanıcısının Gravatar profili`; case `Posted by %s`: return `Gönderen: %s`; case `in reply to`: return `Yanıtlanan: `; case `part`: return `Bölüm`; case `Posted in reply to`: return `Yanıtlanan: `; case `Attachments:`: return `Ekler:`; case `View in thread`: return `Konusu içinde görüntüle`; case `Replies to %s's post from %s`: return `%s tarafından gönderilen %s gönderisine yanıtlar`; case `Permanent link to this post`: return `Bu gönderiye kalıcı bağlantı`; case `%s's Gravatar profile"`: return `Gravatar profili (%s)"`; case `You seem to be posting using an unusual user-agent`: return `Garip bir kullanıcı programı (user-agent) kullanıyorsunuz`; case `Your subject contains a suspicious keyword or character sequence`: return `Konu başlığınız şüpheli bir anahtar kelime veya karakter dizisi içeriyor`; case `Your post contains a suspicious keyword or character sequence`: return `Gönderiniz şüpheli bir anahtar kelime veya karakter dizisi içeriyor`; case `Your top-level post is suspiciously short`: return `Konu başlığınız ve gönderiniz şüpheli derecede kısa`; case `Your post looks like spam (%d%% spamicity)`: return `Gönderiniz spam gibi görünüyor (%d%% spamicity)`; case `from the future`: return `gelecekten`; case `just now`: return `az önce`; case `%d %s ago`: return `%d %s önce`; case `basic`: return `Temel`; case `narrow-index`: return `Dar dizin`; case `threaded`: return `Gönderi listesi`; case `horizontal-split`: return `Yatay bölünmüş`; case `vertical-split`: return `Dikey bölünmüş`; case `Unknown view mode`: return `Bilinmeyen görünüm`; case `You have an %sunsent draft message from %s%s.`: return `%s%s tarihinden kalmış bir taslak mesajınız%s var.`; case `This message is awaiting moderation.`: return `Bu mesaj bir yönetici tarafından denetlenmeyi bekliyor.`; case `This message has already been posted.`: return `Bu mesaj zaten gönderilmiş.`; case `Can't post to archive`: return `Arşive gönderilemiyor`; case `Note: you are posting to a mailing list.`: return `Not: Bir posta listesine gönderiyorsunuz.`; case `Your message will not go through unless you %ssubscribe to the mailing list%s first.`: return `%sPosta listesine abone olmadığınız%s sürece mesajınız gönderilmeyecek.`; case `You must then use the same email address when posting here as the one you used to subscribe to the list.`: return `Buradan gönderirken listeye abone olmak için kullandığınız e-posta adresini kullanmanız gerekir.`; case `If you do not want to receive mailing list mail, you can disable mail delivery at the above link.`: return `Posta listesi gönderisi almak istemiyorsanız, yukarıdaki bağlantıdan posta teslimini devre dışı bırakabilirsiniz.`; case `Warning: the post you are replying to is from`: return `Uyarı: yanıtladığınız gönderi eski:`; case `Posting to`: return ``; case `unknown post`: return `bilinmeyen gönderi`; case `Your name:`: return `Adınız:`; case `Your email address`: return `E-posta adresiniz`; case `Subject:`: return `Konu:`; case `Message:`: return `İleti:`; case `Save and preview`: return `Kaydet ve önizle`; case `Send`: return `Gönder`; case `Discard draft`: return `Taslağı sil`; case `This message has already been sent.`: return `Bu mesaj zaten gönderilmiş.`; case `Automatic fix applied.`: return `Otomatik düzeltme uygulandı.`; case `Undo`: return `Geri al`; case `Sorry, a problem occurred while attempting to fix your post`: return `Üzgünüz, gönderinizi düzeltmeye çalışırken bir sorun oluştu`; case `Undo information not found.`: return `Geri alma bilgisi bulunamadı.`; case `Automatic fix undone.`: return `Otomatik düzeltme geri alındı.`; case `Warning:`: return `Uyarı:`; case `Ignore`: return `Göz ardı et`; case `Explain`: return `Açıkla`; case `Fix it for me`: return `Benim için düzelt`; case `You've attempted to post %d times in the past %s. Please wait a little bit before trying again.`: return `Geçmiş %2$s süresi içinde %1$d kez göndermeye çalıştınız. Tekrar denemeden önce lütfen biraz bekleyin.`; case `You've attempted to post %d times in the past %s. Please solve a CAPTCHA to continue.`: return `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.`; case `Your message has been saved, and will be posted after being approved by a moderator.`: return `Mesajınız kaydedildi; bir yönetici tarafından onaylandıktan sonra gönderilecek.`; case `Unknown action`: return `Bilinmeyen eylem`; case `Posting status`: return `Gönderi durumu`; case `Validating...`: return `Doğrulanıyor...`; case `Verifying reCAPTCHA...`: return `reCAPTCHA doğrulanıyor ...`; case `Connecting to server...`: return `Sunucuya baglanıyor...`; case `Sending message to server...`: return `Sunucuya mesaj gönderiliyor...`; case `Message sent.`: return `Mesaj gönderildi.`; case `Waiting for message announcement...`: return `Mesaj duyurusu bekleniyor...`; case `Message posted! Redirecting...`: return `Mesaj gönderildi! Yönlendiriliyor...`; case `%s. Please solve a CAPTCHA to continue.`: return `%s. Devam etmek için lütfen bir CAPTCHA çözünüz.`; case `Spam check error:`: return `Spam denetimi hatası:`; case `Try clearing your browser's cookies. Create an account to avoid repeated incidents.`: return `Tarayıcınızın çerezlerini temizlemeyi deneyin. Bu gibi tekrarlardan kaçınmak için bir hesap oluşturun.`; case `Malformed Base64 in read post history cookie.`: return `Okuma geçmişi çerezinde hatalı Base64.`; case `Malformed deflated data in read post history cookie`: return `Okuma geçmişi çerezinde hatalı sıkıştırılmış veri`; case `Please enter a username`: return `Lütfen bir kullanıcı adı giriniz`; case `Username too long`: return `Kullanıcı adı çok uzun`; case `Password too long`: return `Şifre çok uzun`; case `Already logged in`: return `Zaten giriş yapılmış`; case `Already registered`: return `Zaten kayıtlı`; case `Can't edit this message. It has already been sent.`: return `Bu mesajı düzenleyemezsiniz. Zaten gönderildi.`; case `Can't edit this message. It has already been submitted for moderation.`: return `Bu mesajı düzenleyemezsiniz. Zaten denetim için gönderildi.`; case `StopForumSpam API error:`: return `StopForumSpam API hatası:`; case `StopForumSpam thinks you may be a spammer (%s last seen: %s, frequency: %s)`: return `StopForumSpam, spam yapan birisi olduğunuzdan şüpheleniyor (%s son görülme: %s, sıklık: %s)`; case `Log in`: return `Oturum aç`; case `Username:`: return `Kullanıcı adı:`; case `Password:`: return `Şifre:`; case `Remember me`: return `Beni Hatırla`; case `Register`: return `Kayıt ol`; case `to keep your preferences
and read post history on the server.`: return `(Ayarlarınızı ve gönderi geçmişinizi sunucuda saklamak için.)`; case `Confirm:`: return `Onaylayın:`; case `Please pick your password carefully.`: return `Lütfen şifrenizi dikkatlice seçin.`; case `There are no password recovery options.`: return `Şifre kurtarma seçeneği yoktur.`; case `Passwords do not match`: return `Şifre uyuşmuyor`; case `First`: return `İlk`; case `Prev`: return `Önceki`; case `Next`: return `Sonraki`; case `Last`: return `Son`; case `Advanced Search`: return `Gelişmiş Arama`; case `Find posts with...`: return `Aşağıdakilere uyan gönderileri bulur.`; case `all these words:`: return `Şu kelimelerin hepsi:`; case `this exact phrase:`: return `Tam olarak şu söz dizisi:`; case `none of these words:`: return `Şu kelimelerin hiçbiri:`; case `posted in the group:`: return `Gönderildiği grup:`; case `posted by:`: return `Gönderenin adı:`; case `posted by (email):`: return `Gönderenin e-posta'sı:`; case `in threads titled:`: return `Konu başlığı:`; case `containing:`: return `İçeriğinde geçen:`; case `posted between:`: return `Tarih aralığı:`; case `yyyy-mm-dd`: return `yyyy-aa-gg`; case `posted as new thread:`: return `Konunun ilk mesajı:`; case `Advanced search`: return `Gelişmiş ara`; case `Search`: return `Ara`; case `Invalid date: %s (%s)`: return `Geçersiz tarih: %s (%s)`; case `Start date must be before end date`: return `Başlangıç ​​tarihi bitiş tarihinden önce olmalıdır`; case `Invalid page number`: return `Geçersiz sayfa numarası`; case `Your search -`: return `Aramanız -`; case `- did not match any forum posts.`: return `- hiçbir forum gönderisiyle eşleşmedi.`; case `View this post`: return `Bu gönderiyi görüntüle`; case `Invalid path`: return `Geçersiz yol`; case `Legacy redirect - unsupported feature`: return `Eski yönlendirme - desteklenmeyen özellik`; case `Legacy redirect - article not found`: return `Eski yönlendirme - gönderi bulunamadı`; case `Legacy redirect - ambiguous artnum (group parameter missing)`: return `Eski yönlendirme - belirsiz artnum (grup parametresi eksik)`; case `No group specified`: return `Grup belirtilmedi`; case `(page %d)`: return `(sayfa %d)`; case `Unknown group`: return `Bilinmeyen grup`; case `%s group index`: return `%s grup dizini`; case `New posts on`: return `Yeni gönderiler:`; case `New threads on`: return `Yeni konular:`; case `No thread specified`: return `Konu belirtilmedi`; case `Viewing thread in basic view mode – click a post's title to open it in %s view mode`: return `Konu temel görünüm ile görüntüleniyor - %s olarak görüntülemek için bir konuya tıklayın`; case `No post specified`: return `Gönderi belirtilmedi`; case `(view single post)`: return `(tek gönderiyi görüntüle)`; case `Invalid URL`: return `Geçersiz URL`; case `No such group`: return `Böyle bir grup yok`; case `Posting to %s`: return `Alıcı: %s`; case `New thread`: return `Yeni konu`; case `Replying to "%s"`: return `Yanıtlanan: "%s"`; case `Post reply`: return `Yanıt gönder`; case `Posting`: return `Gönderi`; case `No post ID specified`: return `Gönderi kimliği belirtilmemiş`; case `Composing message`: return `Mesaj oluşturma`; case `Please log in to do that`: return `Lütfen giriş yapınız`; case `Subscribe to thread`: return `Konuya abone ol`; case `No subscription specified`: return `Abonelik belirtilmemiş`; case `View subscription`: return `Aboneliği görüntüle`; case `Unsubscribe`: return `Aboneliği iptal et`; case `You are not a moderator`: return `Yönetici değilsiniz`; case `Moderating post "%s"`: return `"%s" gönderisi denetleniyor`; case `Moderate post`: return `Gönderiyi denetle`; case `You can't flag posts`: return `Gönderileri işaretleyemezsiniz`; case `Flag "%s" by %s`: return `%2$s tarafından gönderilen "%1$s" gönderisini işaretle`; case `Flag post`: return `Gönderiyi işaretle`; case `You can't approve moderated drafts`: return `Denetlenmiş olan taslakları siz onaylayamazsınız`; case `Approving moderated draft`: return `Denetlenmiş olan taslak onaylanıyor`; case `Registration`: return `Kayıt`; case `Login error`: return `Giriş hatası`; case `Registration error`: return `Kayıt Hatası`; case `Account`: return `Hesap`; case `Change Password`: return `Şifre değiştir`; case `Export Data`: return `Dışa Aktar`; case `Delete Account`: return `Hesabı sil`; case `Help`: return `Yardım`; case `Forum activity summary`: return `Forum etkinliği özeti`; case `Feed type not specified`: return `Feed türü belirtilmedi`; case `Unknown feed type`: return `Bilinmeyen feed türü`; case `hours parameter exceeds limit`: return `hours parametresi sınırı aşıyor`; case `Not Found`: return `Bulunamadı`; case `Error`: return `Hata`; case `Index`: return `Dizin`; case `Log out`: return `Çıkış Yap`; case `Draft discarded.`: return `Taslak silindi.`; case `Settings saved.`: return `Ayarlar kaydedildi.`; case `Warning: cookie size approaching RFC 2109 limit.`: return `Uyarı: çerez boyutu RFC 2109 sınırına yaklaşıyor.`; case `Please consider %screating an account%s to avoid losing your read post history.`: return `Okuma geçmişinizi kaybetmemek için lütfen %shesap açtırın%s.`; case `Forums`: return `Forumlarda`; case `%s group`: return `%s grubunda`; case `View subscription:`: return ``; case `It looks like there's nothing here! No posts matched this subscription so far.`: return `Görünüşe göre burada hiçbir şey yok! Henüz bu abonelikle eşleşen gönderi yok.`; case `This subscription has been deactivated.`: return `Bu abonelik devre dışı bırakıldı.`; case `If you did not intend to do this, you can reactivate the subscription's actions on your %ssettings page%s.`: return `Bunu yapmak istemediyseniz, aboneliğin eylemlerini %sayarlar sayfanızda%s yeniden etkinleştirebilirsiniz.`; case `Hint`: return `İpucu`; case "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.": return "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."; case `Unknown or expired CAPTCHA challenge`: return `Bilinmeyen veya süresi dolmuş CAPTCHA testi`; case `The answer is incorrect`: return `Yanıt yanlış`; case `Akismet thinks your post looks like spam`: return `Akismet, gönderinizin spam gibi göründüğünü düşünüyor`; case `Akismet error:`: return `Akismet hatası:`; case `Latest announcements`: return `Son duyurular`; case `Active discussions`: return `Aktif konu`; case `ProjectHoneyPot thinks you may be a spammer (%s last seen: %d days ago, threat score: %d/255, type: %s)`: return `ProjectHoneyPot, spam yapan birisi olduğunuzdan şüpheleniyor (%s son görülme: %d gün önce, tehdit puanı: %d/255, tür: %s)`; case `From`: return `Gönderen`; case `Date`: return `Tarih`; case `In reply to`: return `Yanıtlanan:`; case `Attachments`: return `Ekler`; case `Parent post is not quoted.`: return `Alıntı yapılmamış.`; case `When replying to someone's post, you should provide some context for your replies by quoting the revelant parts of their post.`: return `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.`; case `Depending on the software (or its configuration) used to read your message, it may not be obvious which post you're replying to.`: return `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.`; case `Thus, when writing a reply, don't delete all quoted text: instead, leave just enough to provide context for your reply.`: return `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.`; case `You can also insert your replies inline (interleaved with quoted text) to address specific parts of the parent post.`: return `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.`; case `You are quoting a post other than the parent.`: return `Bir öncekinden farklı bir gönderiyi alıntılıyorsunuz.`; case `When replying a message, the message you are replying to is referenced in the post's headers.`: return `Yanıtladığınız mesaj yazının başlık alanlarında belirtilir.`; case `Depending on the software (or its configuration) used to read your message, your message may be displayed below its parent post.`: return `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.`; case `If your message contains a reply to a different post, following the conversation may become somewhat confusing.`: return `Mesajınız farklı bir gönderiye yanıt içerdiğinde konunun anlaşılırlığı güçleşebilir.`; case `Thus, make sure to click the "Reply" link on the actual post you're replying to, and quote the parent post for context.`: return `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.`; case `Parent post is not indicated.`: return `Yanıtlanan gönderi belirtilmemiş.`; case `When quoting someone's post, you should leave the "On (date), (author) wrote:" line.`: return `Bir gönderiyi alıntılarken, lütfen yanıtlanan gönderinin tarih ve yazar bilgisini taşıyan satırı silmeyin.`; case `Thus, this line provides important context for your replies regarding the structure of the conversation.`: return `O bilgi, sohbetin anlaşılmasına yardımcı olur.`; case `You are quoting multiple posts.`: return `Birden fazla gönderiden alıntı yapıyorsunuz.`; case `Thus, you should avoid replying to multiple posts in one reply.`: return `Tek bir yanıtta birden fazla gönderiye yanıt vermekten kaçınmalısınız.`; case `If applicable, you should split your message into several, each as a reply to its corresponding parent post.`: return `Mümkünse, yanıtlanmakta olan her mesaj için farklı yanıt yazmalısınız.`; case `You are top-posting.`: return `Yanıtınızı asıl gönderinin üst tarafına yazıyorsunuz.`; case `When replying a message, it is generally preferred to add your reply under the quoted parent text.`: return `Genel tercih, yanıtınızı alıntılanan metnin altına yazmanızdır.`; case `Depending on the software (or its configuration) used to read your message, your message may not be displayed below its parent post.`: return `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.`; case `In such cases, readers would need to first read the quoted text below your reply for context.`: return `Öyle ise, okuyucular önce yanıtınızın altındaki alıntıyı okumak zorunda kalırlar.`; case `Thus, you should add your reply below the quoted text (or reply to individual paragraphs inline), rather than above it.`: return `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.`; case `You are overquoting.`: return `Çok fazla alıntı yapmışsınız.`; case `The ratio between quoted and added text is vastly disproportional.`: return `Alıntılanan ve eklenen metin çok orantısız.`; case `Quoting should be limited to the amount necessary to provide context for your replies.`: return `Alıntı, yanıtınıza anlam katmaya yetecek kadar olmalıdır.`; case `Quoting posts in their entirety is thus rarely necessary, and is a waste of vertical space.`: return `Bu nedenle, metnin tamamını alıntılamak nadiren gereklidir ve alan israfıdır.`; case `Please trim the quoted text to just the relevant parts you're addressing in your reply, or add more content to your post.`: return `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.`; case `Don't use URL shorteners.`: return `URL kısaltıcıları kullanmayınız.`; case `URL shortening services, such as TinyURL, are useful in cases where space is at a premium, e.g. in IRC or Twitter messages.`: return `TinyURL gibi URL kısaltma hizmetleri, alanın kısıtlı olduğu IRC veya Twitter mesajları gibi ortamlarda yararlıdır.`; case `In other circumstances, however, they provide little benefit, and have the significant disadvantage of being opaque:`: return `Ancak diğer durumlarda çok az fayda sağlarlar ve bağlantının anlaşılırlığını düşürürler:`; case `readers can only guess where the link will lead to before they click it.`: return `okuyucular, tıklanan bağlantının nereye götüreceği konusunda tahminde bulunamazlar.`; case `Additionally, URL shortening services come and go - your link may work today, but might not in a year or two.`: return `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.`; case `Thus, do not use URL shorteners when posting messages online - post the full link instead, even if it seems exceedingly long.`: return `Bu nedenle, gönderilerinizde URL kısaltmaları kullanmak yerine, aşırı uzun görünüyor olsa bile tam bağlantıyı verin.`; case `If it is too long to be inserted inline, add it as a footnote instead.`: return `Satır içi için fazla uzun olduğunu düşündüğünüzde mesajınıza dipnot olarak ekleyebilirsiniz.`; case `Could not expand URL:`: return `URL genişletilemedi:`; case `Don't put links in the subject.`: return `Konu satırına bağlantı koymayın.`; case `Links in message subjects are usually not clickable.`: return `Konu satırlarındaki bağlantılar genellikle tıklanabilir değildir.`; case `Please move the link in the message body instead.`: return `Lütfen bağlantıyı mesaj içine taşıyın.`; case `Avoid replying to very old threads.`: return `Çok eski konuları yanıtlamaktan kaçının.`; case `The thread / post you are replying to is very old.`: return `Yanıtladığınız konu veya gönderi çok eski.`; case `Consider creating a new thread instead of replying to an existing one.`: return `Mevcut konuyu yanıtlamak yerine yeni bir konu başlatmayı düşünün.`; case `BlogSpam.net thinks your post looks like spam:`: return `BlogSpam.net, gönderinizin spam gibi göründüğünü düşünüyor:`; case `BlogSpam.net error:`: return `BlogSpam.net hatası:`; case `BlogSpam.net unexpected response:`: return `BlogSpam.net beklenmeyen yanıt:`; case `Perform which moderation actions on this post?`: return `Hangi denetim işlemleri uygulansın?`; case `Delete local cached copy of this post from DFeed's database`: return `Bu gönderinin yerel kopyasını DFeed veritabanından sil`; case `Ban poster (place future posts in moderation queue)`: return `Göndericiyi yasakla (gelecekteki gönderileri de denetlensin)`; case `Delete source copy from %-(%s/%)`: return `%-(%s/%) kaynağındaki asıl kopyasını sil`; case `Reason:`: return `Nedeni:`; case `It looks like you've already flagged this post.`: return `Görünüşe göre bu gönderiyi zaten işaretlemişsiniz.`; case `Would you like to %sunflag it%s?`: return `%sİşaretini kaldırmak%s ister misiniz?`; case `Are you sure you want to flag this post?`: return `Bu gönderiyi işaretlemek istediğinizden emin misiniz?`; case `You can't flag posts!`: return `Gönderileri işaretleyemezsiniz!`; case `You can't flag this post!`: return `Bu gönderiyi işaretleyemezsiniz!`; case `You've already flagged this post.`: return `Bu gönderiyi zaten işaretlediniz.`; case `Post flagged.`: return `Gönderi işaretlendi.`; case `Return to post`: return `Gönderiye dön`; case `It looks like you've already unflagged this post.`: return `Görünüşe göre bu gönderinin işaretini zaten kaldırmışsınız.`; case `Would you like to %sflag it%s?`: return `%sİşaretlemek%s ister misiniz?`; case `Are you sure you want to unflag this post?`: return `Bu gönderinin işaretini kaldırmak istediğinizden emin misiniz?`; case `Unflag`: return `İşareti kaldır`; case `You've already unflagged this post.`: return `Bu gönderinin işaretini zaten kaldırdınız.`; case `Post unflagged.`: return `İşaret kaldırıldı.`; case `You can view it here.`: return `Buradan görüntüleyebilirsiniz.`; case `This is not a post in need of moderation. Its status is currently:`: return `Bu, denetlenmesi gereken bir gönderi değil. Şu andaki durumu:`; case `Are you sure you want to approve this post?`: return `Bu gönderiyi onaylamak istediğinizden emin misiniz?`; case `Approve`: return `Onayla`; case `Post approved!`: return `Gönderi onaylandı!`; case `View posting`: return `Gönderiyi görüntüle`; case `Toggle navigation`: return `Gezinti çubuğunu aç / kapa`; case `Loading message`: return `Mesaj yükleniyor`; case `Your browser does not support HTML5 pushState.`: return `Tarayıcınızda 'HTML5 pushState' olanağı yok.`; case `Keyboard shortcuts`: return `Klavye kısayolları`; case `Ctrl`: return `Ctrl`; case `Down Arrow`: return `Aşağı Ok`; case `Select next message`: return `Sonraki mesajı seç`; case `Up Arrow`: return `Yukarı Ok`; case `Select previous message`: return `Önceki mesajı seç`; case `Enter / Return`: return `Enter / Return`; case `Open selected message`: return `Seçili mesajı aç`; case `Mark as unread`: return `Okunmamış olarak işaretle`; case `Open link`: return `Bağlantı aç`; case `Space Bar`: return `Boşluk Tuşu`; case `Scroll message / Open next unread message`: return `Mesajı ilerlet / Bir sonraki okunmamış mesajı aç`; case `(press any key or click to close)`: return `(kapatmak için bir tuşa basın veya tıklayın)`; case `Draft saved.`: return `Taslak kaydedildi.`; case `Error auto-saving draft.`: return `Taslağı kaydederken hata oluştu.`; case `The CAPTCHA solution was incorrect`: return `CAPTCHA çözümü doğru değil`; case `The solution was received after the CAPTCHA timed out`: return `Çözüm, CAPTCHA zaman aşımına uğradıktan sonra alındı`; case `s`: return `sn`; case `m`: return `dk`; case `h`: return `sa`; case `d`: return `g`; case `HTML-like text was discarded.`: return `HTML benzeri metin atıldı.`; case `Your message seems to contain content which the Markdown renderer has interpreted as raw HTML.`: return `Mesajınız, Markdown yorumlayıcısı tarafından ham HTML olarak yorumlanan içerik içeriyor gibi görünüyor.`; case `Since using raw HTML is not allowed, this content has been discarded from the rendered output.`: return `Ham HTML kullanımına izin verilmediğinden, bu içerik oluşturulan çıktıdan atılmıştır.`; case `If your intention was to use HTML for formatting, please revise your message to use the %savailable Markdown formatting syntax%s instead.`: return `Biçimlendirme için HTML kullanmak istediyseniz, lütfen mesajınızı %sMarkdown sözdizimi%s ile yeniden yazın.`; case "If your intention was to use characters such as > < & verbatim in your message, you can prevent them from being interpreted as special characters by escaping them with a backslash character (\\).": return "Mesajınızda > < & gibi karakterleri aynen kullanmak istediyseniz, bunları ters eğik çizgi karakteriyle (\\) kaçırarak özel karakter olarak yorumlanmalarını engelleyebilirsiniz."; case `Clicking "Fix it for me" will apply this escaping automatically.`: return `"Benim için düzelt" üzerine tıklamak bu kaçırma işlemini otomatik olarak uygular.`; case `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.`: return `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.`; case `Avoid using HTML entities.`: return `HTML varlıklarını kullanmaktan kaçının.`; case `HTML character entities, such as "&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.`: return `"&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.`; case `As such, it is preferable to use the Unicode characters directly instead of their HTML entity encoded form (e.g. "—" instead of "&mdash;").`: return `Bu nedenle, Unicode karakterlerini HTML varlık kodlu biçimleri yerine doğrudan kullanmak tercih edilir (örn. "&mdash;" yerine "—").`; case `If you did not mean to use an HTML entity to represent a character, escape the leading ampersand (&) by prepending a backslash (e.g. "\&").`: return `Bir karakteri temsil etmek için bir HTML varlığı kullanmak istemediyseniz, başındaki ve işaretini (&) ters eğik çizgi ekleyerek kaçırın (örn. "\&").`; case `A code block may be misformatted.`: return `Bir kod bloğu yanlış biçimlendirilmiş olabilir.`; case `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.)`: return `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.)`; case "When using %sMarkdown formatting%s, you should either wrap code blocks in fences (``` lines), or indent all lines by four spaces.": return "%sMarkdown biçimlendirmesi%s kullanırken, kod bloklarını çitlerle (``` satırları) sarmalı veya tüm satırları dört boşlukla girintilemelisisiniz."; case `Click "Fix it for me" to have the forum software attempt to do this automatically.`: return `Otomatik düzeltme için "Benim için düzelt" üzerine tıklayın.`; case `Alternatively, you may uncheck the "Enable Markdown" checkbox to disable Markdown rendering completely, which will cause whitespace to be rendered verbatim.`: return `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.`; case `Markdown syntax was used, but Markdown is disabled.`: return `Markdown sözdizimi kullanıldı, ancak Markdown devre dışı bırakıldı.`; case `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.)`: return `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.)`; case `Click "Fix it for me" to enable Markdown rendering automatically.`: return `Markdown işlemeyi otomatik olarak etkinleştirmek için "Benim için düzelt" üzerine tıklayın.`; case `Failed to render Markdown:`: return `Markdown işlenemedi:`; case `Enable %sMarkdown%s`: return `%sMarkdown%s'ı etkinleştir`; case `No post form submitted. Please click "Back" in your web browser to navigate back to the posting form, and resubmit it.`: return `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.`; case `Unban by key`: return `Anahtara göre yasağı kaldır`; case `Try to moderate in other message sinks (e.g. Twitter)`: return `Diğer mesaj havuzlarında denetlemeyi deneyin (örn. Twitter)`; case `The specified key is not banned.`: return `Belirtilen anahtar yasaklanmamış.`; case `Key to unban:`: return `Yasağı kaldırılacak anahtar:`; case `Look up`: return `Ara`; case `Select which keys to unban:`: return `Yasağı kaldırılacak anahtarları seçin:`; case `Unban Selected`: return `Seçilenlerin Yasağını Kaldır`; case `No keys selected to unban`: return `Yasağı kaldırılacak hiçbir anahtar seçilmedi`; case `Unbanned %d key(s)!`: return `%d anahtarın yasağı kaldırıldı!`; case `Unban another key`: return `Başka bir anahtarın yasağını kaldır`; case `Render Markdown posts as HTML. If disabled, they will just be shown as-is, in plain text.`: return `Markdown gönderilerini HTML olarak işleyin. Devre dışı bırakılırsa, düz metin olarak olduğu gibi gösterilecektir.`; case `Render Markdown`: return `Markdown İşle`; case `I am not a robot`: return `Ben robot değilim`; case `Please confirm you are not a robot`: return `Lütfen robot olmadığınızı onaylayın`; case `Your subject contains a keyword that triggers moderation`: return `Konu başlığınız denetimi tetikleyen bir anahtar kelime içeriyor`; case `%s's profile`: return `%s kullanıcısının profili`; case `No user specified`: return `Kullanıcı belirtilmedi`; case `Users`: return `Kullanıcılar`; case `User not found`: return `Kullanıcı bulunamadı`; case `Subscribe to user`: return `Kullanıcıya abone ol`; case `View Gravatar profile`: return `Gravatar profilini görüntüle`; case `Gravatar profile`: return `Gravatar profili`; case `Subscribe to this user's posts`: return `Bu kullanıcının gönderilerine abone ol`; case `Posts:`: return `Gönderi:`; case `Threads started:`: return `Başlattığı konular:`; case `Replies:`: return `Yanıtlar:`; case `First post:`: return `İlk gönderi:`; case `Last seen:`: return `Son görülme:`; case `Most active in:`: return `En aktif olduğu grup:`; case `Recent posts`: return `Son gönderiler`; case `Subject`: return `Konu`; case `View all %d posts`: return `Tüm %d gönderiyi görüntüle`; case `See also`: return `Ayrıca bakınız`; case `User`: return `Kullanıcı`; case `Last seen`: return `Son görülme`; case `%d posts`: return `%d gönderi`; case `Announcements widget not configured`: return `Duyurular bileşeni yapılandırılmamış`; default: return null; } } private string pluralOf(string unit) { switch (unit) { case "second": return "saniye"; case "minute": return "dakika"; case "hour": return "saat"; case "day": return "gün"; case "week": return "hafta"; case "month": return "ay"; case "year": return "yıl"; case "thread": return "konu"; case "post": return "gönderi"; case "forum post": return "forum gönderisi"; case "subscription": // This seems to be used only in the `No new posts matching your %s%s%s.` string where it happens to be the first // word in Turkish, so we use it capitalized for now. return "Abonelikleriniz"; case "unread post": return "okunmamış gönderi"; case "registered user": return "kayıtlı kullanıcı"; case "visit": // This seems to be used only in the `You have read a total of %s %s during your %s.` string where it happens to // be the first word in Turkish, so we use it capitalized for now. return "Ziyaretiniz"; case "reply": return "yanıt"; case "new reply": return "yeni yanıt"; case "user has created": return "kullanıcı tarafından"; default: assert(false, "Unknown unit: " ~ unit); } } string plural(string unit)(long amount) { // There are no plural forms of nouns in Turkish. return pluralOf(unit); } const WeekdayShortNames = ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"]; const WeekdayLongNames = ["Pazar", "Pazartesi", "Salı", "Çarşamba", "Perşembe", "Cuma", "Cumartesi"]; const MonthShortNames = ["Oca", "Şub", "Mar", "Nis", "May", "Haz", "Tem", "Ağu", "Eyl", "Eki", "Kas", "Ara"]; const MonthLongNames = ["Ocak", "Şubat", "Mart", "Nisan", "Mayıs", "Haziran", "Temmuz", "Ağustos", "Eylül", "Ekim", "Kasım", "Aralık"]; ================================================ FILE: src/dfeed/mail.d ================================================ /* Copyright (C) 2015, 2017, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.mail; import std.exception; import std.format; import std.process; import dfeed.site; /// Send a fully-formatted (incl. headers) message by email. void sendMail(string message) { auto pipes = pipeProcess(["sendmail", "-t", "-r", "%s ".format(site.name.length ? site.name : site.host, site.host), ], Redirect.stdin); pipes.stdin.rawWrite(message); pipes.stdin.close(); enforce(wait(pipes.pid) == 0, "mail program failed"); } ================================================ FILE: src/dfeed/message.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2018, 2020, 2021, 2024 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.message; import std.algorithm; import std.array; import std.conv; import std.exception; import std.range; import std.string; public import ae.net.ietf.message; import ae.net.ietf.headers : decodeTokenHeader; import ae.net.ietf.url; import ae.net.ietf.wrap; import ae.utils.array; import ae.utils.meta; import dfeed.bitly; import dfeed.common; import dfeed.groups; import dfeed.site; alias std.string.indexOf indexOf; class Rfc850Post : Post { /// Internet message. Rfc850Message msg; alias msg this; /// Internal database index int rowid; /// Thread ID obtained by examining parent posts string cachedThreadID; /// URLs for IRC. string url, shortURL; /// For IRC. string verb; /// Result of threadify() Rfc850Post[] children; /// If no, don't announce this message and don't trigger subscriptions Fresh fresh = Fresh.yes; this(string _message, string _id=null, int rowid=0, string threadID=null) { msg = new Rfc850Message(_message); if (!msg.id && _id) msg.id = _id; this.rowid = rowid; this.cachedThreadID = threadID; this.verb = reply ? "replied to" : "posted"; int bugzillaCommentNumber; if ("X-Bugzilla-Who" in headers) { // Special case for Bugzilla emails author = authorEmail = headers["X-Bugzilla-Who"]; foreach (line; content.split("\n")) if (line.endsWith("> changed:")) author = line[0..line.indexOf(" <")]; else if (line.startsWith("--- Comment #") && line.indexOf(" from ")>0 && line.indexOf(" <")>0 && line.endsWith(" ---")) { author = line[line.indexOf(" from ")+6 .. line.indexOf(" <")]; bugzillaCommentNumber = to!int(line["--- Comment #".length .. line.indexOf(" from ")]); } } else if ("List-Id" in headers) { auto list = headers["List-Id"]; auto listId = list.findSplit(" <")[2].findSplit(".puremagic.com>")[0]; auto suffix = " via " ~ listId.toLower(); if (listId.length && author.toLower().endsWith(suffix)) author = author[0 .. $ - suffix.length]; } if ("X-DFeed-List" in headers && !xref.length) xref = [Xref(headers["X-DFeed-List"])]; // Fallback for local posts that have Newsgroups header but no Xref if ("Newsgroups" in headers && !xref.length) foreach (group; headers["Newsgroups"].split(",")) xref ~= Xref(group.strip()); if ("List-ID" in headers && subject.startsWith("[") && xref.length == 1) { auto p = subject.indexOf("] "); if (p >= 0 && !icmp(subject[1..p], xref[0].group)) subject = subject[p+2..$]; } if (subject.startsWith("[Issue ")) { auto urlBase = headers.get("X-Bugzilla-URL", "http://d.puremagic.com/issues/"); url = urlBase ~ "show_bug.cgi?id=" ~ subject.split(" ")[1][0..$-1]; verb = bugzillaCommentNumber ? "commented on" : reply ? "updated" : "created"; if (bugzillaCommentNumber > 0) url ~= "#c" ~ .text(bugzillaCommentNumber); } else if (id.length) { url = format("%s://%s%s", site.proto, site.host, idToUrl(id)); if (!doArchive) url = null; } super.time = msg.time; if ("X-Original-Date" in headers) fresh = Fresh.no; } private this(Rfc850Message msg) { this.msg = msg; } static Rfc850Post newPostTemplate(string groups) { return new Rfc850Post(Rfc850Message.newPostTemplate(groups)); } Rfc850Post replyTemplate() { return new Rfc850Post(msg.replyTemplate()); } /// Set headers and message. void compile() { msg.compile(); headers["User-Agent"] = "DFeed"; } override void formatForIRC(void delegate(string) handler) { if (getImportance() >= Importance.normal && url && !shortURL) return shortenURL(url, (string shortenedURL) { shortURL = shortenedURL; formatForIRC(handler); }); handler(format("%s%s %s %s%s", xref.length ? "[" ~ publicGroupNames.join(",") ~ "] " : null, author == "" ? "" : filterIRCName(author), verb, subject == "" ? "" : `"` ~ subject ~ `"`, shortURL ? ": " ~ shortURL : url ? ": " ~ url : "", )); } override Importance getImportance() { if (!fresh) return Importance.none; if (msg.headers.get("X-List-Administrivia", "").icmp("yes") == 0) return Importance.none; auto group = getGroup(this); if (!reply && group && group.announce) return Importance.high; // GitHub notifications are already grabbed from RSS if (author == "GitHub") return Importance.low; if (where == "") return Importance.low; if (where.isIn(ANNOUNCE_REPLIES)) return Importance.normal; return !reply || author.isIn(VIPs) ? Importance.normal : Importance.low; } @property final bool doArchive() { return msg.headers.get("X-No-Archive", "").icmp("yes") != 0; } @property string[] publicGroupNames() { return xref.map!(x => x.group.getGroupInfo.I!(gi => gi ? gi.publicName : x.group)).array(); } @property string where() { string[] groups; foreach (x; xref) groups ~= x.group; return groups.join(","); } /// Arrange a bunch of posts in a thread hierarchy. Returns the root posts. static Rfc850Post[] threadify(Rfc850Post[] posts) { Rfc850Post[string] postLookup; foreach (post; posts) { post.children = null; postLookup[post.id] = post; } Rfc850Post[] roots; postLoop: foreach (post; posts) { foreach_reverse(reference; post.references) { auto pparent = reference in postLookup; if (pparent) { (*pparent).children ~= post; continue postLoop; } } roots ~= post; } return roots; } /// Get content excluding quoted text. @property string newContent() { auto paragraphs = content.unwrapText(wrapFormat); auto index = paragraphs.length.iota.filter!(i => !paragraphs[i].quotePrefix.length && (i+1 >= paragraphs.length || !paragraphs[i+1].quotePrefix.length) ).array; return paragraphs.indexed(index).map!(p => p.text).join("\n"); } /// Return configured CAPTCHA method. @property string captcha() { auto groups = xref.map!(x => x.group.getGroupInfo()); enforce(groups.length, "No groups"); auto group = groups.front; auto captchas = groups.map!(group => group.captcha); enforce(captchas.uniq.walkLength == 1, "Conflicting CAPTCHA methods"); return captchas.front; } private: string[] ANNOUNCE_REPLIES = []; string[] VIPs = ["Walter Bright", "Andrei Alexandrescu", "Sean Kelly", "Don", "dsimcha"]; } unittest { auto post = new Rfc850Post("From: msonke at example.org (=?ISO-8859-1?Q?S=F6nke_Martin?=)\n\nText"); assert(post.author == "Sönke Martin"); assert(post.authorEmail == "msonke@example.org"); post = new Rfc850Post("Date: Tue, 06 Sep 2011 14:52 -0700\n\nText"); assert(post.time.year == 2011); } // *************************************************************************** template urlEncode(string forbidden, char escape = '%') { alias encoder = encodeUrlPart!(c => c >= 0x20 && c < 0x7F && forbidden.indexOf(c) < 0 && c != escape, escape); string urlEncode(string s) { // !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ // " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~" return encoder(s); } } string urlDecode(string encoded) { return decodeUrlParameter!false(encoded); } /// Encode a string to one suitable for an HTML anchor string encodeAnchor(string s) { //return encodeUrlParameter(s).replace("%", "."); // RFC 3986: " \"#%<>[\\]^`{|}" return urlEncode!(" !\"#$%&'()*+,/;<=>?@[\\]^`{|}~", ':')(s); } alias urlEncodeMessageUrl = urlEncode!(" \"#%/<>?[\\]^`{|}", '%'); /// Get relative URL to a post ID. string idToUrl(string id, string action = "post", int page = 1) { enforce(id.startsWith('<') && id.endsWith('>'), "Invalid message ID: " ~ id); // RFC 3986: // pchar = unreserved / pct-encoded / sub-delims / ":" / "@" // sub-delims = "!" / "$" / "&" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" // unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" string path = "/" ~ action ~ "/" ~ urlEncodeMessageUrl(id[1..$-1]); assert(page >= 1); if (page > 1) path ~= "?page=" ~ text(page); return path; } /// Get URL fragment / anchor name for a post on the same page. string idToFragment(string id) { enforce(id.startsWith('<') && id.endsWith('>'), "Invalid message ID: " ~ id); return "post-" ~ encodeAnchor(id[1..$-1]); } GroupInfo getGroup(Rfc850Post post) { enforce(post.xref.length, "No groups found in post"); return getGroupInfo(post.xref[0].group); } bool isMarkdown(Rfc850Message post) { auto contentType = decodeTokenHeader(post.headers.get("Content-Type", null)); return contentType.value == "text/plain" && contentType.properties.get("markup", null) == "markdown"; } ================================================ FILE: src/dfeed/paths.d ================================================ /* Copyright (C) 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Path resolution with site overlay support. /// /// Files are searched in order: /// 1. site/ (site-specific, highest priority) /// 2. defaults/ (generic fallback) /// /// This allows site-specific configuration and branding to override /// generic defaults without modifying the core DFeed repository. module dfeed.paths; import std.file : exists; import std.path : buildPath, dirSeparator; import std.typecons : tuple; immutable string[] siteSearchPaths = ["site", "site-defaults"]; /// Resolve the location of a site file through the overlay. /// Path is relative to site root, e.g. "config/site.ini" or "web/skel.htt". /// Returns the first overlay where the file is found, /// or null if the file is not found in any overlay. string resolveSiteFileBase(string relativePath) { foreach (base; siteSearchPaths) { auto fullPath = buildPath(base, relativePath); if (exists(fullPath)) return base ~ dirSeparator; } return null; } /// Resolve a site file through the overlay. /// Path is relative to site root, e.g. "config/site.ini" or "web/skel.htt" string resolveSiteFile(string relativePath) { auto base = resolveSiteFileBase(relativePath); if (!base) { // Return first path for error messages (file doesn't exist anywhere) base = siteSearchPaths[0]; } return buildPath(base, relativePath); } /// Resolve the location of a static file through the overlay. /// Returns the base directory for serving `relativePath`, /// or null if the file doesn't exist anywhere. /// `relativePath` is expected to start with `/` (root-relative web path). auto resolveStaticFileBase(string webPath) { import std.algorithm.searching : skipOver; import std.exception : enforce; // Convert web path to relative path auto relativePath = webPath; relativePath.skipOver("/") .enforce("Web path must start with /"); relativePath = buildPath("web", "static", relativePath); auto base = resolveSiteFileBase(relativePath); if (!base) return null; return base.buildPath("web", "static") ~ dirSeparator; } ================================================ FILE: src/dfeed/progs/dfeed.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2021, 2023 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.progs.dfeed; import std.getopt; import std.stdio : stderr; import ae.net.asockets; import ae.net.ssl.openssl; import ae.utils.meta; import ae.utils.sini; import dfeed.backup; import dfeed.common; import dfeed.debugging; import dfeed.web.web.server; // Sources import dfeed.sources.github; import dfeed.sources.mailman; import dfeed.sources.mailrelay; import dfeed.sources.newsgroups; import dfeed.sources.socket; import dfeed.sources.web.feed; import dfeed.sources.web.reddit; import dfeed.sources.web.stackoverflow; // Sinks import dfeed.sinks.irc; import dfeed.sinks.messagedb; import dfeed.sinks.subscriptions; import dfeed.sinks.twitter; import dfeed.web.posting; bool noDownload; void main(string[] args) { bool refresh; bool noSources; getopt(args, "q|quiet", {}, // handled by ae.sys.log "refresh", &refresh, "no-sources", &noSources, "no-download", &noDownload, ); // Create sources if (!noSources) { createServices!NntpSource ("sources/nntp"); createServices!MailRelay ("sources/mailrelay"); createServices!Feed ("sources/feeds"); createServices!StackOverflow("sources/stackoverflow"); createServices!Reddit ("sources/reddit"); createServices!SocketSource ("sources/socket"); createServices!GitHub ("sources/github"); if (!noDownload) createServices!Mailman ("sources/mailman"); } if (refresh) new MessageDBSource(); // Create sinks createServices!IrcSink("sinks/irc"); new MessageDBSink(refresh ? Yes.update : No.update); new PostingNotifySink(); new SubscriptionSink(); createServices!TwitterSink("sinks/twitter"); // Start web server startWebUI(); startNewsSources(); socketManager.loop(); if (!dfeed.common.quiet) stderr.writeln("Exiting."); } /// Avoid any problems (bugs or missed messages) caused by downloader/listener running /// simultaneously or sequentially by doing the following: /// 1. Note NNTP server time before starting downloader (sync) /// 2. Download new messages /// 3. Start listener with querying for new messages since the download START. class NntpSource { alias Config = NntpConfig; this(Config config) { auto listener = new NntpListenerSource(config.host); if (noDownload) listener.startListening; else { auto downloader = new NntpDownloader(config.host, isDebug ? NntpDownloader.Mode.newOnly : NntpDownloader.Mode.fullPurge); downloader.handleFinished = &listener.startListening; } } } ================================================ FILE: src/dfeed/progs/nntpdownload.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.progs.nntpdownload; import std.exception; import std.getopt; import ae.net.asockets; import ae.net.shutdown; import dfeed.common; import dfeed.database; import dfeed.sources.newsgroups; import dfeed.sinks.messagedb; void main(string[] args) { bool full, purge; getopt(args, "f|full", &full, "purge", &purge, ); enforce(!(full && purge), "Specify either --full or --purge, not both"); auto mode = purge ? NntpDownloader.Mode.fullPurge : full ? NntpDownloader.Mode.full : NntpDownloader.Mode.newOnly; static class Downloader : NntpDownloader { alias Config = NntpConfig; this(Config config, NntpDownloader.Mode mode) { super(config.host, mode); } } createServices!Downloader("sources/nntp", mode); new MessageDBSink(); mixin(DB_TRANSACTION); startNewsSources(); socketManager.loop(); } ================================================ FILE: src/dfeed/progs/sendspamfeedback.d ================================================ /* Copyright (C) 2014, 2015, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.progs.sendspamfeedback; import std.file; import std.stdio; import std.string; import ae.net.asockets; import dfeed.web.posting; import dfeed.web.spam; void main(string[] args) { files: foreach (fn; args[1..$]) { if (fn.length == 20) fn = dirEntries("logs", "* - PostProcess-" ~ fn ~ ".log", SpanMode.shallow).front.name; writeln("--------------------------------------------------------------------"); auto pp = new PostProcess(fn); write(pp.post.message); writeln(); writeln("--------------------------------------------------------------------"); SpamFeedback feedback = SpamFeedback.unknown; while (feedback == SpamFeedback.unknown) { write("Is this message spam or ham? "); switch (readln().chomp()) { case "spam": feedback = SpamFeedback.spam; break; case "ham": feedback = SpamFeedback.ham; break; case "skip": continue files; default: break; } } void handler(Spamicity spamicity, string message) { writeln(spamicity < spamThreshold ? "OK!" : "Error: " ~ message); } sendSpamFeedback(pp, &handler, feedback); socketManager.loop(); } } /// Work around link error void foo() { import std.array; auto a = appender!string(); a.put("test"d); dchar c = 't'; a.put(c); } ================================================ FILE: src/dfeed/progs/unban.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.progs.unban; import ae.sys.log; import std.algorithm.searching; import std.format; import std.stdio; import std.string; import dfeed.web.moderation : loadBanList, saveBanList, banned, parseParents; void main(string[] args) { string[] logLines; void log(string s) { logLines ~= s; writeln(s); } loadBanList(); string[][string] parents; string[][string] children; foreach (key, reason; banned) { auto p = parseParents(reason); parents[key] = p; foreach (parent; p) children[parent] ~= key; } string[] queue; size_t total; void unban(string key, string reason) { if (key in banned) { log(format("Unbanning %s (%s)", key, reason)); banned.remove(key); total++; queue ~= key; } } foreach (arg; args[1..$]) unban(arg, "command line"); while (queue.length) { auto key = queue[0]; queue = queue[1..$]; foreach (p; parents.get(key, null)) unban(p, "Parent of " ~ key); foreach (c; children.get(key, null)) unban(c, "Child of " ~ key); } writefln("Unbanning a total of %d keys.", total); writeln("Type 'yes' to continue"); if (readln().strip() != "yes") { writeln("Aborting"); return; } auto logFile = fileLogger("Unban"); foreach (line; logLines) logFile(line); logFile.close(); saveBanList(); writeln("Restart DFeed to apply ban list."); } ================================================ FILE: src/dfeed/sinks/cache.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sinks.cache; import dfeed.common; import dfeed.database; version(Posix) import ae.sys.signals; int dbVersion = 1; /// Fake sink used only to invalidate the cache on new data. final class CacheSink : NewsSink { override void handlePost(Post post, Fresh fresh) { dbVersion++; } } struct Cached(T) { int cacheVersion; T cachedData; T opCall(lazy T dataSource) { if (cacheVersion != dbVersion) { cachedData = dataSource; cacheVersion = dbVersion; debug(NOCACHE) cacheVersion = -1; } return cachedData; } } /// Clears the whole set when the cache is invalidated, to save memory struct CachedSet(K, T) { int cacheVersion; T[K] cachedData; T opCall(K key, lazy T dataSource) { if (cacheVersion != dbVersion) { cachedData = null; cacheVersion = dbVersion; } auto pdata = key in cachedData; if (pdata) return *pdata; else return cachedData[key] = dataSource; } } static this() { new CacheSink(); version(Posix) addSignalHandler(SIGHUP, { dbVersion++; }); } ================================================ FILE: src/dfeed/sinks/irc.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2016, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sinks.irc; import std.algorithm.comparison; import std.datetime; import std.file; import std.string; import ae.net.asockets; import ae.net.irc.client; import ae.net.shutdown; import ae.sys.log; import ae.sys.timing; import ae.utils.text; import dfeed.common; alias core.time.TickDuration TickDuration; /// IRC color code for sent lines enum ircColor = 14; // Dark gray /// Format string for IRC announcements (as raw IRC protocol line) const ircFormat = "PRIVMSG %s :\x01ACTION \x03" ~ format("%02d", ircColor) ~ "%s\x01"; final class IrcSink : NewsSink { static struct Config { string network; string server; ushort port = 6667; string nick; string channel; string channel2; } this(Config config) { if (config.channel.length && !config.channel.startsWith("#")) config.channel = '#' ~ config.channel; if (config.channel2.length && !config.channel2.startsWith("#")) config.channel2 = '#' ~ config.channel2; if (!config.network) config.network = config.server.split(".")[max(2, $)-2].capitalize(); this.config = config; tcp = new TcpConnection(); irc = new IrcClient(tcp); irc.encoder = irc.decoder = &nullStringTransform; irc.exactNickname = true; irc.log = createLogger("IRC-"~config.network); irc.handleConnect = &onConnect; irc.handleDisconnect = &onDisconnect; irc.handleInvite = &onInvite; connect(); addShutdownHandler((scope const(char)[] reason) { stopping = true; if (connecting || connected) irc.disconnect("DFeed shutting down: " ~ cast(string)reason); }); } @property string network() { return config.network; } void sendMessage(string recipient, string message) { if (connected) irc.message(recipient, message); } protected: override void handlePost(Post post, Fresh fresh) { if (!fresh) return; if (post.time < Clock.currTime() - dur!"days"(1)) return; // ignore posts older than a day old (e.g. StackOverflow question activity bumps the questions) auto importance = post.getImportance(); if (!importance) return; bool important = importance >= Post.Importance.normal; if (important || haveUnimportantListeners()) { post.formatForIRC((string summary) { if (connected) { summary = summary.newlinesToSpaces(); if (config.channel.length && important) irc.sendRaw(format(ircFormat, config.channel , summary)); if (config.channel2.length) irc.sendRaw(format(ircFormat, config.channel2, summary)); } }); } } private: TcpConnection tcp; IrcClient irc; immutable Config config; bool connecting, connected, stopping; void connect() { irc.nickname = config.nick; irc.realname = "https://github.com/CyberShadow/DFeed"; tcp.connect(config.server, config.port); connecting = true; } void onConnect() { connecting = false; if (config.channel.length) irc.join(config.channel); if (config.channel2.length) irc.join(config.channel2); connected = true; } void onDisconnect(string reason, DisconnectType type) { connecting = connected = false; if (type != DisconnectType.requested && !stopping) setTimeout(&connect, 10.seconds); } /// This function exists for the sole reason of avoiding creation of /// shortened URLs (thus, needlessly polluting bit.ly) when no one /// will be there to see them. bool haveUnimportantListeners() { return config.channel2.length && config.channel2 in irc.channels && irc.channels[config.channel2].users.length > 1; } void onInvite(string invitee, string channel) { if (channel == config.channel || channel == config.channel2) irc.join(channel); } } ================================================ FILE: src/dfeed/sinks/messagedb.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2018, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sinks.messagedb; import std.algorithm; import std.ascii; import std.conv; import std.string; import ae.sys.log; import ae.sys.timing; import ae.utils.digest; import dfeed.common; import dfeed.database; import dfeed.message; final class MessageDBSink : NewsSink { alias Update = Flag!"update"; this(Update update=Update.no) { log = createLogger("MessageDBSink").asyncLogger(); this.update = update; } private: Logger log; Update update; protected: override void handlePost(Post post, Fresh fresh) { auto message = cast(Rfc850Post)post; if (!message) return; log(format("Saving message %s (%s)", message.id, message.where)); if (!message.doArchive) { log("Archiving disabled, not saving."); return; } scope(success) { if (transactionDepth == 1) // This is a batch operation if (flushTransactionEvery(50)) log("Transaction flushed"); } mixin(DB_TRANSACTION); if (!message.rowid) foreach (int postRowid; query!"SELECT `ROWID` FROM `Posts` WHERE `ID` = ?".iterate(message.id)) message.rowid = postRowid; if (message.rowid) { log(format("Message %s already present with ROWID=%d", message.id, message.rowid)); if (update) { query!"UPDATE [Posts] SET [ID]=?, [Message]=?, [Author]=?, [AuthorEmail]=?, [Subject]=?, [Time]=?, [ParentID]=?, [ThreadID]=? WHERE [ROWID] = ?" .exec(message.id, message.message, message.author, message.authorEmail, message.rawSubject, message.time.stdTime, message.parentID, message.threadID, message.rowid); log("Updated."); } } else { query!"INSERT INTO `Posts` (`ID`, `Message`, `Author`, `AuthorEmail`, `Subject`, `Time`, `ParentID`, `ThreadID`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)" .exec(message.id, message.message, message.author, message.authorEmail, message.rawSubject, message.time.stdTime, message.parentID, message.threadID); message.rowid = db.lastInsertRowID.to!int; log(format("Message %s saved with ROWID=%d", message.id, message.rowid)); } foreach (xref; message.xref) { query!"INSERT OR IGNORE INTO `Groups` (`Group`, `ArtNum`, `ID`, `Time`) VALUES (?, ?, ?, ?)" .exec(xref.group, xref.num, message.id, message.time.stdTime); long threadIndex = 0, created, updated; foreach (long rowid, long threadCreated, long threadUpdated; query!"SELECT `ROWID`, `Created`, `LastUpdated` FROM `Threads` WHERE `ID` = ? AND `Group` = ?".iterate(message.threadID, xref.group)) threadIndex = rowid, created = threadCreated, updated = threadUpdated; if (!threadIndex) // new thread query!"INSERT INTO `Threads` (`Group`, `ID`, `LastPost`, `Created`, `LastUpdated`) VALUES (?, ?, ?, ?, ?)".exec(xref.group, message.threadID, message.id, message.time.stdTime, message.time.stdTime); else { if ((created > message.time.stdTime || !created) && !message.references.length) query!"UPDATE `Threads` SET `Created` = ? WHERE `ROWID` = ?".exec(message.time.stdTime, threadIndex); if (updated < message.time.stdTime) query!"UPDATE `Threads` SET `LastPost` = ?, `LastUpdated` = ? WHERE `ROWID` = ?".exec(message.id, message.time.stdTime, threadIndex); } } query!"INSERT OR REPLACE INTO [PostSearch] ([ROWID], [Time], [ThreadMD5], [Group], [Author], [AuthorEmail], [Subject], [Content], [NewThread]) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" .exec( message.rowid, message.time.stdTime, message.threadID.getDigestString!MD5().toLower(), message.xref.map!(xref => xref.group.searchTerm).join(","), message.author, message.authorEmail, message.subject, message.newContent, message.references.length ? "n" : "y", ); } } /// Source used for refreshing the message database. final class MessageDBSource : NewsSource { this() { super("MessageDBSource"); } int batchSize = 500; Duration idleInterval = 100.msecs; override void start() { stopping = false; doBatch(0); } override void stop() { log("Stop requested..."); stopping = true; } private: bool stopping; void doBatch(int offset) { if (stopping) { log("Stopping."); return; } bool foundPosts; assert(batchSize > 0); log("Processing posts %d..%d".format(offset, offset + batchSize)); { mixin(DB_TRANSACTION); foreach (int rowID, string message, string id; query!"SELECT [ROWID], [Message], [ID] FROM [Posts] LIMIT ? OFFSET ?".iterate(batchSize, offset)) { announcePost(new Rfc850Post(message, id, rowID), Fresh.no); foundPosts = true; } log("Committing..."); } log("Batch committed."); if (foundPosts) setTimeout({doBatch(offset + batchSize);}, idleInterval); else log("All done!"); } } /// Look up the real thread ID of a post, by travelling /// up the chain of the first known ancestor IDs. string getThreadID(string id) { static string[string] cache; auto pcached = id in cache; if (pcached) return *pcached; string result = id; foreach (string threadID; query!"SELECT [ThreadID] FROM [Posts] WHERE [ID] = ?".iterate(id)) result = threadID; if (result != id) result = getThreadID(result); return cache[id] = result; } @property string threadID(Rfc850Post post) { return getThreadID(post.firstAncestorID); } string searchTerm(string s) { string result; foreach (c; s) if (isAlphaNum(c)) result ~= c; return result; } ================================================ FILE: src/dfeed/sinks/subscriptions.d ================================================ /* Copyright (C) 2015, 2016, 2017, 2018, 2020, 2022, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sinks.subscriptions; import std.algorithm; import std.ascii; import std.conv; import std.exception; import std.format; import std.process; import std.regex; import std.string; import std.typecons; import std.typetuple; import ae.net.ietf.url : UrlParameters; import ae.sys.log; import ae.sys.timing; import ae.utils.array; import ae.utils.json; import ae.utils.meta; import ae.utils.regex; import ae.utils.text; import ae.utils.textout; import ae.utils.time; import ae.utils.xmllite : putEncodedEntities; import dfeed.common; import dfeed.database; import dfeed.groups; import dfeed.loc; import dfeed.mail; import dfeed.message; import dfeed.site; import dfeed.sinks.irc; import dfeed.sinks.messagedb : threadID; import dfeed.web.user; import dfeed.web.web.page : NotFoundException; import dfeed.web.web.postinfo : getPost; void log(string s) { static Logger log; (log ? log : (log=createLogger("Subscription")))(s); } struct Subscription { string userName, id; Trigger trigger; Action[] actions; this(string userName, UrlParameters data) { this.userName = userName; this.id = data.get("id", null); this.trigger = getTrigger(userName, data); this.actions = getActions(userName, data); } @property FormSection[] sections() { return cast(FormSection[])[trigger] ~ cast(FormSection[])actions; } void save() { assert(id, "No subscription ID"); assert(userName, "No subscription username"); foreach (section; sections) section.validate(); UrlParameters data; data["id"] = id; data["trigger-type"] = trigger.type; foreach (section; sections) section.serialize(data); { mixin(DB_TRANSACTION); query!"INSERT OR REPLACE INTO [Subscriptions] ([ID], [Username], [Data]) VALUES (?, ?, ?)" .exec(id, userName, SubscriptionData(data).toJson()); foreach (section; sections) section.save(); } } void remove() { mixin(DB_TRANSACTION); foreach (section; sections) section.cleanup(); query!`DELETE FROM [Subscriptions] WHERE [ID] = ?`.exec(id); } void unsubscribe() { foreach (action; actions) action.unsubscribe(); save(); } void runActions(Rfc850Post post) { log("Running subscription %s (%s trigger) actions for post %s".format(id, trigger.type, post.id)); string name = getUserSetting(userName, "name"); string email = getUserSetting(userName, "email"); if ((name && !icmp(name, post.author)) || (email && !icmp(email, post.authorEmail))) { log("Post created by author, ignoring"); return; } foreach (action; actions) action.run(this, post); } bool haveUnread() { auto user = registeredUser(userName); foreach (int rowid; query!"SELECT [MessageRowID] FROM [SubscriptionPosts] WHERE [SubscriptionID] = ?".iterate(id)) if (!user.isRead(rowid)) return true; return false; } int getUnreadCount() { auto user = registeredUser(userName); int count = 0; foreach (int rowid; query!"SELECT [MessageRowID] FROM [SubscriptionPosts] WHERE [SubscriptionID] = ?".iterate(id)) if (!user.isRead(rowid)) count++; return count; } } /// POD serialization type to avoid depending on UrlParameters internals struct SubscriptionData { string[][string] items; this(UrlParameters parameters) { items = parameters.toAA; } @property UrlParameters data() { return UrlParameters(items); } } bool subscriptionExists(string subscriptionID) { return query!`SELECT COUNT(*) FROM [Subscriptions] WHERE [ID]=?`.iterate(subscriptionID).selectValue!int > 0; } Subscription getSubscription(string subscriptionID) out(result) { assert(result.id == subscriptionID); } body { foreach (string userName, string data; query!`SELECT [Username], [Data] FROM [Subscriptions] WHERE [ID] = ?`.iterate(subscriptionID)) return Subscription(userName, data.jsonParse!SubscriptionData.data); throw new NotFoundException(_!"No such subscription"); } Subscription getUserSubscription(string userName, string subscriptionID) out(result) { assert(result.id == subscriptionID && result.userName == userName); } body { enforce(userName.length, _!"Not logged in"); foreach (string data; query!`SELECT [Data] FROM [Subscriptions] WHERE [Username] = ? AND [ID] = ?`.iterate(userName, subscriptionID)) return Subscription(userName, data.jsonParse!SubscriptionData.data); throw new NotFoundException(_!"No such user subscription"); } Subscription[] getUserSubscriptions(string userName) { assert(userName); Subscription[] results; foreach (string data; query!`SELECT [Data] FROM [Subscriptions] WHERE [Username] = ?`.iterate(userName)) results ~= Subscription(userName, data.jsonParse!SubscriptionData.data); return results; } void createReplySubscription(string userName) { auto replySubscriptions = getUserSubscriptions(userName) .filter!(result => result.trigger.type == "reply"); auto subscription = replySubscriptions.empty ? createSubscription(userName, "reply") : replySubscriptions.front ; subscription.save(); } Subscription createSubscription(string userName, string triggerType, string[string] extraData = null) { UrlParameters data = extraData; data["trigger-type"] = triggerType; Subscription subscription; subscription.userName = userName; subscription.id = data["id"] = randomString(); subscription.trigger = getTrigger(userName, data); subscription.actions = getActions(userName, data); return subscription; } abstract class FormSection { string userName, subscriptionID; this(string userName, UrlParameters data) { list(this.userName, this.subscriptionID) = tuple(userName, data.get("id", null)); } /// Output the form HTML to edit this trigger. abstract void putEditHTML(ref StringBuffer html); /// Serialize state to a key-value AA, /// with the same keys as form input names. abstract void serialize(ref UrlParameters data); /// Verify that the settings are valid. /// Throw an exception otherwise. abstract void validate(); /// Create or update any persistent state /// (outside the [Subscriptions] table). abstract void save(); /// Clean up any persistent state after deletion /// (outside the [Subscriptions] table). abstract void cleanup(); } // *********************************************************************** class Trigger : FormSection { mixin GenerateConstructorProxies; /// TriggerType abstract @property string type() const; abstract @property string typeName() const; /// Localized name /// HTML description shown in the subscription list. abstract void putDescription(ref StringBuffer html); final string getDescription() { StringBuffer description; putDescription(description); return description.get().assumeUnique(); } /// Text description shown in emails and feed titles. abstract string getTextDescription(); /// Short description for IRC and email subjects. abstract string getShortPostDescription(Rfc850Post post); /// Longer description emails. abstract string getLongPostDescription(Rfc850Post post); } final class ReplyTrigger : Trigger { mixin GenerateConstructorProxies; override @property string type() const { return "reply"; } override @property string typeName() const { return _!"reply"; } override void putDescription(ref StringBuffer html) { html.put(getTextDescription()); } override string getTextDescription() { return _!"Replies to your posts"; } override string getShortPostDescription(Rfc850Post post) { return _!"%s replied to your post in the thread \"%s\"".format(post.author, post.subject); } override string getLongPostDescription(Rfc850Post post) { return _!"%s has just replied to your %s post in the thread titled \"%s\" in the %s group of %s.".format( post.author, post.time.formatTimeLoc!`F j`, post.subject, post.xref[0].group, site.host, ); } override void putEditHTML(ref StringBuffer html) { html.put(_!"When someone replies to your posts:"); } override void serialize(ref UrlParameters data) {} override void validate() {} override void save() { string email = getUserSetting(userName, "email"); if (email) query!`INSERT OR REPLACE INTO [ReplyTriggers] ([SubscriptionID], [Email]) VALUES (?, ?)`.exec(subscriptionID, email); } override void cleanup() { query!`DELETE FROM [ReplyTriggers] WHERE [SubscriptionID] = ?`.exec(subscriptionID); } } final class ThreadTrigger : Trigger { string threadID; this(string userName, UrlParameters data) { super(userName, data); this.threadID = data.get("trigger-thread-id", null); } override @property string type() const { return "thread"; } override @property string typeName() const { return _!"thread"; } final void putThreadName(ref StringBuffer html) { auto post = getPost(threadID); html.put(``), html.putEncodedEntities(post ? post.subject : threadID), html.put(``); } override void putDescription(ref StringBuffer html) { html.put(_!`Replies to the thread`, ` `), putThreadName(html); } override string getTextDescription() { auto post = getPost(threadID); return _!"Replies to the thread" ~ " " ~ (post ? `"` ~ post.subject ~ `"` : threadID); } override string getShortPostDescription(Rfc850Post post) { return _!"%s replied to the thread \"%s\"".format(post.author, post.subject); } override string getLongPostDescription(Rfc850Post post) { return _!"%s has just replied to a thread you have subscribed to titled \"%s\" in the %s group of %s.".format( post.author, post.subject, post.xref[0].group, site.host, ); } override void putEditHTML(ref StringBuffer html) { auto post = getPost(threadID); html.put( ``, _!`When someone posts a reply to the thread`, ` `), putThreadName(html), html.put(`:` ); } override void serialize(ref UrlParameters data) { data["trigger-thread-id"] = threadID; } override void validate() { enforce(getPost(threadID), _!"No such post"); } override void save() { query!`INSERT OR REPLACE INTO [ThreadTriggers] ([SubscriptionID], [ThreadID]) VALUES (?, ?)`.exec(subscriptionID, threadID); } override void cleanup() { query!`DELETE FROM [ThreadTriggers] WHERE [SubscriptionID] = ?`.exec(subscriptionID); } } final class ContentTrigger : Trigger { struct StringFilter { bool enabled; bool isRegex; bool caseSensitive; string str; } bool onlyNewThreads; bool onlyInGroups; string[] groups; StringFilter authorNameFilter, authorEmailFilter, subjectFilter, messageFilter; this(string userName, UrlParameters data) { super(userName, data); this.onlyNewThreads = data.get("trigger-content-message-type", null) == "threads"; this.onlyInGroups = !!("trigger-content-only-in-groups" in data); this.groups = data.valuesOf("trigger-content-groups"); void readStringFilter(string id, out StringFilter filter) { auto prefix = "trigger-content-" ~ id ~ "-"; filter.enabled = !!((prefix ~ "enabled") in data); filter.isRegex = data.get(prefix ~ "match-type", null) == "regex"; filter.caseSensitive = !!((prefix ~ "case-sensitive") in data); filter.str = data.get(prefix ~ "str", null); } readStringFilter("author-name", authorNameFilter); readStringFilter("author-email", authorEmailFilter); readStringFilter("subject", subjectFilter); readStringFilter("message", messageFilter); } override @property string type() const { return "content"; } override @property string typeName() const { return _!"content"; } override void putDescription(ref StringBuffer html) { html.put(onlyNewThreads ? _!`New threads` : _!`New posts`); if (onlyInGroups) { html.put(` `, _!`in`, ` `); void putGroup(string group) { auto gi = getGroupInfo(group); html.put(``), html.putEncodedEntities(gi ? gi.publicName : group), html.put(``); } putGroup(groups[0]); if (groups.length==1) {} else if (groups.length==2) html.put(` ` ~ _!`and` ~ ` `), putGroup(groups[1]); else if (groups.length==3) html.put(`, `), putGroup(groups[1]), html.put(` `, _!`and`, ` `), putGroup(groups[2]); else html.put(`, `), putGroup(groups[1]), html.put(`, (%d `.format(groups.length-2), _!`more`, `)`); } void putStringFilter(string preface, ref StringFilter filter) { if (filter.enabled) html.put( ` `, preface, ` `, filter.isRegex ? `/` : ``, ``), html.putEncodedEntities(filter.str), html.put(``, filter.isRegex ? `/` : ``, filter.isRegex && !filter.caseSensitive ? `i` : ``, ); } putStringFilter(_!"from", authorNameFilter); putStringFilter(_!"from email", authorEmailFilter); putStringFilter(_!"titled", subjectFilter); putStringFilter(_!"containing", messageFilter); } override string getTextDescription() { return getDescription().replace(``, "\“").replace(``, "\”"); } override string getShortPostDescription(Rfc850Post post) { auto s = _!"%s %s the thread \"%s\" in %s".format( post.author, post.references.length ? _!"replied to" : _!"created", post.subject, post.xref[0].group, ); string matchStr = authorNameFilter .enabled && authorNameFilter .str ? authorNameFilter .str : authorEmailFilter.enabled && authorEmailFilter.str ? authorEmailFilter.str : subjectFilter .enabled && subjectFilter .str ? subjectFilter .str : messageFilter .enabled && messageFilter .str ? messageFilter .str : null; if (matchStr) s = _!"%s matching %s".format(s, matchStr); return s; } override string getLongPostDescription(Rfc850Post post) { return _!"%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( post.author, post.references.length ? _!"replied to" : _!"created", post.subject, post.xref[0].group, site.host, post.references.length ? _!"This post" : _!"This thread", getTextDescription(), ); } override void putEditHTML(ref StringBuffer html) { html.put( `
`, _!`When someone`, ` ` ~ `` ~ `` ~ `` ); void putStringFilter(string name, string id, ref StringFilter filter) { html.put( `` ); } putStringFilter(_!"author name", "author-name", authorNameFilter); putStringFilter(_!"author email", "author-email", authorEmailFilter); putStringFilter(_!"subject", "subject", subjectFilter); putStringFilter(_!"message", "message", messageFilter); html.put(`
` ~ ` `, _!`only in the groups:` ~ `` ~ `` ~ `
` ~ ` ` ~ _!`and when the`, ` `, name, ` ` ~ `` ~ ` ` ~ ` ` ~ `(` ~ `` ~ ` `, _!`case sensitive`, ` )` ~ `
`); } override void serialize(ref UrlParameters data) const { data["trigger-content-message-type"] = onlyNewThreads ? "threads" : "posts"; if (onlyInGroups) data["trigger-content-only-in-groups"] = "on"; foreach (group; groups) data.add("trigger-content-groups", group); void serializeStringFilter(string id, ref in StringFilter filter) { auto prefix = "trigger-content-" ~ id ~ "-"; if (filter.enabled) data[prefix ~ "enabled"] = "on"; data[prefix ~ "match-type"] = filter.isRegex ? "regex" : "substring"; if (filter.caseSensitive) data[prefix ~ "case-sensitive"] = "on"; data[prefix ~ "str"] = filter.str; } serializeStringFilter("author-name", authorNameFilter); serializeStringFilter("author-email", authorEmailFilter); serializeStringFilter("subject", subjectFilter); serializeStringFilter("message", messageFilter); } override void validate() { void validateFilter(string name, ref StringFilter filter) { if (filter.enabled) { enforce(filter.str.length, _!"No %s search term specified".format(name)); try auto re = regex(filter.str); catch (Exception e) throw new Exception(_!"Invalid %s regex `%s`: %s".format(name, filter.str, e.msg)); } } validateFilter(_!"author name", authorNameFilter); validateFilter(_!"author email", authorEmailFilter); validateFilter(_!"subject", subjectFilter); validateFilter(_!"message", messageFilter); if (onlyInGroups) enforce(groups.length, _!"No groups selected"); } override void save() { query!`INSERT OR REPLACE INTO [ContentTriggers] ([SubscriptionID]) VALUES (?)`.exec(subscriptionID); } override void cleanup() { query!`DELETE FROM [ContentTriggers] WHERE [SubscriptionID] = ?`.exec(subscriptionID); } bool checkPost(Rfc850Post post) { if (onlyNewThreads && post.references.length) return false; if (onlyInGroups && post.xref.all!(xref => !groups.canFind(xref.group))) return false; bool checkFilter(ref StringFilter filter, string field) { if (!filter.enabled) return true; if (filter.isRegex) return !!field.match(regex(filter.str, filter.caseSensitive ? "" : "i")); else return field.indexOf(filter.str, filter.caseSensitive ? CaseSensitive.yes : CaseSensitive.no) >= 0; } if (!checkFilter(authorNameFilter , post.author )) return false; if (!checkFilter(authorEmailFilter, post.authorEmail)) return false; if (!checkFilter(subjectFilter , post.subject )) return false; if (!checkFilter(messageFilter , post.newContent )) return false; return true; } } Trigger getTrigger(string userName, UrlParameters data) out(result) { assert(result.type == data.get("trigger-type", null)); } body { auto triggerType = data.get("trigger-type", null); switch (triggerType) { case "reply": return new ReplyTrigger(userName, data); case "thread": return new ThreadTrigger(userName, data); case "content": return new ContentTrigger(userName, data); default: throw new Exception(_!"Unknown subscription trigger type:" ~ " " ~ triggerType); } } // *********************************************************************** void checkPost(Rfc850Post post) { // ReplyTrigger if (auto parentID = post.parentID()) if (auto parent = getPost(parentID)) foreach (string subscriptionID; query!"SELECT [SubscriptionID] FROM [ReplyTriggers] WHERE [Email] = ?".iterate(parent.authorEmail)) getSubscription(subscriptionID).runActions(post); // ThreadTrigger foreach (string subscriptionID; query!"SELECT [SubscriptionID] FROM [ThreadTriggers] WHERE [ThreadID] = ?".iterate(post.threadID)) getSubscription(subscriptionID).runActions(post); // ContentTrigger foreach (string subscriptionID; query!"SELECT [SubscriptionID] FROM [ContentTriggers]".iterate()) { auto subscription = getSubscription(subscriptionID); if (auto trigger = cast(ContentTrigger)subscription.trigger) if (trigger.checkPost(post)) subscription.runActions(post); } } final class SubscriptionSink : NewsSink { protected: override void handlePost(Post post, Fresh fresh) { if (!fresh) return; if (!post.getImportance()) return; auto message = cast(Rfc850Post)post; if (!message) return; log("Checking post " ~ message.id); try checkPost(message); catch (Exception e) foreach (line; e.toString().splitLines()) log("* " ~ line); } } // *********************************************************************** class Action : FormSection { mixin GenerateConstructorProxies; /// Execute this action, if it is enabled. abstract void run(ref Subscription subscription, Rfc850Post post); /// Disable this action (used for one-click-unsubscribe in emails) abstract void unsubscribe(); } final class IrcAction : Action { bool enabled; string nick; string network; this(string userName, UrlParameters data) { super(userName, data); enabled = !!("saction-irc-enabled" in data); nick = data.get("saction-irc-nick", null); network = data.get("saction-irc-network", null); } override void putEditHTML(ref StringBuffer html) { html.put( `

` ~ ` `, _!`Send a private message to`, ` `, _!`on the`, ` ` ~ ` `, _!`IRC network`, `

` ); } override void serialize(ref UrlParameters data) { if (enabled) data["saction-irc-enabled"] = "on"; data["saction-irc-nick"] = nick; data["saction-irc-network"] = network; } override void run(ref Subscription subscription, Rfc850Post post) { if (!enabled) return; // Queue messages to avoid sending more than 1 PM per message. static string[string][string] queue; static TimerTask queueTask; queue[network][nick] = subscription.trigger.getShortPostDescription(post) ~ ": " ~ post.url; if (!queueTask) queueTask = setTimeout({ queueTask = null; scope(exit) queue = null; foreach (irc; services!IrcSink) foreach (nick, message; queue.get(irc.network, null)) irc.sendMessage(nick, message); }, 1.msecs); } override void validate() { if (!enabled) return; enforce(nick.length, _!"No nickname indicated"); foreach (c; nick) if (!(isAlphaNum(c) || c.isOneOf(r"-_|\[]{}`"))) throw new Exception(_!"Invalid character in nickname."); } override void save() {} override void cleanup() {} override void unsubscribe() { enabled = false; } } final class EmailAction : Action { bool enabled; string address; this(string userName, UrlParameters data) { super(userName, data); enabled = !!("saction-email-enabled" in data); address = data.get("saction-email-address", getUserSetting(userName, "email")); } override void putEditHTML(ref StringBuffer html) { html.put( `

` ~ ` `, _!`Send an email to`, ` ` ~ `

` ); } override void serialize(ref UrlParameters data) { if (enabled) data["saction-email-enabled"] = "on"; data["saction-email-address"] = address; } string getUserRealName(string userName) { auto name = getUserSetting(userName, "name"); if (!name) // name = address.split("@")[0].capitalize(); name = userName; return name; } Language getUserLanguage(string userName) { try return getUserSetting(userName, "language").to!Language; catch (Exception e) return Language.init; } override void run(ref Subscription subscription, Rfc850Post post) { if (!enabled) return; if (subscription.haveUnread()) { log("User %s has unread messages in subscription %s - not emailing" .format(subscription.userName, subscription.id)); return; } // Queue messages to avoid sending more than 1 email per message. static string[string] queue; static TimerTask queueTask; if (address in queue) { // TODO: Maybe add something to the content, to indicate that // a second subscription was triggered by the same message. return; } queue[address] = formatMessage(subscription, post); if (!queueTask) queueTask = setTimeout({ queueTask = null; scope(exit) queue = null; foreach (address, message; queue) { try sendMail(message); catch (Exception e) log(_!"Error:" ~ " " ~ e.msg); } }, 1.msecs); } string formatMessage(ref Subscription subscription, Rfc850Post post) { auto realName = getUserRealName(userName); enforce(!(address~realName).canFind("\n"), "Shenanigans detected"); auto oldLanguage = withLanguage(getUserLanguage(userName)); return [ `From: %10$s `, `To: %13$s <%11$s>`, `Subject: %12$s`, `Precedence: bulk`, `Content-Type: text/plain; charset=utf-8`, `List-Unsubscribe-Post: List-Unsubscribe=One-Click`, `List-Unsubscribe: <%6$s://%7$s/subscription-unsubscribe/%9$s>`, ``, _!`Howdy %1$s,`, ``, `%2$s`, ``, _!`This %3$s is located at:`, `%4$s`, ``, _!`Here is the message that has just been posted:`, `----------------------------------------------`, `%5$-(%s`, `%)`, `----------------------------------------------`, ``, _!`To reply to this message, please visit this page:`, `%6$s://%7$s%8$s`, ``, _!`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:`, `%6$s://%7$s/subscription-posts/%9$s`, ``, _!`All the best,`, `%10$s`, ``, `~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~`, _!`Unsubscription information:`, ``, _!`To stop receiving emails for this subscription, please visit this page:`, `%6$s://%7$s/subscription-unsubscribe/%9$s`, ``, _!`Or, visit your settings page to edit your subscriptions:`, `%6$s://%7$s/settings`, `.`, ] .join("\n") .format( /* 1*/ realName.split(" ")[0], /* 2*/ subscription.trigger.getLongPostDescription(post), /* 3*/ post.references.length ? _!"post" : _!"thread", /* 4*/ post.url, /* 5*/ post.content.strip.splitAsciiLines.map!(line => line.length ? "> " ~ line : ">"), /* 6*/ site.proto, /* 7*/ site.host, /* 8*/ idToUrl(post.id, "reply"), /* 9*/ subscription.id, /*10*/ site.name.length ? site.name : site.host, /*11*/ address, /*12*/ subscription.trigger.getShortPostDescription(post), /*13*/ realName, ); } override void validate() { if (!enabled) return; enforce(address.match(re!(`^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]+$`, "i")), _!"Invalid email address"); } override void save() {} override void cleanup() {} override void unsubscribe() { enabled = false; } } final class DatabaseAction : Action { mixin GenerateConstructorProxies; override void putEditHTML(ref StringBuffer html) { html.put( `

`, _!`Additionally, you can %ssubscribe to an ATOM feed of matched posts%s, or %sread them online%s.`.format( ``, ``, ``, ``, ), `

` ); } override void serialize(ref UrlParameters data) {} override void run(ref Subscription subscription, Rfc850Post post) { assert(post.rowid, "No row ID for message " ~ post.id); query!"INSERT INTO [SubscriptionPosts] ([SubscriptionID], [MessageID], [MessageRowID], [Time]) VALUES (?, ?, ?, ?)" .exec(subscriptionID, post.id, post.rowid, post.time.stdTime); // TODO: trim old posts? } override void validate() {} override void save() {} override void cleanup() {} // Just leave the SubscriptionPosts alone, e.g. in case the user clicks undo override void unsubscribe() {} } Action[] getActions(string userName, UrlParameters data) { Action[] result; foreach (ActionType; TypeTuple!(EmailAction, IrcAction, DatabaseAction)) result ~= new ActionType(userName, data); return result; } // *********************************************************************** private string getUserSetting(string userName, string setting) { foreach (string value; query!`SELECT [Value] FROM [UserSettings] WHERE [User] = ? AND [Name] = ?`.iterate(userName, setting)) return value; return null; } ================================================ FILE: src/dfeed/sinks/twitter.d ================================================ /* Copyright (C) 2018 Sebastian Wilzbach * Copyright (C) 2018, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sinks.twitter; import std.algorithm.iteration; import std.datetime; import std.string; import dfeed.common; import dfeed.message; import ae.net.http.client; import ae.net.http.common; import ae.net.oauth.common; import ae.sys.data; import ae.utils.json; final class TwitterSink : NewsSink, ModerationSink { static struct Config { OAuthConfig oauth; string postStatusURL = "https://api.twitter.com/1.1/statuses/update.json"; string deleteStatusURL = "https://api.twitter.com/1.1/statuses/destroy/%s.json"; string oauthAccessToken; string oauthAccessTokenSecret; string formatString = "%s by %s: %s"; } this(Config config) { this.config = config; this.session.config = config.oauth; this.session.token = config.oauthAccessToken; this.session.tokenSecret = config.oauthAccessTokenSecret; } protected: override void handlePost(Post post, Fresh fresh) { if (!fresh) return; if (post.time < Clock.currTime() - dur!"days"(1)) return; // ignore posts older than a day old (e.g. StackOverflow question activity bumps the questions) if (post.getImportance() < Post.Importance.high) return; auto rfcPost = cast(Rfc850Post)post; if (!rfcPost) return; tweet(config.formatString.format( rfcPost.subject, rfcPost.author, rfcPost.url, ), (tweetId) { postTweetCache.put(rfcPost.id, tweetId); }); } void tweet(string message, void delegate(long tweetId) callback) { UrlParameters parameters; parameters["status"] = message; auto request = new HttpRequest; //auto queryString = encodeUrlParameters(parameters); auto queryString = parameters.byPair.map!(p => session.encode(p.key) ~ "=" ~ session.encode(p.value)).join("&"); auto baseURL = config.postStatusURL; auto fullURL = baseURL ~ "?" ~ queryString; request.resource = fullURL; request.method = "POST"; request.headers["Authorization"] = session.prepareRequest(baseURL, "POST", parameters).oauthHeader; request.data = DataVec(Data([])); httpRequest(request, delegate(HttpResponse response, string disconnectReason) { if (response) { @JSONPartial struct ResponseT { long id; } auto res = (cast(const(char)[]) response.getContent().contents) .jsonParse!ResponseT; if (res.id != long.init) callback(res.id); } }); } void handleModeration(Post p, Flag!"ban" ban) { auto rfcPost = cast(Rfc850Post)p; if (!rfcPost) return; if (auto tweetId = postTweetCache.get(rfcPost.id)) { auto request = new HttpRequest; auto deleteURL = config.deleteStatusURL.format(tweetId); request.resource = deleteURL; request.method = "POST"; request.headers["Authorization"] = session.prepareRequest(deleteURL, "POST").oauthHeader; request.data = DataVec(Data([])); httpRequest(request, null); } } private: immutable Config config; OAuthSession session; PostTweetCache postTweetCache; } private struct PostTweetCache { struct Entry { string postId; long tweetId; } Entry[128] lastTweets; size_t i; void put(string postId, long tweetId) { lastTweets[i] = Entry(postId, tweetId); i++; if (i >= lastTweets.length) i = 0; } long get(string postId) { foreach (ref tweet; lastTweets) if (tweet.postId == postId) return tweet.tweetId; return 0; } } ================================================ FILE: src/dfeed/site.d ================================================ /* Copyright (C) 2014, 2015, 2017, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.site; struct SiteConfig { string name; string host = "localhost"; string proto = "http"; string about; string ogImage; // OpenGraph image URL for social media previews immutable(string)[] moderators; } immutable SiteConfig site; import ae.utils.sini; import dfeed.paths : resolveSiteFile; shared static this() { site = loadIni!SiteConfig(resolveSiteFile("config/site.ini")); } ================================================ FILE: src/dfeed/sources/github.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2017, 2018, 2021, 2023 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sources.github; import std.algorithm.searching; import std.exception; import std.json; import std.string; import std.uni; import ae.net.http.common; import ae.sys.dataset; import ae.utils.array; import ae.utils.digest; import ae.utils.sini; import ae.utils.text; import dfeed.bitly; import dfeed.common; class GitHub : NewsSource { this(Config config) { super("GitHub"); this.config = config; enforce(config.secret.length, "No secret set"); } struct Config { string secret; } immutable Config config; override void start() {} override void stop () {} void handleWebHook(HttpRequest request) { auto data = cast(string)request.data.joinToHeap(); auto digest = request.headers.get("X-Hub-Signature", null); enforce(digest.length, "No signature"); enforce(digest.skipOver("sha1="), "Unexpected digest algorithm"); enforce(icmp(HMAC_SHA1(config.secret.representation, data.representation).toHex(), digest) == 0, "Wrong digest"); auto event = request.headers.get("X-Github-Event", null); log("Got event: " ~ event); if (!event.isOneOf("status")) announcePost(new GitHubPost(event, data), Fresh.yes); } } class GitHubPost : Post { this(string event, string data) { this.event = event; this.data = parseJSON(data); } override void formatForIRC(void delegate(string) handler) { string str, url; switch (event) { case "ping": str = "%s sent a ping (\"%s\")".format( data["sender"]["login"].str.filterIRCName, data["zen"].str ); break; case "push": str = "%s: %s pushed %d commit%s to %s".format( data["repository"]["name"].str, data["sender"]["login"].str.filterIRCName, data["commits"].array.length, data["commits"].array.length == 1 ? "" : "s", data["ref"].str.replace("refs/heads/", "branch "), ); url = data["compare"].str; break; case "pull_request": str = "%s: %s %s pull request #%s (\"%s\")".format( data["repository"]["name"].str, data["sender"]["login"].str.filterIRCName, (data["action"].str == "closed" && data["pull_request"]["merged"].type == JSON_TYPE.TRUE) ? "merged" : data["action"].str == "synchronize" ? "updated" : data["action"].str, data["pull_request"]["number"].integer, data["pull_request"]["title"].str, ); url = data["pull_request"]["html_url"].str; break; case "issue_comment": str = "%s: %s %s a comment on issue #%s (\"%s\")".format( data["repository"]["name"].str, data["sender"]["login"].str.filterIRCName, data["action"].str, data["issue"]["number"].integer, data["issue"]["title"].str, ); url = data["comment"]["html_url"].str; break; case "pull_request_review_comment": str = "%s: %s %s a review comment on pull request #%s (\"%s\")".format( data["repository"]["name"].str, data["sender"]["login"].str.filterIRCName, data["action"].str, data["pull_request"]["number"].integer, data["pull_request"]["title"].str, ); url = data["comment"]["html_url"].str; break; case "commit_comment": str = "%s: %s a comment on %s commit %s".format( data["repository"]["name"].str, data["sender"]["login"].str.filterIRCName, data["action"].str, data["comment"]["commit_id"].str[0..8], ); url = data["comment"]["html_url"].str; break; case "create": case "delete": str = "%s: %s %sd %s on %s".format( data["repository"]["name"].str, data["sender"]["login"].str.filterIRCName, event, data["ref_type"].str, data["ref"].str, ); if (event == "create") url = data["repository"]["html_url"].str ~ "/compare/master..." ~ data["ref"].str; break; case "fork": str = "%s: %s forked to %s".format( data["repository"]["name"].str, data["sender"]["login"].str.filterIRCName, data["forkee"]["full_name"].str, ); url = data["forkee"]["html_url"].str; break; case "watch": str = "%s %s %s watching".format( data["repository"]["name"].str, data["sender"]["login"].str.filterIRCName, data["action"].str, ); url = data["sender"]["html_url"].str; break; default: //throw new Exception("Unknown event type: " ~ event); str = "(Unknown event: %s)".format(event); break; } str = "[GitHub] " ~ str; if (url && getImportance() >= Importance.normal) shortenURL(url, (string shortenedURL) { handler(str ~ ": " ~ shortenedURL); }); else { if (url) str ~= ": " ~ url; handler(str); } } override Importance getImportance() { debug return Importance.low; else switch (event) { case "pull_request": return data["action"].str.isOneOf("opened", "closed", "reopened") ? Importance.normal : Importance.low; case "check_run": case "check_suite": case "workflow_job": case "workflow_run": return Importance.none; default: return Importance.low; } } private: string event; JSONValue data; } ================================================ FILE: src/dfeed/sources/mailman.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sources.mailman; import std.datetime; import std.file; import std.getopt; import std.random; import std.string; import std.regex; import ae.net.asockets; import ae.net.http.client; import ae.sys.dataio; import ae.sys.file; import ae.utils.digest; import ae.utils.gzip; import ae.sys.data; import ae.sys.log; import ae.sys.timing; import ae.utils.time; import dfeed.common; import dfeed.database; import dfeed.message; class Mailman : NewsSource { int maxConnections = 5; struct ShadowList { string list, group; } struct Config { string baseURL; string lists; ShadowList[string] shadowLists; } Config config; this(Config config) { super("Mailman"); this.config = config; } override void start() { foreach (list; config.lists.split(",")) downloadList(list); } override void stop() { stopping = true; } private: bool stopping; int queued; void getURL(string url, void delegate(string fn, bool fresh) callback) { if (stopping) return; if (queued >= maxConnections) { setTimeout({getURL(url, callback);}, uniform(1, 1000).msecs); return; } auto cachePath = "data/mailman-cache/" ~ getDigestString!MD5(url); log("%s URL %s to %s...".format(cachePath.exists ? "Updating" : "Downloading", url, cachePath)); queued++; auto request = new HttpRequest; request.resource = url; if (cachePath.exists) request.headers["If-Modified-Since"] = cachePath.timeLastModified.formatTime!(TimeFormats.RFC2822); httpRequest(request, (HttpResponse response, string disconnectReason) { queued--; auto okPath = cachePath ~ ".ok"; if (response && response.status == HttpStatusCode.OK) { ensurePathExists(cachePath); atomicWrite(cachePath, response.getContent().contents); callback(cachePath, !okPath.exists); okPath.touch(); } else if (response && response.status == HttpStatusCode.NotModified) { callback(cachePath, !okPath.exists); okPath.touch(); } else { log("Error getting URL %s: error=%s status=%s".format(url, disconnectReason, response ? response.status : 0)); setTimeout({ log("Retrying..."); getURL(url, callback); }, 10.seconds); } } ); } void downloadList(string list) { if (stopping) return; getURL(config.baseURL ~ list.toLower() ~ "/", (string fn, bool fresh) { log("Got list index: " ~ list); if (!fresh) { log("Stale index, not parsing"); return; } auto html = readText(fn); auto re = regex(``); foreach (line; splitLines(html)) { auto m = match(line, re); if (!m.empty) downloadFile(list, m.captures[1]); } }); } void downloadFile(string list, string fn) { if (stopping) return; auto url = config.baseURL ~ list.toLower() ~ "/" ~ fn; getURL(url, (string datafn, bool fresh) { log("Got %s/%s".format(list, fn)); if (!fresh) { log("Stale file, not parsing"); return; } auto data = readData(datafn); scope(failure) std.file.write("errorfile", data.contents); string text; if (fn.endsWith(".txt.gz")) text = cast(string)(data.uncompress.toHeap); else if (fn.endsWith(".txt")) text = cast(string)(data.toHeap); else assert(false); text = text[text.indexOf('\n')+1..$]; // skip first From line auto fromline = regex("\n\nFrom .* at .* \\w\\w\\w \\w\\w\\w [\\d ]\\d \\d\\d:\\d\\d:\\d\\d \\d\\d\\d\\d\n"); mixin(DB_TRANSACTION); foreach (msg; splitter(text, fromline)) { msg = "X-DFeed-List: " ~ list ~ "\n" ~ msg; scope(failure) std.file.write("errormsg", msg); Rfc850Post post; version (mailman_strict) post = new Rfc850Post(msg); else { try post = new Rfc850Post(msg); catch (Exception e) { log("Invalid message: " ~ e.msg); continue; } } foreach (int n; query!"SELECT COUNT(*) FROM `Posts` WHERE `ID` = ?".iterate(post.id)) if (n == 0) { log("Found new post: " ~ post.id); announcePost(post, Fresh.no); } } }); } } ================================================ FILE: src/dfeed/sources/mailrelay.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sources.mailrelay; import ae.net.asockets; import ae.utils.text; import dfeed.common; import dfeed.message; /// Listen for email messages piped by a helper script to a socket. class MailRelay : NewsSource { static struct Config { string addr; ushort port; } this(Config config) { super("MailRelay"); this.config = config; server = new TcpServer(); server.handleAccept = &onAccept; } override void start() { server.listen(config.port, config.addr); } override void stop() { server.close(); } private: TcpServer server; immutable Config config; void onAccept(TcpConnection incoming) { log("* New connection"); Data received; incoming.handleReadData = (Data data) { received ~= data; }; incoming.handleDisconnect = (string reason, DisconnectType type) { auto text = cast(string)received.toHeap(); foreach (line; splitAsciiLines(text)) log("> " ~ line); log("* Disconnected"); auto post = new Rfc850Post(text); announcePost(post, post.fresh); }; } } ================================================ FILE: src/dfeed/sources/newsgroups.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sources.newsgroups; import std.algorithm; import std.string; import std.conv; import ae.utils.array : queuePop; import ae.utils.aa : HashSet; import ae.utils.json; import ae.net.nntp.client; import ae.net.nntp.listener; import ae.sys.timing; import dfeed.common; import dfeed.database; import dfeed.message; struct NntpConfig { string host; bool postingAllowed = true; string deleteCommand; } /// Poll the server periodically for new messages class NntpListenerSource : NewsSource { this(string server) { super("NNTP-Listener"); this.server = server; client = new NntpListener(log); client.handleMessage = &onMessage; } override void start() { } override void stop() { if (connected) client.disconnect(); stopped = true; } /// Call this to start polling the server. /// startTime is the timestamp (as returned by the /// server DATE command) for the first poll cutoff time. void startListening(string startTime=null) { if (!stopped) { client.connect(server); connected = true; client.startPolling(startTime); } } private: string server; bool connected, stopped; NntpListener client; void onMessage(string[] lines, string num, string id) { auto post = new Rfc850Post(lines.join("\n"), id); announcePost(post, post.fresh); } } /// Download articles not present in the database. class NntpDownloader : NewsSource { enum Mode { newOnly, full, fullPurge } NntpClient client; this(string server, Mode mode) { super("NNTP-Downloader"); this.server = server; this.mode = mode; initialize(); } override void start() { running = true; log("Starting, mode is " ~ text(mode)); client.connect(server, &onConnect); } override void stop() { if (running) { running = false; stopping = true; log("Shutting down"); client.disconnect(); } } void delegate(string startTime) handleFinished; private: string server; Mode mode; bool running, stopping; string startTime; void onConnect() { if (stopping) return; log("Listing groups..."); client.getDate((string date) { startTime = date; }); client.listGroups(&onGroups); } void onGroups(GroupInfo[] groups) { log(format("Got %d groups.", groups.length)); foreach (group; groups) getGroup(group); // Own function for closure client.handleIdle = &onIdle; } void getGroup(GroupInfo group) { // Get maximum article numbers before fetching messages - // a cross-posted message might change a queued group's // "maximum article number in database". // The listGroup commands will be queued all together // before any getMessage commands. log(format("Fetching group info for: %s", group.name)); int maxNum = 0; foreach (int num; query!"SELECT MAX(`ArtNum`) FROM `Groups` WHERE `Group` = ?".iterate(group.name)) maxNum = num; void onListGroup(string[] messages) { if (stopping) return; log(format("%d messages in group %s.", messages.length, group.name)); HashSet!int serverMessages; foreach (i, m; messages) serverMessages.add(to!int(m)); HashSet!int localMessages; foreach (int num; query!"SELECT `ArtNum` FROM `Groups` WHERE `Group` = ?".iterate(group.name)) localMessages.add(num); // Construct set of posts to download HashSet!int messagesToDownload = serverMessages.dup; foreach (num; localMessages) if (num in messagesToDownload) messagesToDownload.remove(num); // Remove posts present in the database if (messagesToDownload.length) { client.selectGroup(group.name); foreach (num; messagesToDownload.keys.sort().release()) client.getMessage(to!string(num), &onMessage); } if (mode == Mode.fullPurge) { HashSet!int messagesToDelete = localMessages.dup; foreach (num; serverMessages) if (num in messagesToDelete) messagesToDelete.remove(num); enum PRETEND = false; void logAndDelete(string TABLE, string WHERE, T...)(T args) { enum selectSql = "SELECT * FROM `" ~ TABLE ~ "` " ~ WHERE; enum deleteSql = "DELETE FROM `" ~ TABLE ~ "` " ~ WHERE; log(" " ~ deleteSql); auto select = query!selectSql; select.bindAll!T(args); while (select.step()) log(" " ~ toJson(select.getAssoc())); static if (!PRETEND) query!deleteSql.exec(args); } foreach (num; messagesToDelete) { log((PRETEND ? "Would delete" : "Deleting") ~ " message: " ~ text(num)); mixin(DB_TRANSACTION); string id; foreach (string msgId; query!"SELECT `ID` FROM `Groups` WHERE `Group` = ? AND `ArtNum` = ?".iterate(group.name, num)) id = msgId; logAndDelete!(`Groups`, "WHERE `Group` = ? AND `ArtNum` = ?")(group.name, num); if (id) { logAndDelete!(`Posts` , "WHERE `ID` = ?")(id); logAndDelete!(`Threads`, "WHERE `ID` = ?")(id); } } } } log(format("Listing group: %s", group.name)); if (mode == Mode.newOnly) { log(format("Highest article number in database: %d", maxNum)); if (group.high > maxNum) { // news.digitalmars.com doesn't support LISTGROUP ranges, use XOVER client.listGroupXover(group.name, maxNum+1, &onListGroup); } } else client.listGroup(group.name, &onListGroup); } void onIdle() { log("All done!"); running = false; client.handleIdle = null; client.disconnect(); assert(startTime); if (handleFinished) handleFinished(startTime); } void onMessage(string[] lines, string num, string id) { log(format("Got message %s (%s)", num, id)); announcePost(new Rfc850Post(lines.join("\n"), id), Fresh.no); } void onDisconnect(string reason, DisconnectType type) { if (running) onError("Unexpected NntpDownloader disconnect: " ~ reason); } void onError(string msg) { log(msg); setTimeout({ log("Retrying..."); restart(); }, 10.seconds); } void initialize() { startTime = null; client = new NntpClient(log); client.handleDisconnect = &onDisconnect; } void restart() { if (stopping) return; initialize(); start(); } } ================================================ FILE: src/dfeed/sources/socket.d ================================================ /* Copyright (C) 2015, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sources.socket; import std.exception; import std.string; import std.file; import ae.net.asockets; import ae.utils.array; import ae.utils.json; import ae.utils.text; import dfeed.bitly; import dfeed.common; import dfeed.message; /// Listen for email messages piped by a helper script to a socket. class SocketSource : NewsSource { static struct Config { ushort port; string password; } this(Config config) { super("SocketSource"); this.config = config; server = new TcpServer(); server.handleAccept = &onAccept; } override void start() { server.listen(config.port); } override void stop() { server.close(); } private: TcpServer server; Config config; void onAccept(TcpConnection incoming) { log("* New connection"); Data[] received; incoming.handleReadData = (Data data) { received ~= data; if (received.length > 1*1024*1024) { received = null; incoming.disconnect("Too much data"); } }; incoming.handleDisconnect = (string reason, DisconnectType type) { log("* Disconnected"); try { if (!received) return; auto text = cast(string)received.joinToHeap(); auto receivedPassword = text.eatLine(); enforce(receivedPassword == config.password, "Wrong password"); auto component = text.eatLine(); switch (component) { case "dwiki": handleDWiki(text); break; default: throw new Exception("Unknown component: " ~ component); } } catch (Exception e) log("* Error: " ~ e.msg); }; } void handleDWiki(string text) { static struct Info { string article, user, text, summary, section, url; bool isMinor, isWatch; } auto info = jsonParse!Info(text); announcePost(new class Post { override Importance getImportance() { return info.isMinor ? Importance.low : Importance.normal; } override void formatForIRC(void delegate(string) handler) { shortenURL(info.url, (string shortenedURL) { handler(format("[DWiki] %s edited \"%s\"%s%s%s%s: %s", filterIRCName(info.user), info.article, info.summary.length ? " (" : null, info.summary, info.summary.length ? ")" : null, info.isMinor ? " [m]" : null, shortenedURL, )); }); } }, Fresh.yes); } } ================================================ FILE: src/dfeed/sources/web/feed.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2016, 2018, 2022 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sources.web.feed; import std.exception; import std.string; import std.datetime; import ae.utils.xml; import ae.net.http.client; import dfeed.common; import dfeed.bitly; import dfeed.sources.web.webpoller; class Feed : WebPoller { static struct Config { string name; string url; string action = "posted"; int pollPeriod = 60; } this(Config config) { this.config = config; super(config.name, config.pollPeriod); } private: Config config; class FeedPost : Post { string title; string author; string url; this(string title, string author, string url, SysTime time) { this.title = title; this.author = author; this.url = url; this.time = time; } override void formatForIRC(void delegate(string) handler) { shortenURL(url, (string shortenedURL) { if (config.action.length) handler(format("[%s] %s %s \"%s\": %s", this.outer.name, filterIRCName(author), config.action, title, shortenedURL)); else // author is already indicated in title handler(format("[%s] %s: %s", this.outer.name, filterIRCName(title), shortenedURL)); }); } } protected: override void getPosts() { httpGet(config.url, (HttpResponse response, string disconnectReason) { try { enforce(response, disconnectReason); enforce(response.status / 100 == 2, format("HTTP %d (%s)", response.status, response.statusMessage)); auto result = (cast(char[])response.getContent().contents).idup; static import std.utf; std.utf.validate(result); static import std.file; scope(failure) std.file.write("feed-error.xml", result); auto data = new XmlDocument(result); Post[string] r; auto feed = data["feed"]; foreach (e; feed) if (e.tag == "entry") { auto key = e["id"].text ~ " / " ~ e["updated"].text; auto published = e.findChild("published"); SysTime time; if (published) time = SysTime.fromISOExtString(published.text); else time = Clock.currTime(); auto post = new FeedPost(e["title"].text, e["author"]["name"].text, e["link"].attributes["href"], time); r[key] = post; } handlePosts(r); } catch (Exception e) handleError(e.msg); }); } } ================================================ FILE: src/dfeed/sources/web/reddit.d ================================================ /* Copyright (C) 2011, 2014, 2015, 2018, 2023 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sources.web.reddit; import std.exception; import std.string; import std.regex; import std.datetime; import ae.utils.xml; import ae.net.http.client; import ae.utils.time; import dfeed.bitly; import dfeed.common; import dfeed.sources.web.webpoller; class Reddit : WebPoller { static struct Config { string subreddit; string filter; int pollPeriod = 60; } this(Config config) { this.config = config; this.filter = regex(config.filter); super("Reddit-" ~ config.subreddit, config.pollPeriod); } private: immutable Config config; Regex!char filter; static string getAuthor(string description) { auto doc = new XmlDocument(description); return strip(doc[1].text); } class RedditPost : Post { string title; string author; string url; this(string title, string author, string url, SysTime time) { this.title = title; this.author = author; this.url = url; this.time = time; } override void formatForIRC(void delegate(string) handler) { // TODO: use redd.it shortenURL(url, (string shortenedURL) { handler(format("[Reddit] %s posted \"%s\": %s", author, title, shortenedURL)); }); } } protected: override void getPosts() { auto url = "http://www.reddit.com/r/"~config.subreddit~"/.rss"; httpGet(url, (HttpResponse response, string disconnectReason) { try { enforce(response, disconnectReason); enforce(response.status / 100 == 2, format("HTTP %d (%s)", response.status, response.statusMessage)); auto result = (cast(char[])response.getContent().contents).idup; static import std.utf; std.utf.validate(result); static import std.file; scope(failure) std.file.write("reddit-error.xml", result); auto data = new XmlDocument(result); Post[string] r; auto feed = data["rss"]["channel"]; foreach (e; feed) if (e.tag == "item") if (!match(e["title"].text, filter).empty) r[e["guid"].text ~ " / " ~ e["pubDate"].text] = new RedditPost( e["title"].text, getAuthor(e["description"].text), e["link"].text, e["pubDate"].text.parseTime!(TimeFormats.RSS)() ); handlePosts(r); } catch (Exception e) handleError(e.msg); }); } } ================================================ FILE: src/dfeed/sources/web/stackoverflow.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sources.web.stackoverflow; import std.string; import std.file; import std.conv; import std.datetime; import ae.net.http.client; import ae.utils.json; import ae.utils.text; import dfeed.bitly; import dfeed.common; import dfeed.sources.web.webpoller; class StackOverflow : WebPoller { static struct Config { string tags; string key; int pollPeriod = 60; } this(Config config) { this.config = config; super("StackOverflow-" ~ config.tags, config.pollPeriod); } private: Config config; class Question : Post { string title; string author; string url; this(string title, string author, string url, SysTime time) { this.title = title; this.author = author; this.url = url; this.time = time; } override void formatForIRC(void delegate(string) handler) { shortenURL(url, (string shortenedURL) { handler(format("[StackOverflow] %s asked \"%s\": %s", filterIRCName(author), title, shortenedURL)); }); } } protected: override void getPosts() { auto url = "http://api.stackexchange.com/2.2/questions?pagesize=10&order=desc&sort=creation&site=stackoverflow&tagged=" ~ config.tags ~ (config.key ? "&key=" ~ config.key : ""); httpGet(url, (string json) { if (json == "

408 Request Time-out

\nYour browser didn't send a complete request in time.\n\n") { log("Server reports request timeout"); return; // Temporary problem } if (json.contains("We are Offline")) { log("Server reports SO is offline"); return; // Temporary problem } struct JsonQuestionOwner { int reputation; int user_id; string user_type; int accept_rate; string profile_image; string display_name; string link; } struct JsonQuestion { string[] tags; bool is_answered; int answer_count, accepted_answer_id, favorite_count; int closed_date; string closed_reason; int bounty_closes_date, bounty_amount; string question_timeline_url, question_comments_url, question_answers_url; int question_id; int locked_date; JsonQuestionOwner owner; int creation_date, last_edit_date, last_activity_date; int up_vote_count, down_vote_count, view_count, score; bool community_owned; string title, link; } struct JsonQuestions { JsonQuestion[] items; bool has_more; int quota_max, quota_remaining, backoff; } scope(failure) std.file.write("so-error.txt", json); auto data = jsonParse!(JsonQuestions)(json); Post[string] r; foreach (q; data.items) r[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))); handlePosts(r); }, (string error) { handleError(error); }); } } ================================================ FILE: src/dfeed/sources/web/webpoller.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.sources.web.webpoller; import ae.sys.timing; import ae.utils.aa; import std.algorithm; import std.array; import std.random; import std.string; import dfeed.common; /// Periodically polls a resource (e.g. on the web), and announces new posts. class WebPoller : NewsSource { /// If there are more than LIMIT new posts, /// assume a glitch happened and don't announce them. enum LIMIT = 5; this(string name, int pollPeriod) { super(name); this.pollPeriod = pollPeriod; } override void start() { getPosts(); } override void stop() { if (timerTask) clearTimeout(timerTask); else stopping = true; } private: int pollPeriod; bool[string] oldPosts; bool first = true; bool stopping; TimerTask timerTask; void scheduleNextRequest() { if (stopping) return; // Use a jitter to avoid making multiple simultaneous requests auto delay = pollPeriod + uniform(-5, 5); log(format("Next poll in %d seconds", delay)); timerTask = setTimeout(&startNextRequest, delay.seconds); } void startNextRequest() { timerTask = null; log("Running..."); getPosts(); } protected: void handlePosts(Post[string] posts) { Post[string] newPosts; log(format("Got %d posts", posts.length)); foreach (id, q; posts) { if (!first && !(id in oldPosts)) newPosts[id] = q; oldPosts[id] = true; } first = false; if (newPosts.length > LIMIT) return handleError("Too many posts, aborting!"); auto newPostList = newPosts.byPair.array(); newPostList.sort!`a.value.time < b.value.time`(); foreach (pair; newPostList) { log(format("Announcing %s", pair.key)); announcePost(pair.value, Fresh.yes); } scheduleNextRequest(); } void handleError(string message) { log(format("WebPoller error: %s", message)); scheduleNextRequest(); } /// Asynchronously fetch new posts, and call handlePosts or handleError when done. abstract void getPosts(); } ================================================ FILE: src/dfeed/web/captcha/common.d ================================================ /* Copyright (C) 2012, 2014, 2015, 2016, 2018, 2021, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.captcha.common; import std.exception; public import ae.net.ietf.url : UrlParameters; class Captcha { /// Get a HTML fragment to insert into the HTML form to present a challenge to the user. /// If showing the form again in response to a wrong CAPTCHA solution, /// the error data passed to the verify handler should be supplied. abstract string getChallengeHtml(CaptchaErrorData error = null); /// Get a description of the challenge for logging purposes. /// Returns null if not available. /// Must be called before verify() as verify may invalidate the challenge. string getChallengeDescription(UrlParameters fields) { return null; } /// Get a description of the user's response for logging purposes. /// Returns null if not available. string getResponseDescription(UrlParameters fields) { return null; } /// Check whether a CAPTCHA attempt is included in the form /// (check for the presence of fields added by getChallengeHtml). abstract bool isPresent(UrlParameters fields); /// Verify the correctness of the user's CAPTCHA solution. /// handler can be called asynchronously. abstract void verify(UrlParameters fields, string ip, void delegate(bool success, string errorMessage, CaptchaErrorData errorData) handler); } /// Opaque class for preserving error data. class CaptchaErrorData { } package Captcha[string] captchas; /// Try all registered captchas to get a response description from a single form field. /// Returns null if no captcha recognizes the field as its response field. string getCaptchaResponseFromField(string fieldName, string fieldValue) { // Create a minimal UrlParameters with just this field UrlParameters fields; fields[fieldName] = fieldValue; foreach (captcha; captchas.byValue) { if (captcha is null) continue; auto desc = captcha.getResponseDescription(fields); if (desc !is null) return desc; } return null; } /// Get the CAPTCHA response description from all registered captchas given form fields. /// Returns null if no captcha recognizes the fields. string getCaptchaResponseDescription(UrlParameters fields) { foreach (captcha; captchas.byValue) { if (captcha is null) continue; auto desc = captcha.getResponseDescription(fields); if (desc !is null) return desc; } return null; } static this() { captchas["none"] = null; } ================================================ FILE: src/dfeed/web/captcha/dcaptcha.d ================================================ /* Copyright (C) 2012, 2014, 2015, 2017, 2018, 2020, 2021, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.captcha.dcaptcha; import std.algorithm : any; import std.string : strip, icmp, replace, format; import ae.utils.text; import ae.utils.xmllite : encodeEntities; import dcaptcha.dcaptcha; import dfeed.loc; import dfeed.web.captcha.common; final class Dcaptcha : Captcha { static Challenge[string] challenges; string createChallenge() { auto challenge = getCaptcha(); auto key = randomString(); challenges[key] = challenge; return key; } override string getChallengeHtml(CaptchaErrorData errorData) { auto key = createChallenge(); auto challenge = challenges[key]; return challenge.question.encodeEntities() ~ "\n" ~ `
` ~ challenge.code.encodeEntities() ~ `
` ~ `` ~ `` ~ `` ~ `

` ~ _!`Hint` ~ `: ` ~ challenge.hint ~ `

` ~ `

` ~ _!"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." .replace("\n", `
`) .format(`
`, ``) ~ `

` ; } override string getChallengeDescription(UrlParameters fields) { if (!isPresent(fields)) return null; auto key = fields["dcaptcha_challenge_field"]; auto pchallenge = key in challenges; if (!pchallenge) return null; return pchallenge.question ~ "\n" ~ pchallenge.code; } override string getResponseDescription(UrlParameters fields) { if ("dcaptcha_response_field" !in fields) return null; return fields["dcaptcha_response_field"]; } override bool isPresent(UrlParameters fields) { return "dcaptcha_challenge_field" in fields && "dcaptcha_response_field" in fields; } override void verify(UrlParameters fields, string ip, void delegate(bool success, string errorMessage, CaptchaErrorData errorData) handler) { assert(isPresent(fields)); auto key = fields["dcaptcha_challenge_field"]; auto pchallenge = key in challenges; if (!pchallenge) return handler(false, _!"Unknown or expired CAPTCHA challenge", null); auto challenge = *pchallenge; challenges.remove(key); auto response = fields["dcaptcha_response_field"].strip(); bool correct = challenge.answers.any!(answer => icmp(answer, response) == 0); return handler(correct, correct ? null : _!"The answer is incorrect", null); } } static this() { captchas["dcaptcha"] = new Dcaptcha(); } ================================================ FILE: src/dfeed/web/captcha/dummy.d ================================================ /* Copyright (C) 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.captcha.dummy; import dfeed.loc; import dfeed.web.captcha.common; /// A dummy CAPTCHA for testing purposes. /// Simply presents an "I am not a robot" checkbox. /// NOT suitable for production use. final class DummyCaptcha : Captcha { override string getChallengeHtml(CaptchaErrorData errorData) { return ``; } override string getChallengeDescription(UrlParameters fields) { return "Dummy CAPTCHA: I am not a robot checkbox"; } override string getResponseDescription(UrlParameters fields) { if ("dummy_captcha_checkbox" !in fields) return null; return fields.get("dummy_captcha_checkbox", "") == "1" ? "checked" : "unchecked"; } override bool isPresent(UrlParameters fields) { return ("dummy_captcha_checkbox" in fields) !is null; } override void verify(UrlParameters fields, string ip, void delegate(bool success, string errorMessage, CaptchaErrorData errorData) handler) { bool checked = fields.get("dummy_captcha_checkbox", "") == "1"; handler(checked, checked ? null : _!"Please confirm you are not a robot", null); } } static this() { captchas["dummy"] = new DummyCaptcha(); } ================================================ FILE: src/dfeed/web/captcha/package.d ================================================ /* Copyright (C) 2012, 2014, 2015, 2016, 2018, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.captcha; import std.exception; public import dfeed.web.captcha.common; static import dfeed.web.captcha.dcaptcha; static import dfeed.web.captcha.dummy; static import dfeed.web.captcha.recaptcha; Captcha getCaptcha(string name) { auto pcaptcha = name in captchas; enforce(name, "CAPTCHA mechanism unknown or not configured: " ~ name); return *pcaptcha; } ================================================ FILE: src/dfeed/web/captcha/recaptcha.d ================================================ /* Copyright (C) 2012, 2014, 2015, 2018, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.captcha.recaptcha; import std.string; import ae.net.http.client; import ae.utils.sini; import dfeed.loc; import dfeed.web.captcha.common; class Recaptcha : Captcha { static struct Config { string publicKey, privateKey; } immutable Config config; this(Config config) { this.config = config; } override string getChallengeHtml(CaptchaErrorData errorData) { string error = errorData ? (cast(RecaptchaErrorData)errorData).code : null; auto publicKey = config.publicKey; return `` ~ ``; } override bool isPresent(UrlParameters fields) { return "recaptcha_challenge_field" in fields && "recaptcha_response_field" in fields; } override void verify(UrlParameters fields, string ip, void delegate(bool success, string errorMessage, CaptchaErrorData errorData) handler) { assert(isPresent(fields)); httpPost("http://www.google.com/recaptcha/api/verify", UrlParameters([ "privatekey" : config.privateKey, "remoteip" : ip, "challenge" : fields["recaptcha_challenge_field"], "response" : fields["recaptcha_response_field"], ]), (string result) { auto lines = result.splitLines(); if (lines[0] == "true") handler(true, null, null); else if (lines.length >= 2) handler(false, "reCAPTCHA error: " ~ errorText(lines[1]), new RecaptchaErrorData(lines[1])); else handler(false, "Unexpected reCAPTCHA reply: " ~ result, null); }, (string error) { handler(false, error, null); }); } private static string errorText(string code) { switch (code) { case "incorrect-captcha-sol": return _!"The CAPTCHA solution was incorrect"; case "captcha-timeout": return _!"The solution was received after the CAPTCHA timed out"; default: return code; } } } class RecaptchaErrorData : CaptchaErrorData { string code; this(string code) { this.code = code; } override string toString() { return code; } } static this() { import dfeed.common : createService; if (auto c = createService!Recaptcha("apis/recaptcha")) captchas["recaptcha"] = c; } ================================================ FILE: src/dfeed/web/lint.d ================================================ /* Copyright (C) 2015, 2016, 2017, 2018, 2020, 2021, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.lint; import core.time; import std.algorithm; import std.conv : to; import std.datetime.systime; import std.exception; import std.functional : not; import std.range; import std.regex; import std.string; import ae.sys.persistence; import ae.utils.aa; // `require` polyfill import ae.utils.array : contains; import ae.utils.regex; import dfeed.loc; import dfeed.message; import dfeed.web.markdown; import dfeed.web.posting; import dfeed.web.web.part.postbody : reURL; import dfeed.web.web.postinfo : getPost; class LintRule { /// ID string - used in forms for button names, etc. abstract @property string id(); /// Short description - visible by default abstract @property string shortDescription(); /// Long description - shown on request, should contain rationale (HTML) abstract @property string longDescription(); /// Check if the lint rule is triggered. /// Return true if there is a problem with the post according to this rule. abstract bool check(in ref PostDraft); /// Should the "Fix it for me" option be presented to the user? abstract bool canFix(in ref PostDraft); /// Fix up the post according to the rule. abstract void fix(ref PostDraft); } class NotQuotingRule : LintRule { override @property string id() { return "notquoting"; } override @property string shortDescription() { return _!"Parent post is not quoted."; } override @property string longDescription() { return "

" ~ _!"When replying to someone's post, you should provide some context for your replies by quoting the revelant parts of their post." ~ "

" ~ "

" ~ _!"Depending on the software (or its configuration) used to read your message, it may not be obvious which post you're replying to." ~ "

" ~ "

" ~ _!"Thus, when writing a reply, don't delete all quoted text: instead, leave just enough to provide context for your reply." ~ " " ~ _!"You can also insert your replies inline (interleaved with quoted text) to address specific parts of the parent post." ~ "

"; } override bool check(in ref PostDraft draft) { if (!hasParent(draft)) return false; auto lines = draft.clientVars.get("text", null).splitLines(); return !lines.canFind!(line => line.startsWith(">")); } override bool canFix(in ref PostDraft draft) { return true; } override void fix(ref PostDraft draft) { auto text = getParent(draft).replyTemplate().content.strip(); draft.clientVars["text"] = text ~ "\n\n" ~ draft.clientVars.get("text", null); (new OverquotingRule).fix(draft); } } string[] getLines(in ref PostDraft draft) { return draft.clientVars.get("text", null).strip().splitLines(); } bool isWroteLine(string line) { return line.startsWith("On ") && line.canFind(", ") && line.endsWith(" wrote:"); } string[] getWroteLines(in ref PostDraft draft) { return getLines(draft).filter!isWroteLine.array(); } string[] getNonQuoteLines(in ref PostDraft draft) { return getLines(draft).filter!(line => !line.startsWith(">") && !line.isWroteLine).array(); } bool hasParent(in ref PostDraft draft) { return "parent" in draft.serverVars && getPost(draft.serverVars["parent"]) !is null; } Rfc850Post getParent(in ref PostDraft draft) { return getPost(draft.serverVars["parent"]).enforce("Can't find parent post"); } string[] getParentLines(in ref PostDraft draft) { return getParent(draft).content.strip().splitLines(); } string[] getQuotedParentLines(in ref PostDraft draft) { return getParent(draft).replyTemplate().content.strip().splitLines(); } class WrongParentRule : LintRule { override @property string id() { return "wrongparent"; } override @property string shortDescription() { return _!"You are quoting a post other than the parent."; } override @property string longDescription() { return "

" ~ _!"When replying a message, the message you are replying to is referenced in the post's headers." ~ "

" ~ "

" ~ _!"Depending on the software (or its configuration) used to read your message, your message may be displayed below its parent post." ~ " " ~ _!"If your message contains a reply to a different post, following the conversation may become somewhat confusing." ~ "

" ~ "

" ~ _!"Thus, make sure to click the \"Reply\" link on the actual post you're replying to, and quote the parent post for context." ~ "

"; } override bool check(in ref PostDraft draft) { if (!hasParent(draft)) return false; auto wroteLines = getWroteLines(draft); return wroteLines.length && !wroteLines.canFind(getQuotedParentLines(draft)[0]); } override bool canFix(in ref PostDraft draft) { return false; } override void fix(ref PostDraft draft) { //(new NotQuotingRule).fix(draft); assert(false); } } class NoParentRule : LintRule { override @property string id() { return "noparent"; } override @property string shortDescription() { return _!"Parent post is not indicated."; } override @property string longDescription() { return "

" ~ _!"When quoting someone's post, you should leave the \"On (date), (author) wrote:\" line." ~ "

" ~ "

" ~ _!"Depending on the software (or its configuration) used to read your message, it may not be obvious which post you're replying to." ~ "

" ~ "

" ~ _!"Thus, this line provides important context for your replies regarding the structure of the conversation." ~ "

"; } override bool check(in ref PostDraft draft) { if (!hasParent(draft)) return false; return getWroteLines(draft).length == 0 && getLines(draft).canFind!(line => line.startsWith(">")); } override bool canFix(in ref PostDraft draft) { return true; } override void fix(ref PostDraft draft) { auto qpLines = getQuotedParentLines(draft); auto lines = getLines(draft); foreach (i, line; lines) if (line.length > 5 && line.startsWith(">") && qpLines.canFind(line)) { auto j = i; while (j && lines[j-1].startsWith(">")) j--; lines = lines[0..j] ~ qpLines[0] ~ lines[j..$]; draft.clientVars["text"] = lines.join("\n"); (new OverquotingRule).fix(draft); return; } // Can't find any bit of quoted text in parent (new NotQuotingRule).fix(draft); } } class MultiParentRule : LintRule { override @property string id() { return "multiparent"; } override @property string shortDescription() { return _!"You are quoting multiple posts."; } override @property string longDescription() { return "

" ~ _!"When replying a message, the message you are replying to is referenced in the post's headers." ~ "

" ~ "

" ~ _!"Depending on the software (or its configuration) used to read your message, your message may be displayed below its parent post." ~ " " ~ "If your message contains a reply to a different post, following the conversation may become somewhat confusing.

" ~ "

" ~ _!"Thus, you should avoid replying to multiple posts in one reply." ~ " " ~ _!"If applicable, you should split your message into several, each as a reply to its corresponding parent post." ~ "

"; } override bool check(in ref PostDraft draft) { if (!hasParent(draft)) return false; return getWroteLines(draft).sort().uniq().walkLength > 1; } override bool canFix(in ref PostDraft draft) { return false; } override void fix(ref PostDraft draft) { assert(false); } } class TopPostingRule : LintRule { override @property string id() { return "topposting"; } override @property string shortDescription() { return _!"You are top-posting."; } override @property string longDescription() { return "

" ~ _!"When replying a message, it is generally preferred to add your reply under the quoted parent text." ~ "

" ~ "

" ~ _!"Depending on the software (or its configuration) used to read your message, your message may not be displayed below its parent post." ~ " " ~ _!"In such cases, readers would need to first read the quoted text below your reply for context." ~ "

" ~ "

" ~ _!"Thus, you should add your reply below the quoted text (or reply to individual paragraphs inline), rather than above it." ~ "

"; } override bool check(in ref PostDraft draft) { if (!hasParent(draft)) return false; auto lines = getLines(draft); bool inQuote; foreach (line; lines) { if (line.startsWith(">")) inQuote = true; else if (inQuote) return false; } return inQuote; } override bool canFix(in ref PostDraft draft) { return true; } override void fix(ref PostDraft draft) { auto lines = getLines(draft); auto start = lines.countUntil!(line => line.startsWith(">")); if (start && lines[start-1].isWroteLine()) start--; lines = lines[start..$] ~ [string.init] ~ lines[0..start]; if (!lines[0].isWroteLine()) { auto i = lines.countUntil!isWroteLine(); if (i > 0) lines = [lines[i]] ~ lines[0..i] ~ lines[i+1..$]; } draft.clientVars["text"] = lines.join("\n").strip(); } } class OverquotingRule : LintRule { override @property string id() { return "overquoting"; } override @property string shortDescription() { return _!"You are overquoting."; } override @property string longDescription() { return "

" ~ _!"The ratio between quoted and added text is vastly disproportional." ~ "

" ~ "

" ~ _!"Quoting should be limited to the amount necessary to provide context for your replies." ~ " " ~ _!"Quoting posts in their entirety is thus rarely necessary, and is a waste of vertical space." ~ "

" ~ "

" ~ _!"Please trim the quoted text to just the relevant parts you're addressing in your reply, or add more content to your post." ~ "

"; } bool checkLines(string[] lines) { auto quoted = lines.filter!(line => line.startsWith(">")).map!(line => line.length).sum(); auto unquoted = lines.filter!(line => !line.startsWith(">")).map!(line => line.length).sum(); if (unquoted < 200) unquoted = 200; return unquoted && quoted > unquoted * 4; } override bool check(in ref PostDraft draft) { auto lines = draft.clientVars.get("text", null).splitLines(); return checkLines(lines); } override bool canFix(in ref PostDraft draft) { return true; } override void fix(ref PostDraft draft) { auto lines = draft.clientVars.get("text", null).splitLines(); static string quotePrefix(string s) { int i; for (; i' || (s[i] == ' ' && i != 0)) continue; else break; return s[0..i]; } static size_t quoteLevel(string quotePrefix) { return quotePrefix.count(">"); } bool check() { draft.clientVars["text"] = lines.join("\n"); return !checkLines(lines); } if (check()) return; // Nothing to do // First, try to trim inner posting levels void trimBeyond(int trimLevel) { bool trimming; foreach_reverse (i, s; lines) { auto prefix = quotePrefix(s); auto level = prefix.count(">"); if (level >= trimLevel) { if (!trimming) { lines[i] = prefix ~ "[...]"; trimming = true; } else lines = lines[0..i] ~ lines[i+1..$]; } else trimming = false; } } foreach_reverse (trimLevel; 2..6) { trimBeyond(trimLevel); if (check()) return; } // Next, try to trim to just the first quoted paragraph string[] newLines; int sawContent; bool trimming; foreach (line; lines) { if (line.startsWith(">")) { if (line.strip() == ">") { if (!trimming && sawContent > 1) { newLines ~= ">"; newLines ~= "> [...]"; trimming = true; sawContent = 0; } } else if (!line.endsWith(" wrote:") && !line.endsWith("[...]")) sawContent++; } else trimming = false; if (!trimming) newLines ~= line; } lines = newLines; if (check()) return; // Lastly, just trim all quoted text trimBeyond(1); check(); } } class ShortLinkRule : LintRule { override @property string id() { return "shortlink"; } override @property string shortDescription() { return _!"Don't use URL shorteners."; } override @property string longDescription() { return "

" ~ _!"URL shortening services, such as TinyURL, are useful in cases where space is at a premium, e.g. in IRC or Twitter messages." ~ " " ~ _!"In other circumstances, however, they provide little benefit, and have the significant disadvantage of being opaque:" ~ " " ~ _!"readers can only guess where the link will lead to before they click it." ~ "

" ~ "

" ~ _!"Additionally, URL shortening services come and go - your link may work today, but might not in a year or two." ~ "

" ~ "

" ~ _!"Thus, do not use URL shorteners when posting messages online - post the full link instead, even if it seems exceedingly long." ~ " " ~ _!"If it is too long to be inserted inline, add it as a footnote instead." ~ "

"; } // http://longurl.org/services static const string[] urlShorteners = ["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"]; static Regex!char re; this() { if (re.empty) re = regex(`https?://(` ~ urlShorteners.map!escapeRE.join("|") ~ `)/\w+`); } static string expandURLImpl(string url) { import std.net.curl; string result; auto http = HTTP(url); http.setUserAgent("DFeed (+https://github.com/CyberShadow/DFeed)"); http.method = HTTP.Method.head; http.verifyPeer(false); http.onReceiveHeader = (in char[] key, in char[] value) { if (icmp(key, "Location")==0) result = value.idup; }; http.perform(); enforce(result, _!"Could not expand URL:" ~ " " ~ url); return result; } enum urlCache = "data/shorturls.json"; auto expandURL = PersistentMemoized!expandURLImpl(urlCache); override bool check(in ref PostDraft draft) { return !draft.getNonQuoteLines.join("\n").match(re).empty; } override bool canFix(in ref PostDraft draft) { return true; } override void fix(ref PostDraft draft) { draft.clientVars["text"] = draft.getLines() .map!(line => line.startsWith(">") ? line : line.replaceAll!(captures => expandURL(captures[0]))(re) ) .join("\n") ; } } class LinkInSubjectRule : LintRule { override @property string id() { return "linkinsubject"; } override @property string shortDescription() { return _!"Don't put links in the subject."; } override @property string longDescription() { return "

" ~ _!"Links in message subjects are usually not clickable." ~ "

" ~ "

" ~ _!"Please move the link in the message body instead." ~ "

"; } override bool check(in ref PostDraft draft) { auto subject = draft.clientVars.get("subject", null); if (subject.startsWith("Re: ") || !subject.canFind("://")) return false; auto text = draft.clientVars.get("text", null); foreach (url; subject.match(re!reURL)) if (!text.canFind(url.captures[0])) return true; return false; // all URLs are also in the body } override bool canFix(in ref PostDraft draft) { return true; } override void fix(ref PostDraft draft) { auto subject = draft.clientVars.get("subject", null); draft.clientVars["text"] = subject ~ "\n\n" ~ draft.clientVars.get("text", null); //draft.clientVars["subject"] = subject.replaceAll(reUrl, "(URL inside)"); } } class NecropostingRule : LintRule { override @property string id() { return "necroposting"; } override @property string shortDescription() { return _!"Avoid replying to very old threads."; } override @property string longDescription() { return "

" ~ _!"The thread / post you are replying to is very old." ~ "

" ~ "

" ~ _!"Consider creating a new thread instead of replying to an existing one." ~ "

"; } enum warnThreshold = (4 * 3).weeks; override bool check(in ref PostDraft draft) { if (!hasParent(draft)) return false; auto parent = getParent(draft); return (Clock.currTime - parent.time) > warnThreshold; } override bool canFix(in ref PostDraft draft) { return true; } override void fix(ref PostDraft draft) { auto parent = getParent(draft); draft.clientVars["text"] = parent.url ~ "\n\n" ~ draft.clientVars.get("text", null); auto subject = draft.clientVars.get("subject", null); if (subject.skipOver("Re: ")) draft.clientVars["subject"] = subject; draft.serverVars.remove("parent"); } } class MarkdownHTMLRule : LintRule { override @property string id() { return "markdownhtml"; } override @property string shortDescription() { return _!"HTML-like text was discarded."; } override @property string longDescription() { return "

" ~ _!"Your message seems to contain content which the Markdown renderer has interpreted as raw HTML." ~ " " ~ _!"Since using raw HTML is not allowed, this content has been discarded from the rendered output." ~ "

" ~ "

" ~ _!"If your intention was to use HTML for formatting, please revise your message to use the %savailable Markdown formatting syntax%s instead.".format( ``, ``, ) ~ "

" ~ "

" ~ _!"If your intention was to use characters such as > < & verbatim in your message, you can prevent them from being interpreted as special characters by escaping them with a backslash character (\\)." ~ " " ~ _!`Clicking "Fix it for me" will apply this escaping automatically.` ~ "

" ~ "

" ~ _!`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.` ~ "

" ~ ""; } override bool check(in ref PostDraft draft) { if ("markdown" !in draft.clientVars) return false; // Note: this is an approximation of how text content is // transformed into a post and then to rendered Markdown // (normally that goes through draftToPost and then // unwrapText), but it doesn't matter for this check. auto result = renderMarkdownCached(draft.clientVars.get("text", null)); if (result.error) return false; return result.html.contains(""); } override bool canFix(in ref PostDraft draft) { return true; } override void fix(ref PostDraft draft) { string result; size_t numEscapes; foreach (c; draft.clientVars.get("text", null)) { if (c.among('<') && numEscapes % 2 == 0) result ~= '\\'; else if (c == '\\') numEscapes++; else numEscapes = 0; result ~= c; } draft.clientVars["text"] = result; } } class MarkdownEntitiesRule : LintRule { import ae.utils.xml.entities : entities; override @property string id() { return "markdownentities"; } override @property string shortDescription() { return _!"Avoid using HTML entities."; } override @property string longDescription() { return "

" ~ _!`HTML character entities, such as "&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.` ~ "

" ~ "

" ~ _!"As such, it is preferable to use the Unicode characters directly instead of their HTML entity encoded form (e.g. \"\—\" instead of \"&mdash;\")." ~ "

" ~ "

" ~ _!`If you did not mean to use an HTML entity to represent a character, escape the leading ampersand (&) by prepending a backslash (e.g. "\&").` ~ "

" ~ ""; } alias reEntity = re!(`(?<=[^\\](?:\\\\)*)&([a-z0-9]+|#[0-9]{1,6}|#x[0-9a-fA-F]{1,6});`, "ig"); override bool check(in ref PostDraft draft) { if ("markdown" !in draft.clientVars) return false; return !!draft.clientVars.get("text", null).matchFirst(reEntity); } override bool canFix(in ref PostDraft draft) { return !draft.clientVars.get("text", null) .matchAll(reEntity) .map!(match => match[1]) .filter!(entityName => entityName.startsWith("#") || entityName in entities) .empty; } override void fix(ref PostDraft draft) { string dg(Captures!string m) { if (m[1].startsWith("#x")) return dchar(m[1][2 .. $].to!uint(16)).to!string; else if (m[1].startsWith("#")) return dchar(m[1][1 .. $].to!uint(10)).to!string; else if (auto c = m[1] in entities) return (*c).to!string; else return m[0]; } draft.clientVars["text"] = draft.clientVars.get("text", null) .replaceAll!dg(reEntity); } } class MarkdownCodeRule : LintRule { override @property string id() { return "markdowncode"; } override @property string shortDescription() { return _!"A code block may be misformatted."; } override @property string longDescription() { return "

" ~ _!"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.)" ~ "

" ~ "

" ~ _!"When using %sMarkdown formatting%s, you should either wrap code blocks in fences (``` lines), or indent all lines by four spaces.".format( ``, ``, ) ~ "

" ~ "

" ~ _!`Click "Fix it for me" to have the forum software attempt to do this automatically.` ~ "

" ~ "

" ~ _!`Alternatively, you may uncheck the "Enable Markdown" checkbox to disable Markdown rendering completely, which will cause whitespace to be rendered verbatim.` ~ "

" ~ ""; } override bool check(in ref PostDraft draft) { if ("markdown" !in draft.clientVars) return false; // Attempt to detect lines with leading indentation which has // been lost after conversion. Avoid false positives by also // tracking lines which were not indented. struct TrieNode { TrieNode[char] children; bool[2] sawWithIndent; } TrieNode root; bool detectIndent(ref string line) { if (line.startsWith(" ") || line.startsWith("\t")) { line = line.strip(); return true; } return false; } // Detect fenced code block delimiters (``` or ~~~) static bool isFenceDelimiter(string line) { auto stripped = line.stripLeft(); return stripped.startsWith("```") || stripped.startsWith("~~~"); } auto paragraphs = draft.clientVars.get("text", null).replace("\r\n", "\n").split("\n\n").map!splitLines.array; if (!paragraphs.canFind!(paragraph => !paragraph.all!detectIndent && !paragraph.all!(not!detectIndent))) return false; bool inFencedBlock = false; foreach (line; draft.clientVars.get("text", null).splitLines()) { // Track fenced code blocks and skip their contents if (isFenceDelimiter(line)) { inFencedBlock = !inFencedBlock; continue; } if (inFencedBlock) continue; bool isIndented = detectIndent(line); TrieNode* n = &root; foreach (c; line) { n.sawWithIndent[isIndented] = true; n = &n.children.require(c, TrieNode.init); } n.sawWithIndent[isIndented] = true; } // Note: this is an approximation of how text content is // transformed into a post and then to rendered Markdown // (normally that goes through draftToPost and then // unwrapText), but it doesn't matter for this check. auto result = renderMarkdownCached(draft.clientVars.get("text", null)); if (result.error) return false; // We trigger a positive if and only if there exists a line prefix which: // 1. Exists in an INDENTED line in the Markdown source // 2. Does NOT exist in a NON-indented line in the Markdown source // 3. Exists in a NON-indented line in the rendered Markdown HTML foreach (line; result.html.splitLines()) { bool isIndented = detectIndent(line); if (isIndented) continue; // Look only at non-indented lines in output TrieNode* n = &root; foreach (c; line) { if (n.sawWithIndent[true] && !n.sawWithIndent[false]) return true; // This prefix only occurred as indented. n = c in n.children; if (!n) break; } } return false; } override bool canFix(in ref PostDraft draft) { return true; } override void fix(ref PostDraft draft) { auto paragraphs = draft.clientVars.get("text", null).replace("\r\n", "\n").split("\n\n"); foreach (ref paragraph; paragraphs) { auto lines = paragraph.split("\n"); if (lines.canFind!(line => line.startsWith(" ") || line.startsWith("\t"))) { foreach (ref line; lines) line = " " ~ line; paragraph = lines.join("\n"); } } draft.clientVars["text"] = paragraphs.join("\n\n").replace("\n", "\r\n"); } } unittest { import dfeed.web.markdown : haveMarkdown; if (!haveMarkdown()) return; bool check(string text) { PostDraft draft; draft.clientVars["markdown"] = "on"; draft.clientVars["text"] = text; return (new MarkdownCodeRule).check(draft); } assert(check(q"EOF if (true) code(); EOF")); assert(!check(q"EOF code code EOF")); // https://github.com/CyberShadow/DFeed/issues/125#issuecomment-830469649 assert(!check(" Code")); // Fenced code blocks with internal indentation should not trigger assert(!check(q"EOF Here is some code: ```d int x = 1; string result = { switch(x) { case 0: return "hi"; default: return "bye"; } }(); ``` EOF")); // Tilde fences should also work assert(!check(q"EOF ~~~ indented content ~~~ EOF")); } class MarkdownSyntaxRule : LintRule { override @property string id() { return "markdownsyntax"; } override @property string shortDescription() { return _!"Markdown syntax was used, but Markdown is disabled."; } override @property string longDescription() { return "

" ~ _!"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( ``, ``, ) ~ "

" ~ "

" ~ _!`Click "Fix it for me" to enable Markdown rendering automatically.` ~ "

" ~ ""; } override bool check(in ref PostDraft draft) { // Only check when Markdown is DISABLED if ("markdown" in draft.clientVars) return false; auto text = draft.clientVars.get("text", null); // Check for Markdown links: [text](url) // Require the URL part to look like an actual URL (contain :// or start with http/https/www or /) if (!text.matchFirst(re!`\[.+?\]\((https?://|www\.|/|\.\./).+?\)`).empty) return true; // Check for GFM fenced code blocks auto lines = text.splitLines(); foreach (line; lines) if (line.startsWith("```")) return true; return false; } override bool canFix(in ref PostDraft draft) { return true; } override void fix(ref PostDraft draft) { // Enable Markdown draft.clientVars["markdown"] = "on"; } } unittest { bool check(string text) { PostDraft draft; // Markdown is disabled (no "markdown" in clientVars) draft.clientVars["text"] = text; return (new MarkdownSyntaxRule).check(draft); } // Fenced code blocks assert(check("```\ncode\n```")); assert(check("```d\ncode\n```")); // Links assert(check("Click [here](http://example.com)")); // Should not trigger when Markdown is enabled PostDraft draft; draft.clientVars["markdown"] = "on"; draft.clientVars["text"] = "[link](url)"; assert(!(new MarkdownSyntaxRule).check(draft)); // Should not trigger on plain text formatting or D code assert(!check("This is **bold** text")); assert(!check("This is *italic* text")); assert(!check("- item")); assert(!check("> quote")); assert(!check("Use `code` here")); assert(!check(`auto str = r"raw string";`)); assert(!check(`auto str = q"EOS\ntext\nEOS";`)); assert(!check(`auto str = q{code};`)); assert(!check(`myFunctions[0]()`)); assert(!check(`urlHandlers[i]("http://google.com")`)); assert(!check(`array[index](param)`)); assert(!check("Just plain text")); } @property LintRule[] lintRules() { static LintRule[] result; if (!result.length) result = [ new NotQuotingRule, new WrongParentRule, new NoParentRule, new MultiParentRule, new TopPostingRule, new OverquotingRule, new ShortLinkRule, new LinkInSubjectRule, new NecropostingRule, new MarkdownHTMLRule, new MarkdownEntitiesRule, new MarkdownCodeRule, new MarkdownSyntaxRule, ]; return result; } LintRule getLintRule(string id) { foreach (rule; lintRules) if (rule.id == id) return rule; throw new Exception("Unknown lint rule: " ~ id); } ================================================ FILE: src/dfeed/web/list.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Infer a list template from an example, /// and allow rendering that template /// with arbitrary list items. module dfeed.web.list; import std.algorithm; import std.array; import std.exception; import std.range; import std.string; struct ListTemplate { string listPrefix, listSuffix; string[] varPrefix; string itemSuffix, itemSeparator; string render(in string[][] items) { return listPrefix ~ items.map!(row => row.length.iota.map!(n => varPrefix[n] ~ row[n] ).join ~ itemSuffix ).join(itemSeparator) ~ listSuffix; } } ListTemplate inferList(string s, string[][] anchors) { assert(anchors.length > 1 && anchors[0].length > 0, "Insufficient anchors"); enforce(anchors.all!(row => row.length == anchors[0].length), "Jagged anchor specification array"); auto anchorStarts = anchors.map!(row => row.map!(anchor => s.indexOf(anchor) ).array).array; auto anchorEnds = anchors.map!(row => row.map!(anchor => s.indexOf(anchor) + anchor.length).array).array; enforce(anchorStarts.joiner.all!(i => i>=0), "An anchor was not found when inferring list"); ListTemplate result; foreach (varIndex; 0..anchors[0].length) { size_t l = 0; auto maxL = varIndex ? anchorStarts[0][varIndex] - anchorEnds[0][varIndex-1] : anchorStarts[0][0]; while (l < maxL && anchors.length.iota.all!(rowIndex => s[anchorStarts[rowIndex][varIndex]-l-1] == s[anchorStarts[0 ][varIndex]-l-1])) l++; result.varPrefix ~= s[anchorStarts[0][varIndex]-l .. anchorStarts[0][varIndex]]; } size_t l = 0; auto maxSuffixLength = min(s.length - anchorEnds[$-1][$-1], anchorStarts[1][0] - result.varPrefix[0].length - anchorEnds[0][$-1]); while (l < maxSuffixLength && anchors.length.iota.all!(rowIndex => s[anchorEnds[rowIndex][$-1]+l] == s[anchorEnds[0 ][$-1]+l])) l++; result.itemSuffix = s[anchorEnds[0][$-1] .. anchorEnds[0][$-1]+l]; result.itemSeparator = s[anchorEnds[0][$-1]+l .. anchorStarts[1][0] - result.varPrefix[0].length]; result.listPrefix = s[0 .. anchorStarts[0][0] - result.varPrefix[0].length]; result.listSuffix = s[anchorEnds[$-1][$-1] + result.itemSuffix.length .. $]; return result; } unittest { auto s = q"EOF

,

EOF"; auto anchors = [["", ""], ["", ""]]; auto list = inferList(s, anchors); assert(list.listPrefix == "

"); assert(list.varPrefix[0] == "\n\t"); assert(list.itemSuffix == ""); assert(list.itemSeparator == ","); assert(list.listSuffix == "\n

\n"); auto s2 = list.render(anchors); assert(s == s2); } ================================================ FILE: src/dfeed/web/mailhide.d ================================================ /* Copyright (C) 2015, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.mailhide; import std.base64; import std.exception; import std.uri; import deimos.openssl.aes; import deimos.openssl.evp; import ae.net.ssl.openssl; import ae.utils.sini; import ae.utils.text; class MailHide { static struct Config { string publicKey, privateKey; } private: immutable Config config; string pubKey; static if (!OPENSSL_VERSION_AT_LEAST(1, 1, 0)) { EVP_CIPHER_CTX ctx_storage; inout(EVP_CIPHER_CTX*) ctx() pure @safe nothrow @nogc inout scope return { return &ctx_storage; } } else EVP_CIPHER_CTX* ctx; void aesInit(ubyte[] key) { enforce(key.length == 16, "Invalid private key length"); static if (OPENSSL_VERSION_AT_LEAST(1, 1, 0)) ctx = enforce(EVP_CIPHER_CTX_new(), "Failed to allocate cipher context"); else ctx = &ctx_storage; EVP_CIPHER_CTX_init(ctx); EVP_EncryptInit_ex(ctx, EVP_aes_128_cbc(), null, key.ptr, null).sslEnforce(); } ubyte[] aesEncrypt(ubyte[] plaintext) { auto valLength = plaintext.length; auto padLength = ((plaintext.length + 15) / 16) * 16; plaintext.length = padLength; plaintext[valLength..padLength] = 16 - valLength % 16; int c_len = cast(uint)plaintext.length + AES_BLOCK_SIZE, f_len = 0; ubyte[] ciphertext = new ubyte[c_len]; EVP_EncryptInit_ex(ctx, null, null, null, null).sslEnforce(); EVP_EncryptUpdate(ctx, ciphertext.ptr, &c_len, plaintext.ptr, cast(uint)plaintext.length).sslEnforce(); EVP_EncryptFinal_ex(ctx, ciphertext.ptr+c_len, &f_len).sslEnforce(); return ciphertext[0..c_len+f_len]; } enum API_MAILHIDE_SERVER = "http://mailhide.recaptcha.net"; public: this(Config config) { this.config = config; aesInit(arrayFromHex(config.privateKey)); } string getUrl(string email) { return API_MAILHIDE_SERVER ~ "/d" ~ "?hl=en" ~ "&k=" ~ encodeComponent(pubKey) ~ "&c=" ~ cast(string)Base64URL.encode(aesEncrypt(cast(ubyte[])email)) ; } } MailHide mailHide; static this() { import dfeed.common : createService; mailHide = createService!MailHide("apis/mailhide"); } ================================================ FILE: src/dfeed/web/markdown.d ================================================ /* Copyright (C) 2021, 2022 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Markdown rendering using cmark. module dfeed.web.markdown; import std.array; import std.concurrency : initOnce; import std.exception; import std.functional : memoize; import std.process; import std.utf : validate; import ae.sys.file : readFile; import ae.utils.path; /// Do we have the ability to render Markdown on this system? bool haveMarkdown() { __gshared bool result; return initOnce!result(haveExecutable("cmark-gfm")); } /// Render this text as Markdown to HTML now. string renderMarkdown(string s) { // Pre-process the string s = s .replace("\n-- \n", "\n\n-- \n") // Disambiguate signatures (from setext headings) ; auto p = pipeProcess([ "timeout", "1", "cmark-gfm", "--hardbreaks", // paragraphs are unwrapped in formatBody "--extension", "table", "--extension", "strikethrough", "--strikethrough-double-tilde", "--extension", "autolink", ], Redirect.stdin | Redirect.stdout); // cmark reads all input before emitting any output, so it's safe // for us to write all input while not reading anything. p.stdin.rawWrite(s); p.stdin.close(); auto result = cast(string)readFile(p.stdout); p.stdout.close(); auto status = wait(p.pid); enforce(status != 124, "Time-out"); enforce(status == 0, "cmark failed"); validate(result); // Post-process the results result = result .replace("
\n", `> `) .replace("\n
", ``) .replace("", ``) .replace(`= 2) banned[parts[0]] = parts[1..$].join("\t"); } } void saveBanList() { const inProgressFileName = banListFileName ~ ".inprogress"; auto f = File(inProgressFileName, "wb"); foreach (key, reason; banned) f.writefln("%s\t%s", key, reason); f.close(); rename(inProgressFileName, banListFileName); } /// Parse parent keys from a propagated ban reason string string[] parseParents(string s) { import std.algorithm.searching : findSplit; string[] result; while ((s = s.findSplit(" (propagated from ")[2]) != null) { auto p = s.findSplit(")"); result ~= p[0]; s = p[2]; } return result; } ================================================ FILE: src/dfeed/web/posting.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.posting; import std.algorithm; import std.conv; import std.datetime; import std.exception; import std.range.primitives; import std.string; import std.file; import ae.net.asockets : socketManager, onNextTick; import ae.net.ietf.headers; import ae.net.ietf.url; import ae.net.nntp.client; import ae.net.smtp.client; import ae.sys.log; import ae.utils.array; import ae.utils.sini; import ae.utils.json; import ae.utils.text; import dfeed.loc; import dfeed.paths : resolveSiteFile; import dfeed.common; import dfeed.database; import dfeed.groups; import dfeed.message; import dfeed.site; import dfeed.sources.newsgroups : NntpConfig; import dfeed.web.captcha; import dfeed.web.spam : Spamicity, spamCheck, spamThreshold; import dfeed.web.user; import dfeed.web.web.postmod : ModerationReason, shouldModerate; import dfeed.web.web.posting : moderateMessage; struct PostDraft { /// Note: convert this to int before writing to database! enum Status : int { /// Unused. Default value, invalid. reserved = 0, /// Unsent draft. edited = 1, /// Sent draft. sent = 2, /// Discarded draft. /// Persisted in the database, at least for a while, to enable one-click undo. discarded = 3, /// In the moderation queue. /// Inaccessible to the author while in this state (mainly so /// they can't vandalize the message if they know a moderator /// will reject it, or recover its text and attempt to repost /// it from another identity). moderation = 4, } Status status; UrlParameters clientVars; string[string] serverVars; } enum PostingStatus { none, captcha, spamCheck, connecting, posting, waiting, posted, moderated, captchaFailed, spamCheckFailed, serverError, redirect, } struct PostError { string message; CaptchaErrorData captchaError; string extraHTML; } final class PostProcess { PostDraft draft; string pid, ip; Headers headers; Rfc850Post post; PostingStatus status; PostError error; bool captchaPresent; User user; this(PostDraft draft, User user, string userID, string ip, Headers headers, Rfc850Post parent) { this.draft = draft; this.ip = ip; this.headers = headers; this.user = user; this.post = createPost(draft, headers, ip, parent); enforce(draft.clientVars.get("name", "").length, _!"Please enter a name"); enforce(draft.clientVars.get("email", "").length, _!"Please enter an email address"); enforce(draft.clientVars.get("subject", "").length, _!"Please enter a message subject"); enforce(draft.clientVars.get("text", "").length, _!"Please enter a message"); this.pid = randomString(); postProcesses[pid] = this; this.post.id = pidToMessageID(pid); log = createLogger("PostProcess-" ~ pid); log("IP: " ~ ip); foreach (name, value; draft.clientVars) foreach (line; splitAsciiLines(value)) log("[Form] " ~ name ~ ": " ~ line); foreach (name, value; draft.serverVars) foreach (line; splitAsciiLines(value)) log("[ServerVar] " ~ name ~ ": " ~ line); foreach (name, value; headers) log("[Header] " ~ name ~ ": " ~ value); // Discard duplicate posts (redirect to original) string allContent = draftContent(draft); if (allContent in postsByContent && postsByContent[allContent] in postProcesses && postProcesses[postsByContent[allContent]].status != PostingStatus.serverError) { string original = postsByContent[allContent]; log("Duplicate post, redirecting to " ~ original); pid = original; status = PostingStatus.redirect; return; } else postsByContent[allContent] = pid; post.compile(); } /// Parse a log file this(string fileName) { pid = "unknown"; { import std.regex; auto m = fileName.match(` - PostProcess-([a-z]{20})\.log`); if (m) pid = m.captures[1]; } foreach (line; split(cast(string)read(fileName), "\n")) { if (line.length < 30 || line[0] != '[') continue; line = line.findSplit("] ")[2]; // trim timestamp static void addLine(T)(ref T aa, string var, string line) { if (var in aa) { if (!line.isOneOf(aa[var].split("\n"))) aa[var] ~= "\n" ~ line; } else aa[var] = line; } if (line.skipOver("[Form] ")) { auto var = line.skipUntil(": "); if (var=="where" || var=="parent") addLine(draft.serverVars, var, line); else addLine(draft.clientVars, var, line); } else if (line.skipOver("[ServerVar] ")) { auto var = line.skipUntil(": "); addLine(draft.serverVars, var, line); } else if (line.skipOver("[Header] ")) { auto name = line.skipUntil(": "); headers[name] = line; } else if (line.skipOver("IP: ")) ip = line; else if (line.skipOver("< Message-ID: <")) pid = line.skipUntil("@"); } post = createPost(draft, headers, ip, null); post.id = pidToMessageID(pid); post.compile(); } // Parse back a Rfc850Post (e.g. to check spam of an arbitrary message) this(Rfc850Post post) { this.post = post; draft.clientVars["name"] = post.author; draft.clientVars["email"] = post.authorEmail; draft.clientVars["subject"] = post.subject; draft.clientVars["text"] = post.content; // TODO: unwrap draft.serverVars["where"] = post.where; foreach (name, value; post.headers) if (name.skipOver("X-Web-")) { if (name == "Originating-IP") this.ip = value; else this.headers.add(name, value); } } static string pidToMessageID(string pid) { return format("<%s@%s>", pid, site.host); } void logLine(string s) { try log.log(s); catch (Exception e) {} } void run() { assert(status != PostingStatus.redirect, "Attempting to run a duplicate PostProcess"); // Allow the scope(exit) in callers to run before we begin our own processing socketManager.onNextTick(&runImpl); } private void runImpl() { if ("preapproved" in draft.serverVars) { log("Pre-approved, skipping spam check / CAPTCHA"); postMessage(); return; } auto captcha = getCaptcha(post.captcha); captchaPresent = captcha ? captcha.isPresent(draft.clientVars) : false; if (captchaPresent) { log("Checking CAPTCHA"); auto challengeDesc = captcha.getChallengeDescription(draft.clientVars); if (challengeDesc) log(" CAPTCHA question: " ~ challengeDesc.toJson); status = PostingStatus.captcha; captcha.verify(draft.clientVars, ip, &onCaptchaResult); } else { if (user) { auto n = user.get("solved-captchas", "0", SettingType.registered).to!uint; enum captchaThreshold = 10; if (n >= captchaThreshold) { log("User is trusted, skipping spam check"); postMessage(); return; } } log("Checking for spam"); status = PostingStatus.spamCheck; spamCheck(this, &onSpamResult, &logLine); } } static Rfc850Post createPost(PostDraft draft, Headers headers, string ip, Rfc850Post parent = null) { Rfc850Post post; if ("parent" in draft.serverVars) { if (parent) { auto parentID = draft.serverVars["parent"]; assert(parent.id == parentID, "Invalid parent ID"); post = parent.replyTemplate(); } else post = Rfc850Post.newPostTemplate(null); } else { assert(parent is null, "Parent specified but not parent in serverVars"); if ("where" in draft.serverVars) post = Rfc850Post.newPostTemplate(draft.serverVars["where"]); else assert(false, "No 'parent' or 'where'"); } post.author = draft.clientVars.get("name", null); post.authorEmail = draft.clientVars.get("email", null); post.subject = post.rawSubject = draft.clientVars.get("subject", null); post.setText(draft.clientVars.get("text", null)); if ("markdown" in draft.clientVars) post.markup = "markdown"; if (auto pUserAgent = "User-Agent" in headers) post.headers["X-Web-User-Agent"] = *pUserAgent; if (ip) post.headers["X-Web-Originating-IP"] = ip; if ("did" in draft.clientVars) post.id = format("", draft.clientVars["did"], site.host); post.msg.time = post.time; return post; } // ********************************************************************** private static string draftContent(ref /*const*/ PostDraft draft) { return draft.clientVars.values.sort().release().join("\0"); } static void allowReposting(ref /*const*/ PostDraft draft) { postsByContent.remove(draftContent(draft)); } // ********************************************************************** private: Logger log; void onCaptchaResult(bool ok, string errorMessage, CaptchaErrorData errorData) { if (!ok) { this.status = PostingStatus.captchaFailed; this.error = PostError(_!"CAPTCHA error:" ~ " " ~ errorMessage, errorData); this.user = User.init; log("CAPTCHA failed: " ~ errorMessage); if (errorData) log("CAPTCHA error data: " ~ errorData.toString()); log.close(); return; } log("CAPTCHA OK"); if (user) { auto n = user.get("solved-captchas", "0", SettingType.registered).to!uint; n++; user.set("solved-captchas", text(n), SettingType.registered); log(" (user solved %d CAPTCHAs)".format(n)); } // Now run spam check to get spamicity for shouldModerate() log("Running spam check after CAPTCHA"); status = PostingStatus.spamCheck; spamCheck(this, &onSpamResultAfterCaptcha, &logLine); } void onSpamResult(Spamicity spamicity, string errorMessage) { // Cache the overall spamicity for later retrieval draft.serverVars["spamicity"] = spamicity.text; if (spamicity >= spamThreshold) { log("Spam check failed (spamicity: %.2f): %s".format(spamicity, errorMessage)); // Check if CAPTCHA is available to challenge the user if (getCaptcha(post.captcha)) { // CAPTCHA available - let user try to solve it this.status = PostingStatus.spamCheckFailed; this.error = PostError(errorMessage); } else { // No CAPTCHA configured - quarantine for moderation auto reason = ModerationReason(ModerationReason.Kind.spam, "No CAPTCHA configured and spam check failed: " ~ errorMessage); this.status = PostingStatus.moderated; moderateMessage(draft, headers, reason); log("Quarantined for moderation: " ~ reason.toString()); } this.user = User.init; log.close(); return; } log("Spam check OK (spamicity: %.2f)".format(spamicity)); checkForModeration(); } void onSpamResultAfterCaptcha(Spamicity spamicity, string errorMessage) { // Cache the overall spamicity for later retrieval draft.serverVars["spamicity"] = spamicity.text; log("Spam check after CAPTCHA: spamicity %.2f".format(spamicity)); // CAPTCHA was solved, so proceed to moderation check. // shouldModerate() will quarantine if spamicity is very high. checkForModeration(); } void checkForModeration() { auto moderationReason = shouldModerate(draft); if (moderationReason.kind != ModerationReason.Kind.none) { this.status = PostingStatus.moderated; this.user = User.init; moderateMessage(draft, headers, moderationReason); log("Quarantined for moderation: " ~ moderationReason.toString()); log.close(); return; } postMessage(); } // ********************************************************************** void postMessage() { auto groups = post.xref.map!(x => x.group.getGroupInfo()); enforce(groups.length, "No groups"); auto group = groups.front; auto sinkTypes = groups.map!(group => group.sinkType.dup); // Issue 17264 enforce(sinkTypes.uniq.walkLength == 1, "Can't cross-post across protocols"); switch (group.sinkType) { case null: throw new Exception(_!"You can't post to this group."); case "nntp": nntpSend(group.sinkName); break; case "smtp": smtpSend(group); break; case "local": localSend(); break; default: assert(false, "Unknown sinkType: " ~ group.sinkType); } } void nntpSend(string name) { NntpClient nntp; void onDisconnect(string reason, DisconnectType type) { this.status = PostingStatus.serverError; this.error = PostError(_!"NNTP connection error:" ~ " " ~ reason); this.user = User.init; log("NNTP connection error: " ~ reason); log.close(); } void onError(string error) { this.status = PostingStatus.serverError; this.error = PostError(_!"NNTP error:" ~ " " ~ error); this.user = User.init; nntp.handleDisconnect = null; if (nntp.connected) nntp.disconnect(); log("NNTP error: " ~ error); log.close(); } void onPosted() { if (this.status == PostingStatus.posting) this.status = PostingStatus.waiting; this.user = User.init; nntp.handleDisconnect = null; nntp.disconnect(); log("Message posted successfully."); log.close(); } void onConnect() { this.status = PostingStatus.posting; nntp.postMessage(post.message.splitAsciiLines(), &onPosted, &onError); } status = PostingStatus.connecting; auto config = loadIni!NntpConfig(resolveSiteFile("config/sources/nntp/" ~ name ~ ".ini")); if (!config.postingAllowed) throw new Exception(_!"Posting is disabled"); nntp = new NntpClient(log); nntp.handleDisconnect = &onDisconnect; nntp.connect(config.host, &onConnect); } void smtpSend(in dfeed.groups.Config.Group* group) { SmtpClient smtp; void onError(string error) { this.status = PostingStatus.serverError; this.error = PostError(_!"SMTP error:" ~ " " ~ error); this.user = User.init; log("SMTP error: " ~ error); log.close(); } void onSent() { if (this.status == PostingStatus.posting) this.status = PostingStatus.waiting; this.user = User.init; log("Message posted successfully."); log.close(); } void onStateChanged() { if (smtp.state == SmtpClient.State.mailFrom) status = PostingStatus.posting; } status = PostingStatus.connecting; auto config = loadIni!SmtpConfig(resolveSiteFile("config/sources/smtp/" ~ group.sinkName ~ ".ini")); auto recipient = "<" ~ toLower(group.internalName) ~ "@" ~ config.domain ~ ">"; smtp = new SmtpClient(log, site.host, config.server, config.port); smtp.handleSent = &onSent; smtp.handleError = &onError; smtp.handleStateChanged = &onStateChanged; smtp.sendMessage( "<" ~ post.authorEmail ~ ">", recipient, ["To: " ~ recipient] ~ post.message.splitAsciiLines() ); } void localSend() { status = PostingStatus.posting; announcePost(post, Fresh.yes); this.status = PostingStatus.posted; this.user = User.init; log("Message stored locally."); log.close(); } } struct SmtpConfig { string domain; string server; ushort port = 25; string listInfo; } PostProcess[string] postProcesses; string[string] postsByContent; final class PostingNotifySink : NewsSink { override void handlePost(Post post, Fresh fresh) { auto rfc850post = cast(Rfc850Post)post; if (rfc850post) { auto id = rfc850post.id; if (id.endsWith("@" ~ site.host ~ ">")) { auto pid = id.split("@")[0][1..$]; if (pid in postProcesses) { postProcesses[pid].status = PostingStatus.posted; postProcesses[pid].user = User.init; postProcesses[pid].post.url = rfc850post.url; query!"UPDATE [Drafts] SET [Status]=? WHERE [ID]=?".exec(PostDraft.Status.sent, postProcesses[pid].draft.clientVars.get("did", pid)); } } } } } ================================================ FILE: src/dfeed/web/spam/akismet.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.spam.akismet; import ae.net.http.client; import dfeed.loc; import dfeed.site; import dfeed.web.posting; import dfeed.web.spam; class Akismet : SpamChecker { struct Config { string key; } Config config; this(Config config) { this.config = config; } override void check(PostProcess process, SpamResultHandler handler) { if (!config.key) return handler(unconfiguredHam, "Akismet is not set up"); string[string] params = [ "blog" : site.proto ~ "://" ~ site.host ~ "/", "user_ip" : process.ip, "user_agent" : process.headers.get("User-Agent", ""), "referrer" : process.headers.get("Referer", ""), "comment_author" : process.draft.clientVars.get("name", ""), "comment_author_email" : process.draft.clientVars.get("email", ""), "comment_content" : process.draft.clientVars.get("text", ""), ]; return httpPost("http://" ~ config.key ~ ".rest.akismet.com/1.1/comment-check", UrlParameters(params), (string result) { if (result == "false") handler(likelyHam, null); else if (result == "true") handler(likelySpam, _!"Akismet thinks your post looks like spam"); else handler(errorSpam, _!"Akismet error:" ~ " " ~ result); }, (string error) { handler(errorSpam, _!"Akismet error:" ~ " " ~ error); }); } override void sendFeedback(PostProcess process, SpamResultHandler handler, SpamFeedback feedback) { if (!config.key) return handler(unconfiguredHam, "Akismet is not set up"); string[string] params = [ "blog" : site.proto ~ "://" ~ site.host ~ "/", "user_ip" : process.ip, "user_agent" : process.headers.get("User-Agent", ""), "referrer" : process.headers.get("Referer", ""), "comment_author" : process.draft.clientVars.get("name", ""), "comment_author_email" : process.draft.clientVars.get("email", ""), "comment_content" : process.draft.clientVars.get("text", ""), ]; string[SpamFeedback] names = [ SpamFeedback.spam : "spam", SpamFeedback.ham : "ham" ]; return httpPost("http://" ~ config.key ~ ".rest.akismet.com/1.1/submit-" ~ names[feedback], UrlParameters(params), (string result) { if (result == "Thanks for making the web a better place.") handler(likelyHam, null); else handler(errorSpam, "Akismet error: " ~ result); }, (string error) { handler(errorSpam, "Akismet error: " ~ error); }); } } ================================================ FILE: src/dfeed/web/spam/bayes.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.spam.bayes; import std.algorithm.searching; import std.file; import std.string; import ae.utils.json; import ae.utils.text; import dfeed.loc; import dfeed.bayes; import dfeed.web.lint; import dfeed.web.posting; import dfeed.web.spam; class BayesChecker : SpamChecker { BayesModel model; bool modelLoaded; this() { auto fn = "data/bayes/model.json"; if (fn.exists) { model = fn.readText.jsonParse!BayesModel; modelLoaded = true; } } static string messageFromDraft(in ref PostDraft draft) { string message; auto subject = draft.clientVars.get("subject", "").toLower(); if ("parent" !in draft.serverVars || !subject.startsWith("Re: ")) // top-level or custom subject message = subject ~ "\n\n"; message ~= draft.getNonQuoteLines().join("\n"); return message; } double checkDraft(in ref PostDraft draft) { return model.checkMessage(messageFromDraft(draft)); } override void check(PostProcess process, SpamResultHandler handler) { if (!modelLoaded) return handler(likelyHam, "No model"); auto spamicity = checkDraft(process.draft); auto percent = cast(int)(spamicity * 100); handler(spamicity, "%d%%".format(percent)); } } ================================================ FILE: src/dfeed/web/spam/blogspam.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.spam.blogspam; import ae.net.http.client; import ae.sys.data; import ae.utils.json; import dfeed.loc; import dfeed.site; import dfeed.web.posting; import dfeed.web.spam; class BlogSpam : SpamChecker { private string[string] getParams(PostProcess process) { return [ "comment" : process.draft.clientVars.get("text", ""), "ip" : process.ip, "agent" : process.headers.get("User-Agent", ""), "email" : process.draft.clientVars.get("email", ""), "name" : process.draft.clientVars.get("name", ""), "site" : site.proto ~ "://" ~ site.host ~ "/", "subject" : process.draft.clientVars.get("subject", ""), "version" : "DFeed (+https://github.com/CyberShadow/DFeed)", ]; } override void check(PostProcess process, SpamResultHandler handler) { auto params = getParams(process); return httpPost("http://test.blogspam.net:9999/", DataVec(Data(toJson(params))), "application/json", (string responseText) { auto response = responseText.jsonParse!(string[string]); auto result = response.get("result", null); auto reason = response.get("reason", "no reason given"); if (result == "OK") handler(likelyHam, reason); else if (result == "SPAM") handler(likelySpam, _!"BlogSpam.net thinks your post looks like spam:" ~ " " ~ reason); else if (result == "ERROR") handler(errorSpam, _!"BlogSpam.net error:" ~ " " ~ reason); else handler(errorSpam, _!"BlogSpam.net unexpected response:" ~ " " ~ result); }, (string error) { handler(errorSpam, _!"BlogSpam.net error:" ~ " " ~ error); }); } override void sendFeedback(PostProcess process, SpamResultHandler handler, SpamFeedback feedback) { auto params = getParams(process); string[SpamFeedback] names = [ SpamFeedback.spam : "spam", SpamFeedback.ham : "ok" ]; params["train"] = names[feedback]; return httpPost("http://test.blogspam.net:9999/classify", DataVec(Data(toJson(params))), "application/json", (string responseText) { auto response = responseText.jsonParse!(string[string]); auto result = response.get("result", null); auto reason = response.get("reason", "no reason given"); if (result == "OK") handler(likelyHam, reason); else if (result == "ERROR") handler(errorSpam, _!"BlogSpam.net error:" ~ " " ~ reason); else handler(errorSpam, _!"BlogSpam.net unexpected response:" ~ " " ~ result); }, (string error) { handler(errorSpam, _!"BlogSpam.net error:" ~ " " ~ error); }); } } ================================================ FILE: src/dfeed/web/spam/openai.d ================================================ /* Copyright (C) 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.spam.openai; import std.algorithm; import std.conv; import std.format; import std.math; import std.string; import ae.net.http.client; import ae.sys.data; import ae.sys.dataset; import ae.utils.json; import dfeed.loc; import dfeed.site; import dfeed.web.posting; import dfeed.web.spam; class OpenAI : SpamChecker { struct Config { string apiKey; string model = "gpt-4o-mini"; } Config config; this(Config config) { this.config = config; } override void check(PostProcess process, SpamResultHandler handler) { if (!config.apiKey) return handler(unconfiguredHam, "OpenAI is not set up"); if (!site.name.length) return handler(unconfiguredHam, "Site name is not set - edit config/site.ini"); // Build the prompt - ask for reasoning first, then verdict // This helps the model think through the decision while still being parseable auto systemPrompt = format( "You are a spam detection system for the online forum titled \"%s\". " ~ "Analyze posts and determine if they are spam or legitimate (ham). " ~ "First, briefly explain your reasoning (1-2 sentences), then on a new line, " ~ "provide your verdict as either 'VERDICT: spam' or 'VERDICT: ham'.\n\n" ~ "Consider spam to be:\n" ~ "- New threads completely unrelated to this forum's topic (even if they appear helpful for other topics)\n" ~ "- Unsolicited advertising or promotional content\n" ~ "- Generic troubleshooting guides for consumer products unrelated to the forum's purpose\n" ~ "- Malicious links or suspicious URLs\n" ~ "- Repetitive patterns or poor grammar used to evade filters\n\n" ~ "Consider ham to be:\n" ~ "- Posts relevant to this forum's topic and purpose\n" ~ "- Posts that continue an existing discussion, even if tangential to the forum's topic", site.name ); auto userMessage = format( "Author: %s\nEmail: %s\nSubject: %s\n\nContent:\n%s", process.draft.clientVars.get("name", ""), process.draft.clientVars.get("email", ""), process.draft.clientVars.get("subject", ""), process.draft.clientVars.get("text", "") ); // Build the API request as JSON string import std.json : JSONValue, JSONType; JSONValue requestBody = JSONValue([ "model": JSONValue(config.model), "messages": JSONValue([ JSONValue([ "role": JSONValue("system"), "content": JSONValue(systemPrompt), ]), JSONValue([ "role": JSONValue("user"), "content": JSONValue(userMessage), ]), ]), "logprobs": JSONValue(true), "top_logprobs": JSONValue(5), "max_tokens": JSONValue(100), "temperature": JSONValue(0.0), ]); auto requestData = requestBody.toString(); // Make the API call // Note: We need to use httpRequest instead of httpPost to add custom headers (Authorization) auto request = new HttpRequest; request.resource = "https://api.openai.com/v1/chat/completions"; request.method = "POST"; request.headers["Authorization"] = "Bearer " ~ config.apiKey; request.headers["Content-Type"] = "application/json"; request.data = DataVec(Data(requestData)); httpRequest(request, (HttpResponse response, string disconnectReason) { if (!response) { handler(errorSpam, "OpenAI error: " ~ disconnectReason); return; } if (response.status != HttpStatusCode.OK) { auto errorMsg = cast(string)response.getContent().toGC(); handler(errorSpam, format("OpenAI API error (HTTP %d): %s", response.status, errorMsg.length > 200 ? errorMsg[0..200] ~ "..." : errorMsg)); return; } auto responseText = cast(string)response.getContent().toGC(); try { import std.json : parseJSON; auto responseJson = parseJSON(responseText); // Extract the response text auto choices = responseJson["choices"].array; if (choices.length == 0) { handler(errorSpam, "OpenAI error: No choices in response"); return; } auto choice = choices[0]; auto message = choice["message"]; auto content = message["content"].str; // Parse verdict from response (should contain "VERDICT: spam" or "VERDICT: ham") bool isSpam; if (content.toLower().canFind("verdict: spam") || content.toLower().canFind("verdict:spam")) isSpam = true; else if (content.toLower().canFind("verdict: ham") || content.toLower().canFind("verdict:ham")) isSpam = false; else { // Fallback: check for spam/ham keywords in response auto lowerContent = content.toLower(); if (lowerContent.canFind("spam") && !lowerContent.canFind("not spam")) isSpam = true; else if (lowerContent.canFind("ham") || lowerContent.canFind("legitimate")) isSpam = false; else { handler(errorSpam, format("OpenAI error: Could not parse verdict from response: %s", content.length > 200 ? content[0..200] ~ "..." : content)); return; } } // Extract confidence from logprobs by finding "spam" and "ham" probabilities Spamicity spamicity; bool hasLogprobs = false; if (auto logprobs = "logprobs" in choice.object) { if (auto content_logprobs = "content" in logprobs.object) { // Check the last token's top_logprobs for " spam" and " ham" double spamProb = 0.0; double hamProb = 0.0; auto tokens = content_logprobs.array; if (tokens.length > 0) { auto lastToken = tokens[$-1]; // Check if the last token has top_logprobs if (auto top_logprobs = "top_logprobs" in lastToken.object) { // Search through the top alternatives for " spam" or " ham" foreach (altToken; top_logprobs.array) { auto tokenStr = altToken["token"].str; auto logprob = altToken["logprob"].floating; auto prob = exp(logprob); // Convert log to linear probability if (tokenStr == " spam") spamProb = prob; else if (tokenStr == " ham") hamProb = prob; } } } // Calculate spamicity as weighted proportion auto totalProb = spamProb + hamProb; if (totalProb > 0) { spamicity = spamProb / totalProb; hasLogprobs = true; } } } // If no logprobs available, use likely constants if (!hasLogprobs) spamicity = isSpam ? likelySpam : likelyHam; // Return full model response in the message auto verdict = isSpam ? "spam" : "ham"; auto resultMessage = format("%s thinks your post is %s: %s", config.model, verdict, content); handler(spamicity, resultMessage); } catch (Exception e) { handler(errorSpam, format("OpenAI error: %s", e.msg)); } }); } } version (main_openai) void main(string[] args) { import std.exception : enforce; import std.file : dirEntries, SpanMode; import std.stdio : stdout; import ae.net.asockets : socketManager; static import ae.net.ssl.openssl; import dfeed.common : createService; auto openai = createService!OpenAI("apis/openai").enforce("OpenAI is not configured"); foreach (fn; args[1..$]) { if (fn.length == 20) fn = dirEntries("logs", "* - PostProcess-" ~ fn ~ ".log", SpanMode.shallow).front.name; stdout.writeln("--------------------------------------------------------------------"); auto pp = new PostProcess(fn); stdout.write(pp.post.message); stdout.writeln(); stdout.writeln("--------------------------------------------------------------------"); void handler(Spamicity spamicity, string message) { stdout.writefln("%s: %s", message, spamicity); } openai.check(pp, &handler); socketManager.loop(); } } ================================================ FILE: src/dfeed/web/spam/package.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.spam; import std.algorithm; import std.exception; import std.file : readText; import std.string; import ae.net.http.client; import ae.sys.data; import ae.utils.array; import ae.utils.json; import ae.utils.text; import dfeed.loc; import dfeed.site; import dfeed.web.posting; import dfeed.web.spam.akismet; import dfeed.web.spam.bayes; import dfeed.web.spam.blogspam; import dfeed.web.spam.openai; import dfeed.web.spam.projecthoneypot; import dfeed.web.spam.simple; import dfeed.web.spam.stopforumspam; void spamCheck(PostProcess process, SpamResultHandler handler, void delegate(string) log = null) { if (!spamCheckers) initSpamCheckers(); int totalResults = 0; bool foundSpam = false; Spamicity maxSpamicity = 0.0; string maxSpamicityMessage = null; // Start all checks simultaneously foreach (checker; spamCheckers) { try (SpamChecker checker) { checker.check(process, (Spamicity spamicity, string message) { totalResults++; if (log) log("Got reply from spam checker %s: spamicity %.2f (%s)".format( checker.classinfo.name, spamicity, message)); if (!foundSpam) { // Track the highest spamicity score if (spamicity > maxSpamicity) { maxSpamicity = spamicity; maxSpamicityMessage = message; } // If spamicity exceeds threshold, immediately report as spam if (spamicity >= spamThreshold) { handler(spamicity, message); foundSpam = true; } else { // If all checkers are done and none found spam, report max spamicity if (totalResults == spamCheckers.length) handler(maxSpamicity, maxSpamicityMessage); } } }); } (checker); catch (Exception e) { if (log) log("Error with spam checker %s: %s".format( checker.classinfo.name, e.msg)); foundSpam = true; handler(errorSpam, _!"Spam check error:" ~ " " ~ e.msg); } // Avoid starting slow checks if the first engines instantly return a positive if (foundSpam) break; } } void sendSpamFeedback(PostProcess process, SpamResultHandler handler, SpamFeedback feedback) { if (!spamCheckers) initSpamCheckers(); foreach (checker; spamCheckers) checker.sendFeedback(process, handler, feedback); } // ************************************************************************** /// Spam confidence score: 0.0 = definitely ham (not spam), 1.0 = definitely spam /// This follows industry-standard semantics where higher values indicate higher spam probability alias Spamicity = double; /// Confidence threshold - scores >= this value are considered spam enum Spamicity spamThreshold = 0.5; /// Very high confidence threshold - scores >= this value should be quarantined/moderated enum Spamicity certainlySpamThreshold = 0.98; /// Predefined spamicity levels for checkers that don't provide granular scores enum Spamicity certainlyHam = 0.0; /// Definitely not spam enum Spamicity likelyHam = 0.25; /// Probably not spam enum Spamicity likelySpam = 0.75; /// Probably spam enum Spamicity certainlySpam = 1.0; /// Definitely spam /// Confidence level returned by spam checkers when they are not configured /// (missing API keys or other configuration). /// We return 0 so that the maximizing logic in spamCheck treats this result /// synonymously with the spam checker not being present. alias unconfiguredHam = certainlyHam; /// Confidence level for errors (challenge instead of outright rejection) alias errorSpam = likelySpam; alias void delegate(Spamicity spamicity, string message) SpamResultHandler; enum SpamFeedback { unknown, spam, ham } class SpamChecker { abstract void check(PostProcess process, SpamResultHandler handler); void sendFeedback(PostProcess process, SpamResultHandler handler, SpamFeedback feedback) { handler(likelyHam, "Not implemented"); } } // ************************************************************************** BayesChecker bayes() { if (!spamCheckers) initSpamCheckers(); return bayesInst; } Spamicity getSpamicity(in ref PostDraft draft) { auto p = *("spamicity" in draft.serverVars) .enforce("getSpamicity called without running spam check first"); import std.conv : to; return p.to!Spamicity; } // ************************************************************************** SpamChecker[] spamCheckers; private BayesChecker bayesInst; void initSpamCheckers() { assert(spamCheckers is null); import dfeed.common; spamCheckers ~= new SimpleChecker(); spamCheckers ~= bayesInst = new BayesChecker(); if (auto c = createService!ProjectHoneyPot("apis/projecthoneypot")) spamCheckers ~= c; if (auto c = createService!Akismet("apis/akismet")) spamCheckers ~= c; if (auto c = createService!OpenAI("apis/openai")) spamCheckers ~= c; if (auto c = createService!StopForumSpam("apis/stopforumspam")) spamCheckers ~= c; //spamCheckers ~= new BlogSpam(); } ================================================ FILE: src/dfeed/web/spam/projecthoneypot.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.spam.projecthoneypot; import std.algorithm.mutation; import std.array; import std.exception; import std.string; import dfeed.loc; import dfeed.site; import dfeed.web.posting; import dfeed.web.spam; class ProjectHoneyPot : SpamChecker { struct Config { string key; } Config config; this(Config config) { this.config = config; } override void check(PostProcess process, SpamResultHandler handler) { if (!config.key) return handler(certainlyHam, "ProjectHoneyPot is not set up"); enum DAYS_THRESHOLD = 7; // consider an IP match as a positive if it was last seen at most this many days ago enum SCORE_THRESHOLD = 10; // consider an IP match as a positive if its ProjectHoneyPot score is at least this value struct PHPResult { bool present; ubyte daysLastSeen, threatScore, type; } PHPResult phpCheck(string ip) { import std.socket; string[] sections = split(ip, "."); if (sections.length != 4) // IPv6 return PHPResult(false); sections.reverse(); string addr = ([config.key] ~ sections ~ ["dnsbl.httpbl.org"]).join("."); InternetHost ih = new InternetHost; if (!ih.getHostByName(addr)) return PHPResult(false); auto resultIP = cast(ubyte[])(&ih.addrList[0])[0..1]; resultIP.reverse(); enforce(resultIP[0] == 127, "PHP API error"); return PHPResult(true, resultIP[1], resultIP[2], resultIP[3]); } auto result = phpCheck(process.ip); with (result) if (present && daysLastSeen <= DAYS_THRESHOLD && threatScore >= SCORE_THRESHOLD) { // Normalize threat score (0-255) to spamicity (0.0-1.0) auto spamicity = threatScore / 255.0; handler(spamicity, format( _!"ProjectHoneyPot thinks you may be a spammer (%s last seen: %d days ago, threat score: %d/255, type: %s)", process.ip, daysLastSeen, threatScore, ( ( type == 0 ? ["Search Engine" ] : []) ~ ((type & 0b0001) ? ["Suspicious" ] : []) ~ ((type & 0b0010) ? ["Harvester" ] : []) ~ ((type & 0b0100) ? ["Comment Spammer"] : []) ).join(", "))); } else handler(likelyHam, null); } } ================================================ FILE: src/dfeed/web/spam/simple.d ================================================ /* Copyright (C) 2011, 2012, 2014, 2015, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.spam.simple; import std.algorithm.searching; import std.string; import ae.utils.text; import dfeed.loc; import dfeed.site; import dfeed.web.posting; import dfeed.web.spam; class SimpleChecker : SpamChecker { override void check(PostProcess process, SpamResultHandler handler) { auto ua = process.headers.get("User-Agent", ""); if (ua.startsWith("WWW-Mechanize")) return handler(likelySpam, _!"You seem to be posting using an unusual user-agent"); auto subject = process.draft.clientVars.get("subject", "").toLower(); // "hardspamtest" triggers certainlySpam (for testing moderation flow) if (subject.contains("hardspamtest")) return handler(certainlySpam, _!"Your subject contains a keyword that triggers moderation"); foreach (keyword; ["kitchen", "spamtest"]) if (subject.contains(keyword)) return handler(likelySpam, _!"Your subject contains a suspicious keyword or character sequence"); auto text = process.draft.clientVars.get("text", "").toLower(); foreach (keyword; [" * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.spam.stopforumspam; import std.algorithm.searching; import std.array; import std.exception; import std.string; import ae.net.http.client; import dfeed.loc; import dfeed.site; import dfeed.web.posting; import dfeed.web.spam; static import ae.utils.xml; // Issue 7016 class StopForumSpam : SpamChecker { struct Config { bool enabled; } Config config; this(Config config) { this.config = config; } override void check(PostProcess process, SpamResultHandler handler) { if (!config.enabled) return handler(unconfiguredHam, "StopForumSpam is disabled"); enum DAYS_THRESHOLD = 3; // consider an IP match as a positive if it was last seen at most this many days ago auto ip = process.ip; if (ip.canFind(':') || ip.split(".").length != 4) { // Not an IPv4 address, skip StopForumSpam check return handler(certainlyHam, "Not an IPv4 address"); } httpGet("http://api.stopforumspam.org/api?ip=" ~ ip, (string result) { import std.datetime; import ae.utils.xml; import ae.utils.time : parseTime; auto xml = new XmlDocument(result); auto response = xml["response"]; if (response.attributes["success"] != "true") { string error = result; auto errorNode = response.findChild("error"); if (errorNode) error = errorNode.text; enforce(false, _!"StopForumSpam API error:" ~ " " ~ error); } if (response["appears"].text == "no") handler(likelyHam, null); else { auto date = response["lastseen"].text.parseTime!"Y-m-d H:i:s"(); if (Clock.currTime() - date < dur!"days"(DAYS_THRESHOLD)) handler(likelySpam, format( _!"StopForumSpam thinks you may be a spammer (%s last seen: %s, frequency: %s)", process.ip, response["lastseen"].text, response["frequency"].text)); else handler(likelyHam, null); } }, (string errorMessage) { handler(errorSpam, "StopForumSpam error: " ~ errorMessage); }); } } ================================================ FILE: src/dfeed/web/user.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ module dfeed.web.user; import core.bitop; import std.datetime : SysTime, Clock; import std.functional; import std.string; import std.exception; import std.base64; import ae.net.shutdown; import ae.sys.data; import std.typecons : RefCounted, refCounted; import ae.sys.log; import ae.sys.timing; import ae.utils.math : flipBits; import ae.utils.text; import ae.utils.time : StdTime; import ae.utils.time.common; import ae.utils.time.format; import ae.utils.meta.rcclass : RCClass, rcClass; import ae.utils.zlib; enum SettingType { registered, /// Always saved server-side, for registered users only server, /// Stored on the server for registered users, and cookies for non-registered ones client, /// Always stored in cookies session, /// Always stored in cookies, and expires at the end of the session } struct AccountData // for export { string username; int level; StdTime creationDate; string[string] settings; } abstract class CUser { abstract string get(string name, string defaultValue, SettingType settingType); abstract void set(string name, string value, SettingType settingType); abstract void remove(string name, SettingType settingType); abstract string[] save(); abstract void logIn(string username, string password, bool remember); abstract bool checkPassword(string password); abstract void changePassword(string password); abstract void logOut(); abstract void register(string username, string password, bool remember); abstract AccountData exportData(); abstract void deleteAccount(); abstract bool isLoggedIn(); abstract SysTime createdAt(); enum Level : int { guest = 0, /// Default user level hasRawLink = 1, /// Get a clickable "raw post" link. canFlag = 2, /// Can flag posts canApproveDrafts = 90, /// Can approve moderated drafts canModerate = 100, /// Can delete posts locally/remotely and ban users sysadmin = 1000, /// Can edit the database (presumably) } string getName() { return null; } Level getLevel() { return Level.init; } protected: /// Save misc data to string settings final void finalize() { flushReadPosts(); } // *********************************************************************** alias ReadPostsData = RefCounted!Data; void getReadPosts() in { assert(this.readPosts is ReadPostsData.init); } out { assert(this.readPosts !is ReadPostsData.init); } do { auto b64 = get("readposts", null, SettingType.server); if (b64.length) { // Temporary hack to catch Phobos bug ubyte[] zcode; string advice = _!"Try clearing your browser's cookies. Create an account to avoid repeated incidents."; try zcode = Base64.decode(b64); catch (Throwable /* Base64 throws AssertErrors on invalid data */) { import std.file; write("bad-base64.txt", b64); throw new Exception(_!"Malformed Base64 in read post history cookie." ~ " " ~ advice); } try readPosts = refCounted(uncompress(Data(zcode))); catch (ZlibException e) { import std.file; write("bad-zlib.z", zcode); throw new Exception(_!"Malformed deflated data in read post history cookie" ~ " (" ~ e.msg ~ "). " ~ advice); } } else readPosts = refCounted(Data()); } static string encodeReadPosts(ref ReadPostsData readPosts) { auto b64 = Base64.encode(cast(ubyte[])compress(readPosts, 1).contents); return assumeUnique(b64); } void saveReadPosts() in { assert(readPosts !is ReadPostsData.init && readPosts.length && readPostsDirty); } do { set("readposts", encodeReadPosts(readPosts), SettingType.server); } ReadPostsData readPosts; bool readPostsDirty; final: void needReadPosts() { if (readPosts is ReadPostsData.init) getReadPosts(); } void flushReadPosts() { if (readPosts !is ReadPostsData.init && readPosts.length && readPostsDirty) { saveReadPosts(); readPostsDirty = false; } } public bool isRead(size_t post) { needReadPosts(); auto pos = post/8; if (pos >= readPosts.length) return false; else return ((cast(ubyte[])readPosts.contents)[pos] & (1 << (post % 8))) != 0; } public void setRead(size_t post, bool value) { needReadPosts(); auto pos = post/8; if (pos >= readPosts.length) { if (value) readPosts.length = pos+1; else return; } ubyte mask = cast(ubyte)(1 << (post % 8)); assert(pos < readPosts.length); auto pbyte = (cast(ubyte*)readPosts.ptr) + pos; if (value) *pbyte = *pbyte | mask; else *pbyte = *pbyte & mask.flipBits; readPostsDirty = true; } public size_t countRead() { needReadPosts(); if (!readPosts.length) return 0; size_t count; auto uints = cast(uint*)readPosts.contents.ptr; foreach (uint u; uints[0..readPosts.length/uint.sizeof]) count += popcnt(u); foreach (ubyte b; cast(ubyte[])readPosts.contents[$/uint.sizeof*uint.sizeof..$]) count += popcnt(b); return count; } } alias User = RCClass!CUser; // *************************************************************************** class CGuestUser : CUser { string[string] cookies, newCookies; SettingType[string] settingTypes; this(string cookieHeader) { auto segments = cookieHeader.split(";"); foreach (segment; segments) { segment = segment.strip(); auto p = segment.indexOf('='); if (p > 0) { string name = segment[0..p]; if (name.startsWith("dfeed_")) cookies[name[6..$]] = segment[p+1..$]; } } } override string get(string name, string defaultValue, SettingType settingType) { auto pCookie = name in newCookies; if (pCookie) return *pCookie; pCookie = name in cookies; if (pCookie) return *pCookie; return defaultValue; } override void set(string name, string value, SettingType settingType) { newCookies[name] = value; settingTypes[name] = settingType; } override void remove(string name, SettingType settingType) { newCookies[name] = null; settingTypes[name] = settingType; } override string[] save() { finalize(); string[] result; foreach (name, value; newCookies) { if (value is null) { if (name !in cookies) continue; result ~= "dfeed_" ~ name ~ "=deleted; Expires=Thu, 01 Jan 1970 00:00:00 GMT; Path=/"; } else { auto settingType = settingTypes[name]; if (settingType == SettingType.registered) continue; if (name in cookies && cookies[name] == value) continue; if (settingType == SettingType.session) result ~= "dfeed_" ~ name ~ "=" ~ value ~ "; Path=/"; else result ~= "dfeed_" ~ name ~ "=" ~ value ~ "; Expires=" ~ (Clock.currTime() + 365.days).formatTime!(TimeFormats.HTTP) ~ "; Path=/"; } } return result; } static string encryptPassword(string password) { // TODO: use bcrypt() enforce(config.salt.length, "Salt not set!"); import std.digest.md; return (password ~ config.salt).md5Of().toHexString!(LetterCase.lower)().idup; // Issue 9279 } override void logIn(string username, string password, bool remember) { foreach (string session; query!"SELECT `Session` FROM `Users` WHERE `Username` = ? AND `Password` = ?".iterate(username, encryptPassword(password))) { set("session", session, remember ? SettingType.client : SettingType.session); return; } throw new Exception("No such username/password combination"); } enum maxPasswordLength = 64; override void register(string username, string password, bool remember) { enforce(username.length, _!"Please enter a username"); enforce(username.length <= 32, _!"Username too long"); enforce(password.length <= maxPasswordLength, _!"Password too long"); // Create user auto session = randomString(); query!"INSERT INTO `Users` (`Username`, `Password`, `Session`, `Created`) VALUES (?, ?, ?, ?)" .exec(username, encryptPassword(password), session, Clock.currTime.stdTime); // Copy cookies to database auto user = registeredUser(username); foreach (name, value; cookies) user.set(name, value, SettingType.server); user.save(); // Log them in this.set("session", session, remember ? SettingType.client : SettingType.session); } override bool checkPassword(string password) { throw new Exception(_!"Not logged in"); } override void changePassword(string password) { throw new Exception(_!"Not logged in"); } override void logOut() { throw new Exception(_!"Not logged in"); } override AccountData exportData() { throw new Exception(_!"Not logged in"); } // just check your cookies override void deleteAccount() { throw new Exception(_!"Not logged in"); } // just clear your cookies override bool isLoggedIn() { return false; } override SysTime createdAt() { return Clock.currTime(); } } alias GuestUser = RCClass!CGuestUser; alias guestUser = rcClass!CGuestUser; // *************************************************************************** import dfeed.loc; import dfeed.database; final class CRegisteredUser : CGuestUser { string[string] settings, newSettings; string username; Level level; StdTime creationTime; this(string username, string cookieHeader = null, Level level = Level.init, StdTime creationTime = 0) { super(cookieHeader); this.username = username; this.level = level; this.creationTime = creationTime; } override string get(string name, string defaultValue, SettingType settingType) { if (settingType != SettingType.server && settingType != SettingType.registered) return super.get(name, defaultValue, settingType); auto pSetting = name in newSettings; if (pSetting) return *pSetting; pSetting = name in settings; string value; if (pSetting) value = *pSetting; else { foreach (string v; query!"SELECT `Value` FROM `UserSettings` WHERE `User` = ? AND `Name` = ?".iterate(username, name)) value = v; settings[name] = value; } return value ? value : defaultValue; } override void set(string name, string value, SettingType settingType) { if (settingType == SettingType.server || settingType == SettingType.registered) newSettings[name] = value; else super.set(name, value, settingType); } override void remove(string name, SettingType settingType) { if (settingType == SettingType.server) newSettings[name] = null; else super.remove(name, settingType); } override string[] save() { finalize(); foreach (name, value; newSettings) { if (value is null) { if (name !in settings) continue; query!"DELETE FROM `UserSettings` WHERE `User` = ? AND `Name` = ?".exec(username, name); } else { if (name in settings && settings[name] == value) continue; query!"INSERT OR REPLACE INTO `UserSettings` (`User`, `Name`, `Value`) VALUES (?, ?, ?)".exec(username, name, value); } } return super.save(); } override void logIn(string username, string password, bool remember) { throw new Exception(_!"Already logged in"); } override bool isLoggedIn() { return true; } override void register(string username, string password, bool remember) { throw new Exception(_!"Already registered"); } override string getName() { return username; } override Level getLevel() { return level; } override SysTime createdAt() { return SysTime(creationTime); } override bool checkPassword(string password) { return query!"SELECT COUNT(*) FROM `Users` WHERE `Username` = ? AND `Password` = ?" .iterate(username, encryptPassword(password)) .selectValue!int != 0; } override void changePassword(string password) { enforce(password.length <= maxPasswordLength, _!"Password too long"); query!"UPDATE `Users` SET `Password` = ? WHERE `Username` = ?" .exec(encryptPassword(password), username); } override void logOut() { query!"UPDATE `Users` SET `Session` = ? WHERE `Username` = ?".exec(randomString(), username); super.remove("session", SettingType.client); } override AccountData exportData() { AccountData result; result.username = username; // Omit password hash here for security reasons // Omit session; it is already in a cookie result.level = level; result.creationDate = query!"SELECT `Created` FROM `Users` WHERE `Username` = ?" .iterate(username).selectValue!StdTime; foreach (string name, string value; query!"SELECT `Name`, `Value` FROM `UserSettings` WHERE `User` = ?".iterate(username)) result.settings[name] = value; return result; } override void deleteAccount() { // Delete all preferences foreach (string name; query!"SELECT `Name` FROM `UserSettings` WHERE `User` = ?".iterate(username)) this.remove(name, SettingType.server); save(); // Delete user query!"DELETE FROM `Users` WHERE `Username` = ?".exec(username); query!"DELETE FROM `UserSettings` WHERE `User` = ?".exec(username); } // *************************************************************************** /// Keep read posts for registered users in memory, /// and flush them out to the database periodically. static class ReadPostsCache { static struct Entry { ReadPostsData readPosts; bool dirty; } Entry[string] entries; Logger log; this() { auto flushTimer = setInterval(&flushReadPostCache, 5.minutes); addShutdownHandler((scope const(char)[] reason){ flushTimer.cancel(); flushReadPostCache(); }); log = createLogger("ReadPostsCache"); } int counter; void flushReadPostCache() { mixin(DB_TRANSACTION); foreach (username, ref cacheEntry; entries) if (cacheEntry.dirty) { log("Flushing " ~ username); auto user = registeredUser(username); user.set("readposts", encodeReadPosts(cacheEntry.readPosts), SettingType.server); user.save(); cacheEntry.dirty = false; } if (++counter % 100 == 0) { log("Clearing cache."); foreach (username, ref cacheEntry; entries) cacheEntry.readPosts = ReadPostsData.init; // Free memory now entries = null; } } } static ReadPostsCache readPostsCache; override void getReadPosts() { if (!readPostsCache) readPostsCache = new ReadPostsCache(); auto pcache = username in readPostsCache.entries; if (pcache) readPosts = pcache.readPosts; else { super.getReadPosts(); readPostsCache.entries[username] = ReadPostsCache.Entry(readPosts, false); } } override void saveReadPosts() { if (!readPostsCache) readPostsCache = new ReadPostsCache(); auto pcache = username in readPostsCache.entries; if (pcache) { assert(readPosts is pcache.readPosts); pcache.dirty = true; } else readPostsCache.entries[username] = ReadPostsCache.Entry(readPosts, true); } } alias RegisteredUser = RCClass!CRegisteredUser; alias registeredUser = rcClass!CRegisteredUser; // *************************************************************************** User getUser(string cookieHeader) { auto guest = guestUser(cookieHeader); if ("session" in guest.cookies) { foreach (string username, int level, StdTime creationTime; query!"SELECT `Username`, `Level`, `Created` FROM `Users` WHERE `Session` = ?".iterate(guest.cookies["session"])) return User(registeredUser(username, cookieHeader, cast(CUser.Level)level, creationTime)); } return User(guest); } // *************************************************************************** struct Config { string salt; } immutable Config config; import ae.utils.sini; import dfeed.paths : resolveSiteFile; shared static this() { config = loadIni!Config(resolveSiteFile("config/user.ini")); } ================================================ FILE: src/dfeed/web/web/cache.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Some cached data. module dfeed.web.web.cache; import dfeed.database; import dfeed.sinks.cache; import dfeed.web.web.perf; int[string] getThreadCounts() { enum PERF_SCOPE = "getThreadCounts"; mixin(MeasurePerformanceMixin); int[string] threadCounts; foreach (string group, int count; query!"SELECT `Group`, COUNT(*) FROM `Threads` GROUP BY `Group`".iterate()) threadCounts[group] = count; return threadCounts; } int[string] getPostCounts() { enum PERF_SCOPE = "getPostCounts"; mixin(MeasurePerformanceMixin); int[string] postCounts; foreach (string group, int count; query!"SELECT `Group`, COUNT(*) FROM `Groups` GROUP BY `Group`".iterate()) postCounts[group] = count; return postCounts; } Cached!(int[string]) threadCountCache, postCountCache; ================================================ FILE: src/dfeed/web/web/config.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Performance logging. module dfeed.web.web.config; import ae.utils.sini : loadIni; import dfeed.paths : resolveSiteFile; struct ListenConfig { string addr; ushort port = 8080; } struct Config { ListenConfig listen; string remoteIPHeader; // e.g. "X-Forwarded-For" when behind a reverse proxy string staticDomain = null; string apiSecret = null; bool indexable = false; // Widget configuration string announceGroup; // Group for "Latest announcements" widget string[] activeDiscussionExclude; // Groups to exclude from "Active discussions" } const Config config; shared static this() { config = loadIni!Config(resolveSiteFile("config/web.ini")); } ================================================ FILE: src/dfeed/web/web/draft.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Post draft utility code. module dfeed.web.web.draft; import std.conv : to; import std.datetime.systime : Clock; import std.typecons : Flag, No; import ae.net.ietf.headers : Headers; import ae.net.ietf.url : UrlParameters; import ae.utils.json : toJson, jsonParse; import ae.utils.text : randomString; import dfeed.loc; import dfeed.database : query; import dfeed.groups : GroupInfo; import dfeed.message : Rfc850Post, isMarkdown; import dfeed.web.posting : PostDraft, PostProcess; import dfeed.web.web.postinfo : getPost; import dfeed.web.web.user : user, userSettings; void createDraft(PostDraft draft) { query!"INSERT INTO [Drafts] ([ID], [UserID], [Status], [ClientVars], [ServerVars], [Time]) VALUES (?, ?, ?, ?, ?, ?)" .exec(draft.clientVars["did"], userSettings.id, int(draft.status), draft.clientVars.toJson, draft.serverVars.toJson, Clock.currTime.stdTime); } // Handle backwards compatibility in stored drafts UrlParameters jsonParseUrlParameters(string json) { if (!json) return UrlParameters.init; try return jsonParse!UrlParameters(json); catch (Exception e) { static struct S { string[][string] items; } S s = jsonParse!S(json); return UrlParameters(s.items); } } PostDraft getDraft(string draftID) { T parse(T)(string json) { return json ? json.jsonParse!T : T.init; } foreach (int status, string clientVars, string serverVars; query!"SELECT [Status], [ClientVars], [ServerVars] FROM [Drafts] WHERE [ID] == ?".iterate(draftID)) return PostDraft(status.to!(PostDraft.Status), jsonParseUrlParameters(clientVars), parse!(string[string])(serverVars)); throw new Exception("Can't find this message draft"); } PostDraft.Status getDraftStatus(string draftID) { foreach (int status; query!"SELECT [Status] FROM [Drafts] WHERE [ID] == ?".iterate(draftID)) return status.to!(PostDraft.Status); return PostDraft.Status.reserved; } void ensureDraftWritable(string draftID) { final switch (getDraftStatus(draftID)) { case PostDraft.status.reserved: case PostDraft.status.edited: case PostDraft.status.discarded: return; case PostDraft.status.sent: throw new Exception(_!"Can't edit this message. It has already been sent."); case PostDraft.status.moderation: throw new Exception(_!"Can't edit this message. It has already been submitted for moderation."); } } void saveDraft(PostDraft draft, Flag!"force" force = No.force) { auto draftID = draft.clientVars.get("did", null); auto postID = draft.serverVars.get("pid", null); if (!force) ensureDraftWritable(draftID); query!"UPDATE [Drafts] SET [PostID]=?, [ClientVars]=?, [ServerVars]=?, [Time]=?, [Status]=? WHERE [ID] == ?" .exec(postID, draft.clientVars.toJson, draft.serverVars.toJson, Clock.currTime.stdTime, int(draft.status), draftID); } void autoSaveDraft(UrlParameters clientVars) { auto draftID = clientVars.get("did", null); ensureDraftWritable(draftID); query!"UPDATE [Drafts] SET [ClientVars]=?, [Time]=?, [Status]=? WHERE [ID] == ?" .exec(clientVars.toJson, Clock.currTime.stdTime, PostDraft.Status.edited, draftID); } PostDraft newPostDraft(GroupInfo groupInfo, UrlParameters parameters = null) { auto draftID = randomString(); auto draft = PostDraft(PostDraft.Status.reserved, UrlParameters([ "did" : draftID, "name" : userSettings.name, "email" : userSettings.email, "subject" : parameters.get("subject", null), "markdown" : "on", ]), [ "where" : groupInfo.internalName, ]); createDraft(draft); return draft; } PostDraft newReplyDraft(Rfc850Post post) { auto postTemplate = post.replyTemplate(); auto draftID = randomString(); auto draft = PostDraft(PostDraft.Status.reserved, UrlParameters([ "did" : draftID, "name" : userSettings.name, "email" : userSettings.email, "subject" : postTemplate.subject, "text" : postTemplate.content, ]), [ "where" : post.where, "parent" : post.id, ]); if (post.isMarkdown()) draft.clientVars["markdown"] = "on"; createDraft(draft); return draft; } Rfc850Post draftToPost(PostDraft draft, Headers headers = Headers.init, string ip = null) { auto parent = "parent" in draft.serverVars ? getPost(draft.serverVars["parent"]) : null; return PostProcess.createPost(draft, headers, ip, parent); } ================================================ FILE: src/dfeed/web/web/moderation.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Moderation tools. module dfeed.web.web.moderation; import std.algorithm.iteration : map, filter, uniq; import std.algorithm.sorting : sort; import std.array : array, split; import std.exception : enforce; import std.file : exists, read, rename; import std.format : format; import std.process : execute; import std.range : chain, only; import std.range.primitives : empty; import std.regex : match; import std.stdio : File; import std.string : splitLines, indexOf; import std.typecons : Flag, Yes; import ae.net.http.common : HttpRequest; import ae.net.ietf.headers : Headers; import ae.net.ietf.url : UrlParameters; import ae.sys.log : Logger, fileLogger; import ae.utils.json : toJson, jsonParse; import ae.utils.meta : identity; import ae.utils.regex : escapeRE; import ae.utils.sini : loadIni; import dfeed.paths : resolveSiteFile; import ae.utils.text : splitAsciiLines, asciiStrip; import dfeed.common : handleModeration; import dfeed.database : query; import dfeed.groups : getGroupInfo; import dfeed.message : Rfc850Post; import dfeed.sinks.cache : dbVersion; import dfeed.site : site; import dfeed.sources.newsgroups : NntpConfig; import dfeed.web.moderation : banned, saveBanList, parseParents; import dfeed.web.posting : PostProcess, PostDraft; import dfeed.web.web.draft : getDraft, saveDraft; import dfeed.web.web.postinfo : getPost; import dfeed.web.web.posting : postDraft; import dfeed.web.web.postmod : learnModeratedMessage; import dfeed.web.web.user : userSettings; string findPostingLog(string id) { if (id.match(`^<[a-z]{20}@` ~ site.host.escapeRE() ~ `>`)) { auto post = id[1..21]; version (Windows) auto logs = dirEntries("logs", "*PostProcess-" ~ post ~ ".log", SpanMode.depth).array; else { import std.process; auto result = execute(["find", "logs/", "-name", "*PostProcess-" ~ post ~ ".log"]); // This is MUCH faster than dirEntries. enforce(result.status == 0, "find error"); auto logs = splitLines(result.output); } if (logs.length == 1) return logs[0]; } return null; } struct DeletedPostInfo { string timestamp; string moderator; string reason; string messageContent; string[string] postsRow; string[string] threadsRow; } DeletedPostInfo findDeletedPostInfo(string messageID) { import std.file : dirEntries, SpanMode; import std.algorithm : startsWith; DeletedPostInfo result; foreach (de; dirEntries("logs", "* - Deleted.log", SpanMode.shallow)) { auto content = cast(string) read(de.name); auto lines = splitLines(content); for (size_t i = 0; i < lines.length; i++) { auto line = lines[i]; // Look for deletion line: "[timestamp] User X is deleting post (reason)" auto deletingPos = line.indexOf(" is deleting post "); if (deletingPos < 0) continue; // Check if this line is for our message ID if (line.indexOf(messageID) < 0) continue; // Extract timestamp (everything before first "]") auto bracketPos = line.indexOf("]"); if (bracketPos > 0 && line.length > 1 && line[0] == '[') result.timestamp = line[1 .. bracketPos]; // Extract moderator name: "User X is deleting" auto userPos = line.indexOf("User "); if (userPos >= 0) { auto afterUser = line[userPos + 5 .. deletingPos]; result.moderator = afterUser; } // Extract reason from parentheses at end auto lastParen = line.indexOf("("); auto lastCloseParen = line.indexOf(")"); if (lastParen >= 0 && lastCloseParen > lastParen) result.reason = line[lastParen + 1 .. lastCloseParen]; // Extract message content (lines containing "] > ") // Log format: "[timestamp] > content" string[] contentLines; size_t j = i + 1; while (j < lines.length) { auto contentMarker = lines[j].indexOf("] > "); if (contentMarker >= 0) { contentLines ~= lines[j][contentMarker + 4 .. $]; j++; } else break; } import std.array : join; result.messageContent = contentLines.join("\n"); // Look for [Posts] and [Threads] rows while (j < lines.length) { auto postsMarker = lines[j].indexOf("[Posts] row: "); auto threadsMarker = lines[j].indexOf("[Threads] row: "); if (postsMarker >= 0) { try result.postsRow = lines[j][postsMarker + 13 .. $].jsonParse!(string[string]); catch (Exception) { } } else if (threadsMarker >= 0) { try result.threadsRow = lines[j][threadsMarker + 15 .. $].jsonParse!(string[string]); catch (Exception) { } } else if (lines[j].indexOf(" is ") >= 0 && lines[j].indexOf("post ") >= 0) { // Start of next log entry break; } j++; } return result; } } return result; } void moderatePost( string messageID, string reason, string userName, Flag!"deleteLocally" deleteLocally, Flag!"ban" ban, Flag!"deleteSource" deleteSource, Flag!"callSinks" callSinks, void delegate(string) feedbackCallback, ) { auto post = getPost(messageID); enforce(post, "Post not found"); auto moderationLog = fileLogger("Deleted"); scope(exit) moderationLog.close(); scope(failure) moderationLog("An error occurred"); void feedback(string message) { moderationLog(message); feedbackCallback(message); } moderationLog("User %s is %s post %s (%s)".format( userName, deleteLocally ? "deleting" : "moderating", post.id, reason)); foreach (line; post.message.splitAsciiLines()) moderationLog("> " ~ line); foreach (string[string] values; query!"SELECT * FROM `Posts` WHERE `ID` = ?".iterate(post.id)) moderationLog("[Posts] row: " ~ values.toJson()); foreach (string[string] values; query!"SELECT * FROM `Threads` WHERE `ID` = ?".iterate(post.id)) moderationLog("[Threads] row: " ~ values.toJson()); if (ban) { banPoster(userName, post.id, reason); feedback("User banned."); } if (callSinks) { handleModeration(post, ban); } if (deleteSource) { auto deleteCommands = post.xref .map!(x => x.group.getGroupInfo()) .filter!(g => g.sinkType == "nntp") .map!(g => loadIni!NntpConfig(resolveSiteFile("config/sources/nntp/" ~ g.sinkName ~ ".ini")).deleteCommand) .filter!identity .array.sort.uniq ; auto args = chain(messageID.only, post.xref.map!(xref => "%s:%d".format(xref.group, xref.num))).array; foreach (deleteCommand; deleteCommands) { auto result = execute([deleteCommand] ~ args); feedback("Deletion from source %s with %s".format( result.status == 0 ? "was successful" : "failed with status %d".format(result.status), result.output.empty ? "no output." : "the following output:", )); foreach (line; result.output.asciiStrip.splitAsciiLines) feedback("> " ~ line); } } if (deleteLocally) { query!"DELETE FROM `Posts` WHERE `ID` = ?".exec(post.id); query!"DELETE FROM `Threads` WHERE `ID` = ?".exec(post.id); dbVersion++; feedback("Post deleted."); } } string approvePost(string draftID, string who) { auto draft = getDraft(draftID); draft.serverVars["preapproved"] = null; auto headers = Headers(draft.serverVars.get("headers", "null").jsonParse!(string[][string])); PostProcess.allowReposting(draft); auto pid = postDraft(draft, headers); saveDraft(draft, Yes.force); needBanLog(); banLog("User %s is approving draft %s (post %s) titled %(%s%) by %(%s%)".format( who, draftID, pid, [draft.clientVars.get("subject", "")], [draft.clientVars.get("name", "")], )); learnModeratedMessage(draft, false, 10); return pid; } // Create logger on demand, to avoid creating empty log files Logger banLog; void needBanLog() { if (!banLog) banLog = fileLogger("Banned"); } void banPoster(string who, string id, string reason) { needBanLog(); banLog("User %s is banning poster of post %s (%s)".format(who, id, reason)); auto fn = findPostingLog(id); enforce(fn && fn.exists, "Can't find posting log"); auto pp = new PostProcess(fn); string[] keys; keys ~= pp.ip; keys ~= pp.draft.clientVars.get("secret", null); foreach (cookie; pp.headers.get("Cookie", null).split("; ")) { auto p = cookie.indexOf("="); if (p<0) continue; auto name = cookie[0..p]; auto value = cookie[p+1..$]; if (name == "dfeed_secret" || name == "dfeed_session") keys ~= value; } foreach (key; keys) if (key.length) { if (key in banned) banLog("Key already known: " ~ key); else { banned[key] = reason; banLog("Adding key: " ~ key); } } saveBanList(); banLog("Done."); } struct BanCheckResult { string key; // The matched ban key (empty if not banned) string reason; // The ban reason (empty if not banned) bool opCast(T : bool)() const { return key.length > 0; } } /// If the user is banned, returns the matched key and ban reason. /// Otherwise, returns an empty BanCheckResult. BanCheckResult banCheck(string ip, HttpRequest request) { string[] keys = [ip]; foreach (cookie; request.headers.get("Cookie", null).split("; ")) { auto p = cookie.indexOf("="); if (p<0) continue; auto name = cookie[0..p]; auto value = cookie[p+1..$]; if (name == "dfeed_secret" || name == "dfeed_session") if (value.length) keys ~= value; } string secret = userSettings.secret; if (secret.length) keys ~= secret; string bannedKey = null, reason = null; foreach (key; keys) if (key in banned) { bannedKey = key; reason = banned[key]; break; } if (!bannedKey) return BanCheckResult.init; needBanLog(); banLog("Request from banned user: " ~ request.resource); foreach (name, value; request.headers) banLog("* %s: %s".format(name, value)); banLog("Matched on: %s (%s)".format(bannedKey, reason)); bool propagated; foreach (key; keys) if (key !in banned) { banLog("Propagating: %s -> %s".format(bannedKey, key)); banned[key] = "%s (propagated from %s)".format(reason, bannedKey); propagated = true; } if (propagated) saveBanList(); return BanCheckResult(bannedKey, reason); } struct UnbanTree { struct Node { string key; string reason; string unbanReason; Node*[] children; } Node*[] roots; Node*[string] allNodes; } UnbanTree getUnbanPreviewByKey(string key) { if (key !in banned) return UnbanTree.init; // Build parent-child relationships from ban list string[][string] parents; string[][string] children; foreach (k, reason; banned) { auto p = parseParents(reason); parents[k] = p; foreach (parent; p) children[parent] ~= k; } // Build the tree structure starting from the given key UnbanTree result; string[] queue; bool[string] visited; void addNode(string k, string unbanReason, UnbanTree.Node* parent = null) { if (k in visited || k !in banned) return; visited[k] = true; auto node = new UnbanTree.Node(k, banned[k], unbanReason); result.allNodes[k] = node; if (parent) parent.children ~= node; else result.roots ~= node; queue ~= k; } // Start with the specified key as root addNode(key, "specified key"); // Process queue to add children and parents while (queue.length) { auto k = queue[0]; queue = queue[1..$]; auto node = result.allNodes[k]; // Add parent keys as children in the tree foreach (p; parents.get(k, null)) addNode(p, "parent of " ~ k, node); // Add child keys foreach (c; children.get(k, null)) addNode(c, "child of " ~ k, node); } return result; } void unbanPoster(string who, string id, string[] keysToUnban) { needBanLog(); banLog("User %s is unbanning poster of post %s".format(who, id)); size_t count = 0; foreach (key; keysToUnban) { if (key in banned) { banLog(format("Unbanning %s", key)); banned.remove(key); count++; } } banLog(format("Unbanned a total of %d keys.", count)); saveBanList(); banLog("Done."); } ================================================ FILE: src/dfeed/web/web/page.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Current page. module dfeed.web.web.page; import ae.utils.textout : StringBuffer; import dfeed.loc; StringBuffer html; // *********************************************************************** class Redirect : Throwable { string url; this(string url) { this.url = url; super("Uncaught redirect"); } } class NotFoundException : Exception { this(string str = null) { super(str ? str : _!"The specified resource cannot be found on this server."); } } ================================================ FILE: src/dfeed/web/web/part/gravatar.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Gravatar rendering module dfeed.web.web.part.gravatar; import std.conv : text; import std.format : format; import std.string; import std.uni : toLower; import ae.utils.xmllite : putEncodedEntities; import dfeed.web.web.page : html; string gravatar(string authorEmail, int size) { return `https://www.gravatar.com/avatar/%s?d=identicon&s=%d`.format(getGravatarHash(authorEmail), size); } enum gravatarMetaSize = 256; string getGravatarHash(string email) { import std.digest.md; import std.ascii : LetterCase; return email.toLower().strip().md5Of().toHexString!(LetterCase.lower)().idup; // Issue 9279 } void putGravatar(string gravatarHash, string personName, string linkTarget, string linkDescription, string aProps = null, int size = 0) { html.put( `` ~ `Gravatar of `), html.putEncodedEntities(personName),
			html.put(`` ); } else html.put( `src="//www.gravatar.com/avatar/`, gravatarHash, `?d=identicon" ` ~ `srcset="//www.gravatar.com/avatar/`, gravatarHash, `?d=identicon&s=160 2x"` ~ `>` ); html.put(``); } ================================================ FILE: src/dfeed/web/web/part/pager.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Paging. module dfeed.web.web.part.pager; import std.algorithm.comparison; import std.algorithm.searching; import std.array; import std.conv; import std.exception; import ae.utils.text.html : encodeHtmlEntities; import dfeed.loc; import dfeed.groups : GroupInfo; import dfeed.web.web.cache; import dfeed.web.web.page : html; /// pageCount==int.max indicates unknown number of pages void pager(string base, int page, int pageCount, int maxWidth = 50) { if (!pageCount) return html.put(`-`); string linkOrNot(string text, int page, bool cond) { if (cond) return `` ~ text ~ ``; else return `` ~ text ~ ``; } // Try to make the pager as wide as it will fit in the alotted space int widthAt(int radius) { import std.math : log10; int pagerStart = max(1, page - radius); int pagerEnd = min(pageCount, page + radius); if (pageCount==int.max) pagerEnd = page + 1; int width = pagerEnd - pagerStart; foreach (n; pagerStart..pagerEnd+1) width += 1 + cast(int)log10(n); if (pagerStart > 1) width += 3; if (pagerEnd < pageCount) width += 3; return width; } int radius = 0; for (; radius < 10 && widthAt(radius+1) < maxWidth; radius++) {} int pagerStart = max(1, page - radius); int pagerEnd = min(pageCount, page + radius); if (pageCount==int.max) pagerEnd = page + 1; string[] pager; if (pagerStart > 1) pager ~= "…"; foreach (pagerPage; pagerStart..pagerEnd+1) if (pagerPage == page) pager ~= `` ~ text(pagerPage) ~ ``; else pager ~= linkOrNot(text(pagerPage), pagerPage, true); if (pagerEnd < pageCount) pager ~= "…"; html.put( `
` ~ `
`, linkOrNot("« " ~ _!"First", 1, page!=1), `   `, linkOrNot("‹ " ~ _!"Prev", page-1, page>1), `
` ~ `
`, pager.join(` `), `
` ~ `
`, linkOrNot(_!"Next" ~ " ›", page+1, page
` ~ ``); } enum THREADS_PER_PAGE = 15; enum POSTS_PER_PAGE = 10; static int indexToPage(int index, int perPage) { return index / perPage + 1; } // Return value is 1-based, index is 0-based static int getPageCount(int count, int perPage) { return count ? indexToPage(count-1, perPage) : 0; } static int getPageOffset(int page, int perPage) { return (page-1) * perPage; } void threadPager(GroupInfo groupInfo, int page, int maxWidth = 40) { auto threadCounts = threadCountCache(getThreadCounts()); auto threadCount = threadCounts.get(groupInfo.internalName, 0); auto pageCount = getPageCount(threadCount, THREADS_PER_PAGE); pager(`/group/` ~ groupInfo.urlName, page, pageCount, maxWidth); } ================================================ FILE: src/dfeed/web/web/part/post.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Formatting posts. module dfeed.web.web.part.post; import std.algorithm.iteration : map; import std.array : array, join, replicate; import std.conv : text; import std.format; import ae.net.ietf.message : Rfc850Message; import ae.utils.text.html : encodeHtmlEntities; import ae.utils.xmllite : putEncodedEntities; import dfeed.loc; import dfeed.message : idToUrl, Rfc850Post, idToFragment; import dfeed.web.user : User; import dfeed.web.web.page : html; import dfeed.web.web.part.gravatar : getGravatarHash, putGravatar; import dfeed.web.web.part.postbody : formatBody; import dfeed.web.web.part.profile : profileUrl; import dfeed.web.web.part.strings : formatShortTime, summarizeTime; import dfeed.web.web.postinfo : PostInfo, getPostInfo, idToThreadUrl; import dfeed.web.web.statics : staticPath; import dfeed.web.web.user : user, userSettings; // *********************************************************************** string postLink(int rowid, string id, string author) { return `` ~ encodeHtmlEntities(author) ~ ``; } string postLink(PostInfo* info) { return postLink(info.rowid, info.id, info.author); } // *********************************************************************** struct PostAction { string className, text, title, url, icon; } PostAction[] getPostActions(Rfc850Message msg) { PostAction[] actions; auto id = msg.id; if (userSettings.groupViewMode == "basic") actions ~= PostAction("permalink", _!"Permalink", _!"Canonical link to this post. See \"Canonical links\" on the Help page for more information.", idToUrl(id), "link"); if (true) actions ~= PostAction("replylink", _!"Reply", _!"Reply to this post", idToUrl(id, "reply"), "reply"); /* if (mailHide) actions ~= PostAction("emaillink", _!"Email", _!"Solve a CAPTCHA to obtain this poster's email address.", mailHide.getUrl(msg.authorEmail), "email"); */ if (user.isLoggedIn() && msg.references.length == 0) actions ~= PostAction("subscribelink", _!"Subscribe", _!"Subscribe to this thread", idToUrl(id, "subscribe"), "star"); if (user.getLevel() >= User.Level.canFlag && user.createdAt() < msg.time) actions ~= PostAction("flaglink", _!"Flag", _!"Flag this post for moderator intervention", idToUrl(id, "flag"), "flag"); if (user.getLevel() >= User.Level.hasRawLink) actions ~= PostAction("sourcelink", _!"Source", _!"View this message's source code", idToUrl(id, "source"), "source"); if (user.getLevel() >= User.Level.canModerate) actions ~= PostAction("modlink", _!"Moderate", _!"Perform moderation actions on this post", idToUrl(id, "moderate"), "delete"); return actions; } void postActions(PostAction[] actions) { foreach (action; actions) html.put( `` ~ ``), html.putEncodedEntities(action.text), html.put( ``); } // *********************************************************************** string getParentLink(Rfc850Post post, Rfc850Post[string] knownPosts) { if (post.parentID) { string author, link; if (post.parentID in knownPosts) { auto parent = knownPosts[post.parentID]; author = parent.author; link = '#' ~ idToFragment(parent.id); } else { auto parent = getPostInfo(post.parentID); if (parent) { author = parent.author; link = idToUrl(parent.id); } } if (author && link) return `` ~ encodeHtmlEntities(author) ~ ``; } return null; } void miniPostInfo(Rfc850Post post, Rfc850Post[string] knownPosts, bool showActions = true) { string horizontalInfo; string gravatarHash = getGravatarHash(post.authorEmail); auto parentLink = getParentLink(post, knownPosts); with (post.msg) { html.put( `` ~ `` ~ `` ); if (showActions) html.put( `` ); html.put( `` ); } } // *********************************************************************** string[] formatPostParts(Rfc850Post post) { string[] partList; void visitParts(Rfc850Message[] parts, int[] path) { foreach (i, part; parts) { if (part.parts.length) visitParts(part.parts, path~cast(int)i); else if (part.content !is post.content) { string partUrl = ([idToUrl(post.id, "raw")] ~ array(map!text(path~cast(int)i))).join("/"); with (part) partList ~= (name || fileName) ? `` ~ encodeHtmlEntities(name) ~ (name && fileName ? " - " : "") ~ encodeHtmlEntities(fileName) ~ `` ~ (description ? ` (` ~ encodeHtmlEntities(description) ~ `)` : "") : `` ~ encodeHtmlEntities(mimeType) ~ ` ` ~ _!`part` ~ (description ? ` (` ~ encodeHtmlEntities(description) ~ `)` : ""); } } } visitParts(post.parts, null); return partList; } void formatPost(Rfc850Post post, Rfc850Post[string] knownPosts, bool markAsRead = true) { string gravatarHash = getGravatarHash(post.authorEmail); string[] infoBits; auto parentLink = getParentLink(post, knownPosts); if (parentLink) infoBits ~= _!`Posted in reply to` ~ ` ` ~ parentLink; auto partList = formatPostParts(post); if (partList.length) infoBits ~= _!`Attachments:` ~ ``; if (knownPosts is null && post.cachedThreadID) infoBits ~= `` ~ _!`View in thread` ~ ``; string repliesTitle = encodeHtmlEntities(_!`Replies to %s's post from %s`.format(post.author, formatShortTime(post.time, false))); with (post.msg) { html.put( `
` ~ `` ~ ``, ``.replicate(2), `` ~ // Fixed layout dummies `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `
` ~ `
`, summarizeTime(time), `
` ~ `` ~ `
`, // ); miniPostInfo(post, knownPosts); html.put( ), formatBody(post), html.put( (error ? `` ~ encodeHtmlEntities(error) ~ `` : ``), `
` ~ `
`); if (post.children) { html.put( `` ~ `` ~ `` ~ `
` ~ `` ~ ``); foreach (child; post.children) formatPost(child, knownPosts); html.put(`
`); } } if (post.rowid && markAsRead) user.setRead(post.rowid, true); } ================================================ FILE: src/dfeed/web/web/part/postbody.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Formatting post bodies. module dfeed.web.web.part.postbody; import std.algorithm.comparison : max; import std.algorithm.iteration : splitter, map, reduce; import std.array : join; import std.range : iota, radial; import std.regex : matchAll; import ae.net.ietf.message : Rfc850Message; import ae.net.ietf.wrap : unwrapText; import ae.utils.meta : I; import ae.utils.regex : re; import ae.utils.text : contains, segmentByWhitespace; import ae.utils.text.html : encodeHtmlEntities; import ae.utils.xmllite : putEncodedEntities; import dfeed.loc; import dfeed.message : isMarkdown; import dfeed.web.markdown : haveMarkdown, renderMarkdownCached; import dfeed.web.web.page : html; import dfeed.web.web.user : userSettings; enum reURL = `\w+://[^<>\s]+[\w/\-+=]`; void formatBody(Rfc850Message post) { auto paragraphs = unwrapText(post.content, post.wrapFormat); if (post.isMarkdown() && userSettings.renderMarkdown == "true" && haveMarkdown()) { auto content = paragraphs.map!((ref p) => p.quotePrefix ~ p.text ~ "\n").join(); auto result = renderMarkdownCached(content); if (result.error) { html.put( `
`, _!`Failed to render Markdown:`), html.putEncodedEntities(result.error), html.put( `
`); // continue on to plain text } else { html.put( `
`, result.html, `
` ); return; } } html.put(`
`);
	bool inSignature = false;
	int quoteLevel = 0;
	foreach (paragraph; paragraphs)
	{
		int paragraphQuoteLevel;
		foreach (c; paragraph.quotePrefix)
			if (c == '>')
				paragraphQuoteLevel++;

		for (; quoteLevel > paragraphQuoteLevel; quoteLevel--)
			html ~= ``;
		for (; quoteLevel < paragraphQuoteLevel; quoteLevel++)
			html ~= ``;

		if (!quoteLevel && (paragraph.text == "-- " || paragraph.text == "_______________________________________________"))
		{
			html ~= ``;
			inSignature = true;
		}

		enum forceWrapThreshold = 30;
		enum forceWrapMinChunkSize =  5;
		enum forceWrapMaxChunkSize = 15;
		static assert(forceWrapMaxChunkSize > forceWrapMinChunkSize * 2);

		import std.utf : byChar;
		bool needsWrap = paragraph.text.byChar.splitter(' ').map!(s => s.length).I!(r => reduce!max(size_t.init, r)) > forceWrapThreshold;

		auto hasURL = paragraph.text.contains("://");
		auto hasHashTags = paragraph.text.contains('#');

		void processText(string s)
		{
			html.put(encodeHtmlEntities(s));
		}

		void processWrap(string s)
		{
			alias next = processText;

			if (!needsWrap)
				return next(s);

			auto segments = s.segmentByWhitespace();
			foreach (ref segment; segments)
			{
				if (segment.length > forceWrapThreshold)
				{
					void chunkify(string s, string delimiters)
					{
						if (s.length < forceWrapMaxChunkSize)
						{
							html.put(``);
							next(s);
							html.put(``);
						}
						else
						if (!delimiters.length)
						{
							// Don't cut UTF-8 sequences in half
							static bool canCutAt(char c) { return (c & 0x80) == 0 || (c & 0x40) != 0; }
							foreach (i; s.length.iota.radial)
								if (canCutAt(s[i]))
								{
									chunkify(s[0..i], null);
									chunkify(s[i..$], null);
									return;
								}
							chunkify(s[0..$/2], null);
							chunkify(s[$/2..$], null);
						}
						else
						{
							foreach (i; iota(forceWrapMinChunkSize, s.length-forceWrapMinChunkSize).radial)
								if (s[i] == delimiters[0])
								{
									chunkify(s[0..i+1], delimiters);
									chunkify(s[i+1..$], delimiters);
									return;
								}
							chunkify(s, delimiters[1..$]);
						}
					}

					chunkify(segment, "/&=.-+,;:_\\|`'\"~!@#$%^*()[]{}");
				}
				else
					next(segment);
			}
		}

		void processURLs(string s)
		{
			alias next = processWrap;

			if (!hasURL)
				return next(s);

			size_t pos = 0;
			foreach (m; matchAll(s, re!reURL))
			{
				next(s[pos..m.pre().length]);
				html.put(``);
				next(m.hit());
				html.put(``);
				pos = m.pre().length + m.hit().length;
			}
			next(s[pos..$]);
		}

		void processHashTags(string s)
		{
			alias next = processURLs;

			if (!hasHashTags)
				return next(s);

			size_t pos = 0;
			enum reHashTag = `(^| )(#([a-zA-Z][a-zA-Z0-9_-]+))`;
			foreach (m; matchAll(s, re!reHashTag))
			{
				next(s[pos .. m.pre().length + m[1].length]);
				html.put(``);
				next(m[2]);
				html.put(``);
				pos = m.pre().length + m.hit().length;
			}
			next(s[pos..$]);
		}

		alias first = processHashTags;

		if (paragraph.quotePrefix.length)
			html.put(``), html.putEncodedEntities(paragraph.quotePrefix), html.put(``);
		first(paragraph.text);
		html.put('\n');
	}
	for (; quoteLevel; quoteLevel--)
		html ~= ``;
	if (inSignature)
		html ~= ``;
	html.put(`
`); } // https://github.com/CyberShadow/DFeed/issues/121 unittest { import std.string : strip; auto msg = new Rfc850Message(q"EOF Subject: test http://a/b+ EOF"); scope(exit) html.clear(); formatBody(msg); assert(html.get.strip == `
http://a/b+

`, html.get.strip); } ================================================ FILE: src/dfeed/web/web/part/profile.d ================================================ /* Copyright (C) 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// User profile utilities. module dfeed.web.web.part.profile; import std.ascii : LetterCase; import std.digest.sha; import ae.utils.text.html : encodeHtmlEntities; import dfeed.web.web.page : html; /// Generate a URL-safe hash for a (name, email) identity tuple. /// Uses first 32 hex chars of SHA256 of name + null byte + email. string getProfileHash(string name, string email) { auto hash = sha256Of(name ~ "\0" ~ email); return hash.toHexString!(LetterCase.lower)()[0..32].idup; } /// Generate the URL path for a user profile. string profileUrl(string name, string email) { return "/user/" ~ getProfileHash(name, email); } /// Output a link to the user's profile page. void putAuthorLink(string author, string authorEmail) { html.put(``); html.put(encodeHtmlEntities(author)); html.put(``); } ================================================ FILE: src/dfeed/web/web/part/strings.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Various string formatting. module dfeed.web.web.part.strings; import core.time; import std.algorithm.comparison : min, max; import std.conv : text; import std.datetime.systime : SysTime, Clock; import std.datetime.timezone : UTC; import std.format : format; import ae.utils.text.html : encodeHtmlEntities; import dfeed.loc; string summarizeTime(SysTime time, bool colorize = false) { if (!time.stdTime) return "-"; string style; if (colorize) { import std.math; auto diff = Clock.currTime() - time; auto diffLog = log2(diff.total!"seconds"); enum LOG_MIN = 10; // 1 hour-ish enum LOG_MAX = 18; // 3 days-ish enum COLOR_MAX = 0xA0; auto f = (diffLog - LOG_MIN) / (LOG_MAX - LOG_MIN); f = min(1, max(0, f)); auto c = cast(int)(f * COLOR_MAX); style ~= format("color: #%02X%02X%02X;", c, c, c); } bool shorter = colorize; // hack return `` ~ encodeHtmlEntities(formatShortTime(time, shorter)) ~ ``; } string formatTinyTime(SysTime time) { if (!time.stdTime) return "-"; SysTime now = Clock.currTime(UTC()); Duration duration = now - time; if (duration < 1.seconds) return "0s"; else if (duration < 1.minutes) return text(duration.total!"seconds", _!"s"); else if (duration < 1.hours) return text(duration.total!"minutes", _!"m"); else if (duration < 1.days) return text(duration.total!"hours", _!"h"); else if (duration < 7.days) return text(duration.total!"days", _!"d"); else if (duration < 300.days) return time.formatTimeLoc!"M j"(); else return time.formatTimeLoc!"M 'y"(); } string formatShortTime(SysTime time, bool shorter) { if (!time.stdTime) return "-"; auto now = Clock.currTime(UTC()); auto duration = now - time; if (duration < 7.days) return formatDuration(duration); else if (duration < 300.days) if (shorter) return time.formatTimeLoc!"M d"(); else return time.formatTimeLoc!"F d"(); else if (shorter) return time.formatTimeLoc!"M d, Y"(); else return time.formatTimeLoc!"F d, Y"(); } string formatDuration(Duration duration) { string ago(string unit)(long amount) { assert(amount > 0); return _!"%d %s ago".format(amount, plural!unit(amount)); } if (duration < 0.seconds) return _!"from the future"; else if (duration < 1.seconds) return _!"just now"; else if (duration < 1.minutes) return ago!"second"(duration.total!"seconds"); else if (duration < 1.hours) return ago!"minute"(duration.total!"minutes"); else if (duration < 1.days) return ago!"hour"(duration.total!"hours"); else /*if (duration < dur!"days"(2)) return "yesterday"; else if (duration < dur!"days"(6)) return formatTimeLoc!"l"(time); else*/ if (duration < 7.days) return ago!"day"(duration.total!"days"); else if (duration < 31.days) return ago!"week"(duration.total!"weeks"); else if (duration < 365.days) return ago!"month"(duration.total!"days" / 30); else return ago!"year"(duration.total!"days" / 365); } string formatLongTime(SysTime time) { if (!time.stdTime) return "-"; SysTime now = Clock.currTime(UTC()); Duration duration = now - time; if (duration < 7.days) return formatDuration(duration); else return formatAbsoluteTime(time); } string formatAbsoluteTime(SysTime time) { return time.formatTimeLoc!"l, d F Y, H:i:s e"(); } /// Add thousand-separators string formatNumber(long n) { string s = text(n); int digits = 0; auto separator = digitGroupingSeparators[currentLanguage]; foreach_reverse(p; 1..s.length) if (++digits % 3 == 0) s = s[0..p] ~ separator ~ s[p..$]; return s; } static string truncateString(string s8, int maxLength = 30) { auto encoded = encodeHtmlEntities(s8); return `` ~ encoded ~ ``; } /+ /// Generate a link to set a user preference string setOptionLink(string name, string value) { return "/set?" ~ encodeUrlParameters(UrlParameters([name : value, "url" : "__URL__", "secret" : userSettings.secret])); } +/ ================================================ FILE: src/dfeed/web/web/part/thread.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Formatting threads. module dfeed.web.web.part.thread; import std.algorithm.comparison : min; import std.algorithm.sorting : sort; import std.datetime.systime : SysTime; import std.datetime.timezone : UTC; import std.format : format; import ae.utils.xmllite : putEncodedEntities; import dfeed.loc; import dfeed.database : query; import dfeed.message : idToUrl; import dfeed.web.web.page : html; import dfeed.web.web.perf; import dfeed.web.web.postinfo : PostInfo, getPost; import dfeed.web.web.user : user, userSettings; import dfeed.web.web.part.profile : profileUrl; import dfeed.web.web.part.strings : summarizeTime, truncateString; string[][string] referenceCache; // invariant void formatThreadedPosts(PostInfo*[] postInfos, bool narrow, string selectedID = null) { enum OFFSET_INIT = 1f; enum OFFSET_MAX = 2f; enum OFFSET_WIDTH = 25f; enum OFFSET_UNITS = "%"; class Post { PostInfo* info; Post parent; SysTime maxTime; Post[] children; int maxDepth; bool ghost; // dummy parent for orphans string ghostSubject; @property string subject() { return ghostSubject ? ghostSubject : info.subject; } this(PostInfo* info = null) { this.info = info; } void calcStats() { foreach (child; children) child.calcStats(); if (info) maxTime = info.time; foreach (child; children) if (maxTime < child.maxTime) maxTime = child.maxTime; //maxTime = reduce!max(time, map!"a.maxTime"(children)); maxDepth = 1; foreach (child; children) if (maxDepth < 1 + child.maxDepth) maxDepth = 1 + child.maxDepth; } } Post[string] posts; foreach (info; postInfos) posts[info.id] = new Post(info); // Check if linking child under parent would create a cycle // by walking up parent's ancestor chain bool wouldCreateCycle(Post child, Post parent) { for (Post p = parent; p !is null; p = p.parent) if (p is child) return true; return false; } posts[null] = new Post(); foreach (post; posts.values) if (post.info) { auto parent = post.info.parentID; // Parent missing or would create cycle - find alternate parent if (parent !in posts || wouldCreateCycle(post, posts[parent])) { string[] references; if (post.info.id in referenceCache) references = referenceCache[post.info.id]; else references = referenceCache[post.info.id] = getPost(post.info.id).references; // Search References header for any ancestor in this thread parent = null; foreach_reverse (reference; references) if (reference in posts && !wouldCreateCycle(post, posts[reference])) { parent = reference; break; } // No valid parent found - create ghost post for missing parent if (!parent && references.length) { auto dummy = new Post; dummy.ghost = true; dummy.ghostSubject = post.info.subject; // HACK parent = references[0]; posts[parent] = dummy; dummy.parent = posts[null]; posts[null].children ~= dummy; } } // Link post to its parent (or root if none was found) post.parent = posts[parent]; posts[parent].children ~= post; } bool reversed = userSettings.groupViewMode == "threaded"; posts[null].calcStats(); foreach (post; posts) { if (post.info || post.ghost) sort!"a.info.time < b.info.time"(post.children); else // sort threads by last-update if (reversed) sort!"a.maxTime > b.maxTime"(post.children); else sort!"a.maxTime < b.maxTime"(post.children); } float offsetIncrement; // = max(1f, min(OFFSET_MAX, OFFSET_WIDTH / posts[null].maxDepth)); string normalizeSubject(string s) { import std.array : replace; import std.algorithm.searching : skipOver; s.skipOver("Re: "); return s .replace("New: ", "") // Bugzilla hack .replace("\t", " ") // Apple Mail hack .replace(" ", "") // Outlook Express hack ; } // Group replies under a ghost post when multiple replies have the same subject, // but different from their parent (Bugzilla hack) foreach (thread; posts[null].children) { for (int i=1; i` ~ `` ~ `
` ~ `
`, summarizeTime(post.info.time, true), `
`, ``, truncateString(post.info.author, narrow ? 17 : 50), `` ~ `
` ~ `` ~ ``); formatPosts(post.children, level+1, post.subject, false); } foreach (post; posts) { if (topLevel) offsetIncrement = min(OFFSET_MAX, OFFSET_WIDTH / post.maxDepth); if (topLevel || normalizeSubject(post.subject) != normalizeSubject(parentSubject)) { auto offsetStr = format("%1.1f", OFFSET_INIT + level * offsetIncrement) ~ OFFSET_UNITS; html.put( `` ~ `` ~ ``); formatPost(post, 0); html.put( `
`), html.putEncodedEntities(post.subject), html.put(`
` ~ ``); } else formatPost(post, level); } } formatPosts(posts[null].children, 0, null, true); } // *********************************************************************** PostInfo*[] getThreadPosts(string threadID) { PostInfo*[] posts; enum ViewSQL = "SELECT `ROWID`, `ID`, `ParentID`, `Author`, `AuthorEmail`, `Subject`, `Time` FROM `Posts` WHERE `ThreadID` = ?"; foreach (int rowid, string id, string parent, string author, string authorEmail, string subject, long stdTime; query!ViewSQL.iterate(threadID)) posts ~= [PostInfo(rowid, id, null, parent, author, authorEmail, subject, SysTime(stdTime, UTC()))].ptr; return posts; } void discussionThreadOverview(string threadID, string selectedID) { enum PERF_SCOPE = "discussionThreadOverview"; mixin(MeasurePerformanceMixin); html.put( `` ~ ``, `
` ~ _!`Thread overview` ~ `
`); formatThreadedPosts(getThreadPosts(threadID), false, selectedID); html.put(`
`); } ================================================ FILE: src/dfeed/web/web/perf.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Performance logging. module dfeed.web.web.perf; import ae.sys.log; enum measurePerformance = true; static if (is(typeof({import std.datetime.stopwatch;}))) { import std.datetime.stopwatch; alias StopWatch = std.datetime.stopwatch.StopWatch; Duration readStopwatch(ref StopWatch sw) { return sw.peek(); } } else Duration readStopwatch(ref StopWatch sw) { return sw.peek().msecs.msecs; } static if (measurePerformance) Logger perfLog; enum MeasurePerformanceMixin = q{ static if (measurePerformance) { StopWatch performanceSW; performanceSW.start(); scope(success) { performanceSW.stop(); import std.conv : text; perfLog(PERF_SCOPE ~ ": " ~ text(performanceSW.readStopwatch)); } } }; ================================================ FILE: src/dfeed/web/web/postinfo.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Post data and lookup. module dfeed.web.web.postinfo; import ae.net.ietf.message : Rfc850Message; import std.algorithm.searching : startsWith, endsWith; import std.datetime.systime : SysTime; import std.datetime.timezone : UTC; import std.exception : enforce; import dfeed.loc; import dfeed.database : query, selectValue; import dfeed.message : Rfc850Post, idToUrl, idToFragment; import dfeed.sinks.cache : CachedSet; import dfeed.web.web.page : NotFoundException; import dfeed.web.web.part.pager : indexToPage, POSTS_PER_PAGE; import dfeed.web.web.view.thread : getPostThreadIndex; string resolvePostUrl(string id) { foreach (string threadID; query!"SELECT `ThreadID` FROM `Posts` WHERE `ID` = ?".iterate(id)) return idToThreadUrl(id, threadID); throw new NotFoundException(_!"Post not found"); } string idToThreadUrl(string id, string threadID) { return idToUrl(threadID, "thread", indexToPage(getPostThreadIndex(id), POSTS_PER_PAGE)) ~ "#" ~ idToFragment(id); } static Rfc850Post getPost(string id) { foreach (int rowid, string message, string threadID; query!"SELECT `ROWID`, `Message`, `ThreadID` FROM `Posts` WHERE `ID` = ?".iterate(id)) return new Rfc850Post(message, id, rowid, threadID); return null; } static Rfc850Message getPostPart(string id, uint[] partPath = null) { foreach (string message; query!"SELECT `Message` FROM `Posts` WHERE `ID` = ?".iterate(id)) { auto post = new Rfc850Message(message); while (partPath.length) { enforce(partPath[0] < post.parts.length, _!"Invalid attachment"); post = post.parts[partPath[0]]; partPath = partPath[1..$]; } return post; } return null; } static string getPostSource(string id) { foreach (string message; query!"SELECT `Message` FROM `Posts` WHERE `ID` = ?".iterate(id)) return message; return null; } struct PostInfo { int rowid; string id, threadID, parentID, author, authorEmail, subject; SysTime time; } CachedSet!(string, PostInfo*) postInfoCache; PostInfo* getPostInfo(string id) { return postInfoCache(id, retrievePostInfo(id)); } PostInfo* retrievePostInfo(string id) { if (id.startsWith('<') && id.endsWith('>')) foreach (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)) { if (authorEmail is null) { authorEmail = new Rfc850Message(query!"SELECT [Message] FROM [Posts] WHERE [ROWID]=?".iterate(rowid).selectValue!string).authorEmail; if (authorEmail is null) authorEmail = ""; assert(authorEmail !is null); query!"UPDATE [Posts] SET [AuthorEmail]=? WHERE [ROWID]=?".exec(authorEmail, rowid); } return [PostInfo(rowid, id, threadID, parentID, author, authorEmail, subject, SysTime(stdTime, UTC()))].ptr; } return null; } ================================================ FILE: src/dfeed/web/web/posting.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021, 2022, 2024, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Authoring new posts. module dfeed.web.web.posting; import core.time : seconds, minutes, weeks; import std.algorithm.iteration : map; import std.algorithm.searching : canFind, findSplit; import std.conv : to; import std.datetime.systime : SysTime, Clock; import std.datetime.timezone : UTC; import std.exception : enforce; import std.format : format; import std.string : strip, splitLines; import std.typecons : Yes; import ae.net.ietf.headers : Headers; import ae.net.ietf.url : UrlParameters, encodeUrlParameter; import ae.utils.aa : aaGet; import ae.utils.json : toJson, jsonParse; import ae.utils.meta : I, isDebug; import ae.utils.sini : loadIni; import ae.utils.text : splitAsciiLines; import ae.utils.text.html : encodeHtmlEntities; import ae.utils.xmllite; import dfeed.loc; import dfeed.paths : resolveSiteFile; import dfeed.database : query; import dfeed.groups : getGroupInfo; import dfeed.mail : sendMail; import dfeed.message : idToUrl; import dfeed.sinks.subscriptions : createReplySubscription; import dfeed.site : site; import dfeed.web.captcha; import dfeed.web.lint : getLintRule, lintRules; import dfeed.web.markdown : haveMarkdown; import dfeed.web.posting : PostDraft, PostProcess, PostError, SmtpConfig, PostingStatus; import dfeed.web.web.draft : getDraft, saveDraft, draftToPost; import dfeed.web.web.page : html; import dfeed.web.web.part.post : formatPost, postLink; import dfeed.web.web.part.strings : formatShortTime, formatDuration; import dfeed.web.web.postinfo : getPostInfo, getPost; import dfeed.web.web.postmod : shouldModerate, learnModeratedMessage, ModerationReason; import dfeed.web.web.request : ip; import dfeed.web.web.user : user, userSettings; void draftNotices(string except = null) { foreach (string id, long time; query!"SELECT [ID], [Time] FROM [Drafts] WHERE [UserID]==? AND [Status]==?".iterate(userSettings.id, PostDraft.Status.edited)) { if (id == except) continue; auto t = SysTime(time, UTC()); html.put(`
`, _!`You have an %sunsent draft message from %s%s.`.format( ``, formatShortTime(t, false), `` ), `
`); } } bool discussionPostForm(PostDraft draft, Captcha captcha=null, PostError error=PostError.init) { auto draftID = draft.clientVars.get("did", null); draftNotices(draftID); if (draft.status == PostDraft.Status.moderation) throw new Exception(_!"This message is awaiting moderation."); // Only happens if visiting a posting page when it's not in // postProcesses, i.e., from a previous DFeed process instance. if (draft.status == PostDraft.Status.sent) throw new Exception(_!"This message has already been posted."); // Immediately resurrect discarded posts when user clicks "Undo" or "Back" if (draft.status == PostDraft.Status.discarded) query!"UPDATE [Drafts] SET [Status]=? WHERE [ID]=?".exec(PostDraft.Status.edited, draftID); auto where = draft.serverVars.get("where", null); auto info = getGroupInfo(where); if (!info) throw new Exception(_!"Unknown group:" ~ " " ~ where); if (info.postMessage) { html.put( `` ~ `` ~ `` ~ `
`, _!`Can't post to archive`, `
` , info.postMessage, `
`); return false; } if (info.notice) html.put(`
`, info.notice, `
`); if (info.sinkType == "smtp" && info.subscriptionRequired) { auto config = loadIni!SmtpConfig(resolveSiteFile("config/sources/smtp/" ~ info.sinkName ~ ".ini")); html.put(`
`, _!`Note: you are posting to a mailing list.`, `
`, _!`Your message will not go through unless you %ssubscribe to the mailing list%s first.`.format( ``, ``, ), `
`, _!`You must then use the same email address when posting here as the one you used to subscribe to the list.`, `
`, _!`If you do not want to receive mailing list mail, you can disable mail delivery at the above link.`, `
`); } auto parent = draft.serverVars.get("parent", null); auto parentInfo = parent ? getPostInfo(parent) : null; if (parentInfo && Clock.currTime - parentInfo.time > 2.weeks) html.put(`
`, _!`Warning: the post you are replying to is from`, ` `, formatDuration(Clock.currTime - parentInfo.time), ` (`, formatShortTime(parentInfo.time, false), `).
`); html.put(`
`); if (error.message) html.put(`
`), html.putEncodedEntities(error.message), html.put(error.extraHTML, `
`); html.put(draft.clientVars.get("html-top", null)); if (parent) html.put(``); else html.put(``); auto subject = draft.clientVars.get("subject", null); html.put( `
`, _!`Posting to`, ` `), html.putEncodedEntities(info.publicName), html.put(``, (parent ? parentInfo ? ` ` ~ _!`in reply to` ~ ` ` ~ postLink(parentInfo) : ` ` ~ _!`in reply to` ~ ` (` ~ _!`unknown post` ~ `)` : info ? `:
(` ~ encodeHtmlEntities(info.description) ~ `)` : ``), `
` ~ `` ~ `` ~ ``, `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ ``); if (captcha) html.put(`
`, captcha.getChallengeHtml(error.captchaError), `
`); html.put( `
` ~ `
` ~ `` ~ ``); if (haveMarkdown && userSettings.renderMarkdown == "true") html.put( ``); html.put(`
` ~ `
` ~ `` ~ `
` ~ `
` ~ `
` ~ `
`); return true; } /// Calculate a secret string from a key. /// Can be used in URLs in emails to authenticate an action on a /// public/guessable identifier. version(none) string authHash(string s) { import dfeed.web.user : userConfig = config; auto digest = sha256Of(s ~ userConfig.salt); return Base64.encode(digest)[0..10]; } SysTime[][string] lastPostAttempts; string discussionSend(UrlParameters clientVars, Headers headers) { import std.algorithm.iteration : filter; import std.algorithm.searching : startsWith; import std.range : chain, only; auto draftID = clientVars.get("did", null); auto draft = getDraft(draftID); try { if (draft.status == PostDraft.Status.sent) { // Redirect if we know where to if ("pid" in draft.serverVars) return idToUrl(PostProcess.pidToMessageID(draft.serverVars["pid"])); else throw new Exception(_!"This message has already been sent."); } if (clientVars.get("secret", "") != userSettings.secret) throw new Exception(_!"XSRF secret verification failed. Are your cookies enabled?"); if (draft.status == PostDraft.Status.moderation) throw new Exception(_!"This message is awaiting moderation."); draft.clientVars = clientVars; draft.status = PostDraft.Status.edited; scope(exit) saveDraft(draft); auto action = clientVars.byKey.filter!(key => key.startsWith("action-")).chain("action-none".only).front[7..$]; static struct UndoInfo { UrlParameters clientVars; string[string] serverVars; } bool lintDetails; if (action.startsWith("lint-ignore-")) { draft.serverVars[action] = null; action = "send"; } else if (action.startsWith("lint-fix-")) { auto ruleID = action[9..$]; try { draft.serverVars["lint-undo"] = UndoInfo(draft.clientVars, draft.serverVars).toJson; getLintRule(ruleID).fix(draft); draft.clientVars["html-top"] = `
` ~ _!`Automatic fix applied.` ~ ` ` ~ `
`; } catch (Exception e) { draft.serverVars["lint-ignore-" ~ ruleID] = null; html.put(`
` ~ _!`Sorry, a problem occurred while attempting to fix your post` ~ ` ` ~ `(`), html.putEncodedEntities(e.msg), html.put(`).
`); } discussionPostForm(draft); return null; } else if (action == "lint-undo") { enforce("lint-undo" in draft.serverVars, _!"Undo information not found."); auto undoInfo = draft.serverVars["lint-undo"].jsonParse!UndoInfo; draft.clientVars = undoInfo.clientVars; draft.serverVars = undoInfo.serverVars; html.put(`
` ~ _!`Automatic fix undone.` ~ `
`); discussionPostForm(draft); return null; } else if (action == "lint-explain") { lintDetails = true; action = "send"; } switch (action) { case "save": { discussionPostForm(draft); // Show preview auto post = draftToPost(draft, headers, ip); post.compile(); formatPost(post, null); return null; } case "send": { userSettings.name = aaGet(clientVars, "name"); userSettings.email = aaGet(clientVars, "email"); foreach (rule; lintRules) if ("lint-ignore-" ~ rule.id !in draft.serverVars && rule.check(draft)) { PostError error; error.message = _!"Warning:" ~ " " ~ rule.shortDescription(); error.extraHTML ~= ` `; if (!lintDetails) error.extraHTML ~= ` `; if (rule.canFix(draft)) error.extraHTML ~= ` `; if (lintDetails) error.extraHTML ~= `
` ~ rule.longDescription() ~ `
`; discussionPostForm(draft, null, error); return null; } auto now = Clock.currTime(); // Get per-group rate limit settings auto groupInfo = getGroupInfo(draft.serverVars.get("where", draft.clientVars.get("where", null))); auto postThrottleRejectTime = groupInfo ? groupInfo.postThrottleRejectTime.seconds : 30.seconds; auto postThrottleRejectCount = groupInfo ? groupInfo.postThrottleRejectCount : 3; auto postThrottleCaptchaTime = groupInfo ? groupInfo.postThrottleCaptchaTime.seconds : 180.seconds; auto postThrottleCaptchaCount = groupInfo ? groupInfo.postThrottleCaptchaCount : 3; auto ipPostAttempts = lastPostAttempts.get(ip, null); if (postThrottleRejectCount > 0 && ipPostAttempts.length >= postThrottleRejectCount && now - ipPostAttempts[$-postThrottleRejectCount+1] < postThrottleRejectTime) { discussionPostForm(draft, null, PostError(_!"You've attempted to post %d times in the past %s. Please wait a little bit before trying again." .format(postThrottleRejectCount, postThrottleRejectTime))); return null; } if (postThrottleCaptchaCount > 0 && ipPostAttempts.length >= postThrottleCaptchaCount && now - ipPostAttempts[$-postThrottleCaptchaCount+1] < postThrottleCaptchaTime) { auto captcha = getCaptcha(draftToPost(draft).captcha); if (captcha) { bool captchaPresent = captcha.isPresent(clientVars); if (!captchaPresent) { discussionPostForm(draft, captcha, PostError(_!"You've attempted to post %d times in the past %s. Please solve a CAPTCHA to continue." .format(postThrottleCaptchaCount, postThrottleCaptchaTime))); return null; } } else { discussionPostForm(draft, null, PostError(_!"You've attempted to post %d times in the past %s. Please wait a little bit before trying again." .format(postThrottleCaptchaCount, postThrottleCaptchaTime))); return null; } } auto pid = postDraft(draft, headers); lastPostAttempts[ip] ~= Clock.currTime(); if (user.isLoggedIn()) createReplySubscription(user.getName()); return "/posting/" ~ pid; } case "discard": { // Show undo notice userSettings.pendingNotice = "draft-deleted:" ~ draftID; // Mark as deleted draft.status = PostDraft.Status.discarded; // Redirect to relevant page if ("parent" in draft.serverVars) return idToUrl(draft.serverVars["parent"]); else if ("where" in draft.serverVars) return "/group/" ~ draft.serverVars["where"]; else return "/"; } default: throw new Exception(_!"Unknown action"); } } catch (Exception e) { auto error = isDebug ? e.toString() : e.msg; discussionPostForm(draft, null, PostError(error)); return null; } } string postDraft(ref PostDraft draft, Headers headers) { auto parent = "parent" in draft.serverVars ? getPost(draft.serverVars["parent"]) : null; auto process = new PostProcess(draft, user, userSettings.id, ip, headers, parent); if (process.status == PostingStatus.redirect) return process.pid; process.run(); draft.serverVars["pid"] = process.pid; return process.pid; } void moderateMessage(ref PostDraft draft, Headers headers, ModerationReason reason) { import std.range : chain, only; learnModeratedMessage(draft, true, 1); draft.serverVars["headers"] = headers.to!(string[][string]).toJson; draft.status = PostDraft.Status.moderation; saveDraft(draft, Yes.force); string sanitize(string s) { return "%(%s%)".format(s.only)[1..$-1]; } try { import std.file : readText; auto badStrings = "config/known-spammers.txt".readText().splitLines; foreach (badString; badStrings) if (badString.length && draft.clientVars.get("text", null).canFind(badString)) { import ae.sys.log : fileLogger; auto moderationLog = fileLogger("Deleted"); scope(exit) moderationLog.close(); moderationLog("Silently ignoring known spammer: " ~ draft.clientVars.get("did", "").I!sanitize); return; } } catch (Exception e) {} string context; { context = `The message was submitted`; string contextURL = null; auto urlPrefix = site.proto ~ "://" ~ site.host; if (auto parentID = "parent" in draft.serverVars) { context ~= ` in reply to `; auto parent = getPostInfo(*parentID); if (parent) context ~= parent.author.I!sanitize ~ "'s post"; else context ~= "a post"; contextURL = urlPrefix ~ idToUrl(*parentID); } if ("where" in draft.serverVars) { context ~= ` on the ` ~ draft.serverVars["where"] ~ ` group`; if (!contextURL) contextURL = urlPrefix ~ `/group/` ~ draft.serverVars["where"]; } else context ~= ` on an unknown group`; context ~= contextURL ? ":\n" ~ contextURL : "."; } // Check if moderation is due to ban string unbanSection = reason.kind == ModerationReason.Kind.bannedUser && reason.bannedKey.length ? ` If this user was previously banned and you would like to unban them, you can do so here: %s://%s/unban/%s ` .format( site.proto, site.host, encodeUrlParameter(reason.bannedKey), ) : ""; foreach (mod; site.moderators) sendMail((q"EOF From: %1$s To: %3$s Subject: Please moderate: post by %5$s with subject "%7$s" Content-Type: text/plain; charset=utf-8 Howdy %4$s, User %5$s <%6$s> attempted to post a message with the subject "%7$s". This post was held for moderation for the following reason: %8$s Here is the message: ---------------------------------------------- %9$-(%s %) ---------------------------------------------- %13$s IP address this message was posted from: %12$s You can preview and approve this message here: %10$s://%2$s/approve-moderated-draft/%11$s %14$s Otherwise, no action is necessary. All the best, %1$s ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You are receiving this message because you are configured as a site moderator on %2$s. To stop receiving messages like this, please ask the administrator of %1$s to remove you from the list of moderators. . EOF") .format( /* 1*/ site.name.length ? site.name : site.host, /* 2*/ site.host, /* 3*/ mod, /* 4*/ mod.canFind("<") ? mod.findSplit("<")[0].findSplit(" ")[0] : mod.findSplit("@")[0], /* 5*/ draft.clientVars.get("name", "").I!sanitize, /* 6*/ draft.clientVars.get("email", "").I!sanitize, /* 7*/ draft.clientVars.get("subject", "").I!sanitize, /* 8*/ reason.toString(), /* 9*/ draft.clientVars.get("text", "").strip.splitAsciiLines.map!(line => line.length ? "> " ~ line : ">"), /*10*/ site.proto, /*11*/ draft.clientVars.get("did", "").I!sanitize, /*12*/ ip, /*13*/ context, /*14*/ unbanSection, )); } void discussionPostStatusMessage(string messageHtml) { html.put( `` ~ `` ~ `` ~ `
`, _!`Posting status`, `
`, messageHtml, `
`); } void discussionPostStatus(PostProcess process, out bool refresh, out string redirectTo, out bool form) { refresh = form = false; PostError error = process.error; switch (process.status) { case PostingStatus.spamCheck: // discussionPostStatusMessage(_!"Checking for spam..."); discussionPostStatusMessage(_!"Validating..."); refresh = true; return; case PostingStatus.captcha: discussionPostStatusMessage(_!"Verifying reCAPTCHA..."); refresh = true; return; case PostingStatus.connecting: discussionPostStatusMessage(_!"Connecting to server..."); refresh = true; return; case PostingStatus.posting: discussionPostStatusMessage(_!"Sending message to server..."); refresh = true; return; case PostingStatus.waiting: discussionPostStatusMessage(_!"Message sent." ~ "
" ~ _!"Waiting for message announcement..."); refresh = true; return; case PostingStatus.posted: redirectTo = idToUrl(process.post.id); discussionPostStatusMessage(_!`Message posted! Redirecting...`); refresh = true; return; case PostingStatus.moderated: discussionPostStatusMessage(_!`Your message has been saved, and will be posted after being approved by a moderator.`); return; case PostingStatus.captchaFailed: discussionPostForm(process.draft, getCaptcha(process.post.captcha), error); form = true; return; case PostingStatus.spamCheckFailed: // CAPTCHA is available (checked in onSpamResult) - show form with challenge auto captcha = getCaptcha(process.post.captcha); error.message = format(_!"%s. Please solve a CAPTCHA to continue.", error.message); discussionPostForm(process.draft, captcha, error); form = true; return; case PostingStatus.serverError: discussionPostForm(process.draft, null, error); form = true; return; default: discussionPostStatusMessage("???"); refresh = true; return; } } ================================================ FILE: src/dfeed/web/web/postmod.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Automatic moderation of new posts. module dfeed.web.web.postmod; import std.array : array; import std.format : format; import dfeed.bayes : BayesModel, splitWords, splitWords, train, checkMessage; import dfeed.web.posting : PostDraft; import dfeed.web.spam : bayes, getSpamicity, certainlySpamThreshold; import dfeed.web.web.moderation : banCheck; import dfeed.web.web.request : ip, currentRequest; struct ModerationReason { enum Kind { none, spam, bannedUser, similarToModerated, } Kind kind; string details; // Additional information (ban reason, percentage, etc.) string bannedKey; // The specific banned key that matched (only for bannedUser) /// Returns a human-readable description string toString() const { final switch (kind) { case Kind.none: return null; case Kind.spam: return "Very high Bayes spamicity (" ~ details ~ ")"; case Kind.bannedUser: return "Post from banned user (ban reason: " ~ details ~ ")"; case Kind.similarToModerated: return "Very similar to recently moderated messages (" ~ details ~ ")"; } } } /// Bayes model trained to detect recently moderated messages. RAM only. /// The model is based off the spam model, but we throw away all spam data at first. BayesModel* getModerationModel() { static BayesModel* model; if (!model) { model = new BayesModel; *model = bayes.model; model.words = model.words.dup; foreach (word, ref counts; model.words) counts.spamCount = 0; model.spamPosts = 0; } return model; } void learnModeratedMessage(in ref PostDraft draft, bool isBad, int weight) { auto message = bayes.messageFromDraft(draft); auto model = getModerationModel(); auto words = message.splitWords.array; train(*model, words, isBad, weight); } double checkModeratedMessage(in ref PostDraft draft) { auto message = bayes.messageFromDraft(draft); auto model = getModerationModel(); return checkMessage(*model, message); } /// Should this post be queued for moderation instead of being posted immediately? /// If yes, return a reason; if no, return ModerationReason with kind == none. ModerationReason shouldModerate(in ref PostDraft draft) { auto spamicity = getSpamicity(draft); if (spamicity >= certainlySpamThreshold) return ModerationReason(ModerationReason.Kind.spam, format("%s%%", spamicity * 100), null); auto banResult = banCheck(ip, currentRequest); if (banResult) return ModerationReason(ModerationReason.Kind.bannedUser, banResult.reason, banResult.key); auto modScore = checkModeratedMessage(draft); if (modScore >= 0.95) return ModerationReason(ModerationReason.Kind.similarToModerated, format("%s%%", modScore * 100), null); return ModerationReason(ModerationReason.Kind.none, null, null); } ================================================ FILE: src/dfeed/web/web/request.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021, 2023, 2024, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Handling/routing of HTTP requests and global HTML structure. module dfeed.web.web.request; import std.algorithm.iteration : map, filter, map; import std.algorithm.searching : startsWith, canFind, skipOver, endsWith, findSplit; import std.array : split, join, array, replace; import std.conv : to, text; import std.exception : enforce; import std.file : readText; import std.format : format; import std.functional : not; import std.string : indexOf; import std.uni : icmp, toLower; import dfeed.common; import dfeed.paths : resolveSiteFile, resolveStaticFileBase; import dfeed.database : query; import dfeed.groups : GroupInfo, groupHierarchy, getGroupInfoByUrl, getGroupInfo; import dfeed.loc; import dfeed.message : idToUrl, urlDecode, urlEncodeMessageUrl, getGroup; import dfeed.sinks.messagedb : threadID, searchTerm; import dfeed.sinks.subscriptions; import dfeed.site : site; import dfeed.sources.github; import dfeed.web.list; import dfeed.web.posting : postProcesses; import dfeed.web.user : User, getUser; import dfeed.web.web.config : config; import dfeed.web.web.draft : getDraft, draftToPost, newPostDraft, newReplyDraft, autoSaveDraft; import dfeed.web.web.page : html, NotFoundException, Redirect; import dfeed.web.web.part.gravatar; import dfeed.web.web.part.pager : getPageOffset, POSTS_PER_PAGE; import dfeed.web.web.postinfo; import dfeed.web.web.posting : discussionPostForm, discussionSend, discussionPostStatus; import dfeed.web.web.site : putSiteNotice; import dfeed.web.web.statics : optimizedPath, serveFile, makeBundle, staticPath, createBundles; import dfeed.web.web.user; import dfeed.web.web.view.feed : getFeed, getSubscriptionFeed, FEED_HOURS_DEFAULT, FEED_HOURS_MAX; import dfeed.web.web.view.group : discussionGroup, discussionGroupNarrowIndex, discussionGroupThreaded, discussionGroupSplit, discussionGroupVSplit, discussionGroupSplitFromPost, discussionGroupVSplitFromPost; import dfeed.web.web.view.index : discussionIndex; import dfeed.web.web.view.login : discussionLoginForm, discussionRegisterForm, discussionLogin, discussionRegister; import dfeed.web.web.view.moderation : discussionModeration, discussionModerationDeleted, deletePostApi, discussionFlagPage, discussionApprovePage, discussionUnbanByKeyPage; import dfeed.web.web.view.post : discussionSplitPost, discussionVSplitPost, discussionSinglePost; import dfeed.web.web.view.search : discussionSearch; import dfeed.web.web.view.settings; import dfeed.web.web.view.subscription : discussionSubscriptionPosts, discussionSubscriptionUnsubscribe; import dfeed.web.web.view.thread : getPostAtThreadIndex, discussionThread, discussionFirstUnread; import dfeed.web.web.view.userprofile : discussionUserProfile, lookupAuthorByHash; import dfeed.web.web.view.widgets; import ae.net.http.common : HttpRequest, HttpResponse, HttpStatusCode; import ae.net.http.responseex : HttpResponseEx; import ae.net.http.server : HttpServerConnection; import ae.net.ietf.url : UrlParameters, decodeUrlParameters, encodeUrlParameter; import ae.sys.data : Data; import ae.utils.array; import ae.utils.digest; import ae.utils.exception; import ae.utils.json : toJson; import ae.utils.meta : I; import ae.utils.regex : re, escapeRE; import ae.utils.text.html : encodeHtmlEntities; HttpRequest currentRequest; string ip; void onRequest(HttpRequest request, HttpServerConnection conn) { conn.sendResponse(handleRequest(request, conn)); } HttpResponse handleRequest(HttpRequest request, HttpServerConnection conn) { currentRequest = request; auto response = new HttpResponseEx(); ip = conn.remoteAddressStr(request); user = getUser(request.headers.get("Cookie", null)); string[] cookies; scope(success) { if (!cookies) cookies = user.save(); foreach (cookie; cookies) response.headers.add("Set-Cookie", cookie ~ "; SameSite=Lax"); } string title; string[] breadcrumbs; string bodyClass = "narrowdoc"; string returnPage = request.resource; html.clear(); string[] tools, extraHeaders; string[string] jsVars; auto status = HttpStatusCode.OK; GroupInfo currentGroup; string currentThread; // for search Language userLanguage; try userLanguage = userSettings.language.to!Language; catch (Exception e) { userLanguage = detectLanguage(request.headers.get("Accept-Language", null)); userSettings.language = userLanguage.to!string; } auto oldLanguage = withLanguage(userLanguage); // Redirect to canonical domain name auto host = request.headers.get("Host", ""); host = request.headers.get("X-Forwarded-Host", host); if (host != site.host && host != "localhost" && site.host != "localhost" && ip != "127.0.0.1" && !request.resource.startsWith("/.well-known/acme-challenge/")) return response.redirect(site.proto ~ "://" ~ site.host ~ request.resource, HttpStatusCode.MovedPermanently); // Opt-in HTTPS redirect if (site.proto == "https" && request.headers.get("X-Scheme", "") == "http" && request.headers.get("Upgrade-Insecure-Requests", "0") == "1") { response.redirect("https://" ~ site.host ~ request.resource); response.headers.add("Vary", "Upgrade-Insecure-Requests"); return response; } auto canonicalHeader = ``; enum horizontalSplitHeaders = ``; void addMetadata(string description, string canonicalLocation, string image) { assert(title, "No title for metadata"); if (!description) description = site.name; if (!image) image = site.ogImage; auto canonicalURL = site.proto ~ "://" ~ site.host ~ canonicalLocation; extraHeaders ~= [ ``, ``, ``, ``, ``, ]; // Maybe emit here as well one day // Needs changes to forum-template.dd } try { auto pathStr = request.resource; enforce(pathStr.startsWith('/'), _!"Invalid path"); UrlParameters parameters; if (pathStr.indexOf('?') >= 0) { auto p = pathStr.indexOf('?'); parameters = decodeUrlParameters(pathStr[p+1..$]); pathStr = pathStr[0..p]; } auto path = pathStr[1..$].split("/"); if (!path.length) path = [""]; auto pathX = path[1..$].join("%2F"); // work around Apache bug switch (path[0]) { // Obsolete "/discussion/" prefix case "discussion": return response.redirect(request.resource["/discussion".length..$], HttpStatusCode.MovedPermanently); case "": // Handle redirects from pnews // Abort on redirect from URLs with unsupported features. // Only search engines would be likely to hit these. if ("path" in parameters || "mid" in parameters) throw new NotFoundException(_!"Legacy redirect - unsupported feature"); // Redirect to our URL scheme string redirectGroup, redirectNum; if ("group" in parameters) redirectGroup = parameters["group"]; if ("art_group" in parameters) redirectGroup = parameters["art_group"]; if ("artnum" in parameters) redirectNum = parameters["artnum"]; if ("article_id" in parameters) redirectNum = parameters["article_id"]; if (redirectGroup && redirectNum) { foreach (string id; query!"SELECT `ID` FROM `Groups` WHERE `Group`=? AND `ArtNum`=?".iterate(redirectGroup, redirectNum)) return response.redirect(idToUrl(id), HttpStatusCode.MovedPermanently); throw new NotFoundException(_!"Legacy redirect - article not found"); } else if (redirectNum) { string[] ids; foreach (string id; query!"SELECT `ID` FROM `Groups` WHERE `ArtNum`=?".iterate(redirectNum)) ids ~= id; if (ids.length == 1) return response.redirect(idToUrl(ids[0]), HttpStatusCode.MovedPermanently); else if (ids.length > 1) throw new NotFoundException(_!"Legacy redirect - ambiguous artnum (group parameter missing)"); else throw new NotFoundException(_!"Legacy redirect - article not found"); } else if (redirectGroup) return response.redirect("/group/" ~ redirectGroup, HttpStatusCode.MovedPermanently); if (request.resource != "/") return response.redirect("/"); title = "Index"; //breadcrumbs ~= `Forum Index`; extraHeaders ~= ``; extraHeaders ~= ``; addMetadata(null, "/", null); putSiteNotice(); discussionIndex(); break; case "group": { enforce(path.length > 1, _!"No group specified"); string groupUrlName = path[1]; foreach (groupInfo; groupHierarchy.map!(set => set.groups).join) if (groupInfo.urlAliases.canFind(groupUrlName)) throw new Redirect("/group/" ~ groupInfo.urlName); foreach (groupInfo; groupHierarchy.map!(set => set.groups).join) if (groupInfo.urlAliases.canFind!(not!icmp)(groupUrlName)) throw new Redirect("/group/" ~ groupInfo.urlName); int page = to!int(parameters.get("page", "1")); string pageStr = page==1 ? "" : " " ~ _!"(page %d)".format(page); auto groupInfo = currentGroup = getGroupInfoByUrl(groupUrlName); enforce(groupInfo, _!"Unknown group"); title = _!"%s group index".format(groupInfo.publicName) ~ pageStr; breadcrumbs ~= `` ~ encodeHtmlEntities(groupInfo.publicName) ~ `` ~ pageStr; putSiteNotice(); if (groupInfo.notice) html.put(`
`, groupInfo.notice, `
`); auto viewMode = userSettings.groupViewMode; if (viewMode == "basic") discussionGroup(groupInfo, page); else if (viewMode == "narrow-index") discussionGroupNarrowIndex(groupInfo, page); else if (viewMode == "threaded") discussionGroupThreaded(groupInfo, page); else if (viewMode == "horizontal-split") { discussionGroupSplit(groupInfo, page); extraHeaders ~= horizontalSplitHeaders; } else discussionGroupVSplit(groupInfo, page); extraHeaders ~= ``; extraHeaders ~= ``; addMetadata(groupInfo.description, "/group/" ~ groupInfo.urlName, null); break; } case "thread": { enforce(path.length > 1, _!"No thread specified"); int page = to!int(parameters.get("page", "1")); string threadID = '<' ~ urlDecode(pathX) ~ '>'; auto firstPostUrl = idToUrl(getPostAtThreadIndex(threadID, getPageOffset(page, POSTS_PER_PAGE))); auto viewMode = userSettings.groupViewMode; if (viewMode != "basic" && viewMode != "narrow-index") html.put(`
` ~ _!"Viewing thread in basic view mode \– click a post's title to open it in %s view mode".format(viewModeName(viewMode)).encodeHtmlEntities() ~ `
`); returnPage = firstPostUrl; string pageStr = page==1 ? "" : " " ~ format(_!"(page %d)", page); GroupInfo groupInfo; string subject, authorEmail; discussionThread(threadID, page, groupInfo, subject, authorEmail, viewMode == "basic" || viewMode == "narrow-index"); enforce(groupInfo, _!"Unknown group"); title = subject ~ pageStr; currentGroup = groupInfo; currentThread = threadID; breadcrumbs ~= `` ~ encodeHtmlEntities(groupInfo.publicName) ~ ``; breadcrumbs ~= `` ~ encodeHtmlEntities(subject) ~ `` ~ pageStr; extraHeaders ~= canonicalHeader; // Google confuses /post/ URLs with threads addMetadata(null, idToUrl(threadID, "thread"), gravatar(authorEmail, gravatarMetaSize)); break; } case "post": enforce(path.length > 1, _!"No post specified"); string id = '<' ~ urlDecode(pathX) ~ '>'; // Normalize URL encoding to allow JS to find message by URL if (urlEncodeMessageUrl(urlDecode(pathX)) != pathX) return response.redirect(idToUrl(id)); auto viewMode = userSettings.groupViewMode; if (viewMode == "basic" || viewMode == "narrow-index") return response.redirect(resolvePostUrl(id)); else if (viewMode == "threaded") { string subject, authorEmail; discussionSinglePost(id, currentGroup, subject, authorEmail, currentThread); title = subject; breadcrumbs ~= `` ~ encodeHtmlEntities(currentGroup.publicName) ~ ``; breadcrumbs ~= `` ~ encodeHtmlEntities(subject) ~ ` ` ~ _!`(view single post)`; addMetadata(null, idToUrl(id), gravatar(authorEmail, gravatarMetaSize)); break; } else { int page; if (viewMode == "horizontal-split") discussionGroupSplitFromPost(id, currentGroup, page, currentThread); else discussionGroupVSplitFromPost(id, currentGroup, page, currentThread); string pageStr = page==1 ? "" : " " ~ format(_!"(page %d)", page); title = _!"%s group index".format(currentGroup.publicName) ~ pageStr; breadcrumbs ~= `` ~ encodeHtmlEntities(currentGroup.publicName) ~ `` ~ pageStr; extraHeaders ~= horizontalSplitHeaders; addMetadata(null, idToUrl(id), null); break; } case "raw": { enforce(path.length > 1, _!"Invalid URL"); auto post = getPostPart('<' ~ urlDecode(path[1]) ~ '>', array(map!(to!uint)(path[2..$]))); enforce(post, _!"Post not found"); if (!post.data && post.error) throw new Exception(post.error); if (post.fileName) //response.headers["Content-Disposition"] = `inline; filename="` ~ post.fileName ~ `"`; response.headers["Content-Disposition"] = `attachment; filename="` ~ post.fileName ~ `"`; // " else // TODO: separate subdomain for attachments response.headers["Content-Disposition"] = `attachment; filename="raw"`; return response.serveData(Data(post.data), post.mimeType ? post.mimeType : "application/octet-stream"); } case "source": { enforce(path.length > 1, _!"Invalid URL"); auto message = getPostSource('<' ~ urlDecode(path[1]) ~ '>'); if (message is null) { auto slug = urlDecode(path[1]); if (slug.skipOver("draft-") && slug.endsWith("@" ~ site.host)) { auto did = slug.skipUntil("@"); auto draft = getDraft(did); auto post = draftToPost(draft); post.compile(); message = post.message; } } enforce(message !is null, _!"Post not found"); return response.serveData(Data(message), "text/plain"); } case "split-post": enforce(path.length > 1, _!"No post specified"); discussionSplitPost('<' ~ urlDecode(pathX) ~ '>'); return response.serveData(cast(string)html.get()); case "vsplit-post": enforce(path.length > 1, _!"No post specified"); discussionVSplitPost('<' ~ urlDecode(pathX) ~ '>'); return response.serveData(cast(string)html.get()); /+ case "set": { if (parameters.get("secret", "") != userSettings.secret) throw new Exception("XSRF secret verification failed. Are your cookies enabled?"); foreach (name, value; parameters) if (name != "url" && name != "secret") user.set(name, value); // TODO: is this a good idea? if ("url" in parameters) return response.redirect(parameters["url"]); else return response.serveText("OK"); } +/ case "mark-unread": { enforce(path.length > 1, _!"No post specified"); auto post = getPostInfo('<' ~ urlDecode(pathX) ~ '>'); enforce(post, _!"Post not found"); user.setRead(post.rowid, false); return response.serveText("OK"); } case "first-unread": { enforce(path.length > 1, _!"No thread specified"); return response.redirect(discussionFirstUnread('<' ~ urlDecode(pathX) ~ '>')); } case "newpost": { enforce(path.length > 1, _!"No group specified"); string groupUrlName = path[1]; currentGroup = getGroupInfoByUrl(groupUrlName).enforce(_!"No such group"); title = _!"Posting to %s".format(currentGroup.publicName); breadcrumbs ~= `` ~ encodeHtmlEntities(currentGroup.publicName) ~ ``; breadcrumbs ~= `` ~ _!`New thread` ~ ``; if (discussionPostForm(newPostDraft(currentGroup, parameters))) bodyClass ~= " formdoc"; break; } case "reply": { enforce(path.length > 1, _!"No post specified"); auto post = getPost('<' ~ urlDecode(pathX) ~ '>'); enforce(post, _!"Post not found"); title = _!`Replying to "%s"`.format(post.subject); // " currentGroup = post.getGroup(); currentThread = post.threadID; breadcrumbs ~= `` ~ encodeHtmlEntities(currentGroup.publicName) ~ ``; breadcrumbs ~= `` ~ encodeHtmlEntities(post.subject) ~ ``; breadcrumbs ~= `` ~ _!`Post reply` ~ ``; if (discussionPostForm(newReplyDraft(post))) bodyClass = "formdoc"; break; } case "send": { enforce(request.method == "POST", _!"No post form submitted. Please click \"Back\" in your web browser to navigate back to the posting form, and resubmit it."); auto postVars = request.decodePostData(); auto redirectTo = discussionSend(postVars, request.headers); if (redirectTo) return response.redirect(redirectTo); breadcrumbs ~= title = _!`Posting`; bodyClass ~= " formdoc"; break; } case "posting": { enforce(path.length > 1, _!"No post ID specified"); auto pid = pathX; if (pid in postProcesses) { bool refresh, form; string redirectTo; discussionPostStatus(postProcesses[pid], refresh, redirectTo, form); if (refresh) response.setRefresh(1, redirectTo); if (form) { breadcrumbs ~= title = _!`Posting`; bodyClass ~= " formdoc"; } else breadcrumbs ~= title = _!`Posting status`; } else { auto draftID = pid; foreach (string id; query!"SELECT [ID] FROM [Drafts] WHERE [PostID]=?".iterate(pid)) draftID = id; discussionPostForm(getDraft(draftID)); title = _!"Composing message"; } break; } case "auto-save": { auto postVars = request.decodePostData(); if (postVars.get("secret", "") != userSettings.secret) throw new Exception(_!"XSRF secret verification failed"); autoSaveDraft(postVars); return response.serveText("OK"); } case "subscribe": { enforce(path.length > 1, _!"No post specified"); enforce(user.isLoggedIn(), _!"Please log in to do that"); auto id = '<' ~ urlDecode(pathX) ~ '>'; Subscription threadSubscription; foreach (subscription; getUserSubscriptions(user.getName())) if (auto threadTrigger = cast(ThreadTrigger)subscription.trigger) if (threadTrigger.threadID == id) { threadSubscription = subscription; break; } if (!threadSubscription.trigger) threadSubscription = createSubscription(user.getName(), "thread", ["trigger-thread-id" : id]); title = _!"Subscribe to thread"; discussionSubscriptionEdit(threadSubscription); break; } case "subscription-posts": { enforce(path.length > 1, _!"No subscription specified"); int page = to!int(parameters.get("page", "1")); breadcrumbs ~= _!"View subscription"; discussionSubscriptionPosts(urlDecode(pathX), page, title); break; } case "subscription-feed": { enforce(path.length > 1, _!"No subscription specified"); return getSubscriptionFeed(urlDecode(pathX)).getResponse(request); } case "subscription-unsubscribe": { enforce(path.length > 1, _!"No subscription specified"); title = _!"Unsubscribe"; discussionSubscriptionUnsubscribe(urlDecode(pathX)); break; } case "search": { breadcrumbs ~= title = _!"Search"; discussionSearch(parameters); break; } case "user": { enforce(path.length > 1, _!"No user specified"); string profileHash = pathX; string author; discussionUserProfile(profileHash, title, author); breadcrumbs ~= _!"Users"; breadcrumbs ~= `` ~ encodeHtmlEntities(author) ~ ``; break; } case "subscribe-user": { enforce(path.length > 1, _!"No user specified"); enforce(user.isLoggedIn(), _!"Please log in to do that"); string profileHash = pathX; auto authorInfo = lookupAuthorByHash(profileHash); enforce(authorInfo[0] !is null, _!"User not found"); string authorName = authorInfo[0]; string authorEmail = authorInfo[1]; // Create a content subscription with author name and email filters prefilled // Use regex mode with escaped strings for exact matching auto subscription = createSubscription(user.getName(), "content", [ "trigger-content-author-name-enabled" : "on", "trigger-content-author-name-match-type" : "regex", "trigger-content-author-name-case-sensitive" : "on", "trigger-content-author-name-str" : "^" ~ authorName.escapeRE ~ "$", "trigger-content-author-email-enabled" : "on", "trigger-content-author-email-match-type" : "regex", "trigger-content-author-email-case-sensitive" : "on", "trigger-content-author-email-str" : "^" ~ authorEmail.escapeRE ~ "$", ]); title = _!"Subscribe to user"; discussionSubscriptionEdit(subscription); break; } case "delete": case "dodelete": return response.redirect("/moderate/" ~ path[1..$].join("/")); case "moderate": { enforce(user.getLevel() >= User.Level.canModerate, _!"You are not a moderator"); enforce(path.length > 1, _!"No post specified"); auto messageID = '<' ~ urlDecode(pathX) ~ '>'; auto post = getPost(messageID); if (post) { title = _!`Moderating post "%s"`.format(post.subject); // " breadcrumbs ~= `` ~ encodeHtmlEntities(post.subject) ~ ``; breadcrumbs ~= `` ~ _!`Moderate post` ~ ``; discussionModeration(post, request.method == "POST" ? request.decodePostData() : UrlParameters.init); } else { title = _!`Post not found`; breadcrumbs ~= `` ~ _!`Post not found` ~ ``; discussionModerationDeleted(messageID); } bodyClass ~= " formdoc"; break; } case "api-delete": { enforce(config.apiSecret.length, "No API secret configured"); enforce(parameters.get("secret", null) == config.apiSecret, "Incorrect secret"); enforce(path.length == 3, "Invalid URL"); auto group = path[1]; auto id = path[2].to!int; deletePostApi(group, id); return response.serveText(html.get().idup); } case "flag": case "unflag": { enforce(user.getLevel() >= User.Level.canFlag, _!"You can't flag posts"); enforce(path.length > 1, _!"No post specified"); auto post = getPost('<' ~ urlDecode(pathX) ~ '>'); enforce(post, _!"Post not found"); title = _!`Flag "%s" by %s`.format(post.subject, post.author); // " breadcrumbs ~= `` ~ encodeHtmlEntities(post.subject) ~ ``; breadcrumbs ~= `` ~ _!`Flag post` ~ ``; discussionFlagPage(post, path[0] == "flag", request.method == "POST" ? request.decodePostData() : UrlParameters.init); bodyClass ~= " formdoc"; break; } case "approve-moderated-draft": { enforce(user.getLevel() >= User.Level.canApproveDrafts, _!"You can't approve moderated drafts"); title = _!"Approving moderated draft"; enforce(path.length == 2 || path.length == 3, _!"Invalid URL"); // Backwards compatibility with old one-click URLs auto draftID = path[1]; discussionApprovePage(draftID, request.method == "POST" ? request.decodePostData() : UrlParameters.init); break; } case "unban": { enforce(user.getLevel() >= User.Level.canModerate, _!"You are not a moderator"); title = _!"Unban by key"; breadcrumbs ~= `` ~ _!`Unban by key` ~ ``; auto key = path.length > 1 ? urlDecode(pathX) : parameters.get("key", ""); discussionUnbanByKeyPage(key, request.method == "POST" ? request.decodePostData() : UrlParameters.init); bodyClass ~= " formdoc"; break; } case "loginform": { discussionLoginForm(parameters); breadcrumbs ~= title = _!`Log in`; tools ~= `` ~ _!`Register` ~ ``; returnPage = "/"; break; } case "registerform": { discussionRegisterForm(parameters); breadcrumbs ~= title = _!`Registration`; tools ~= `` ~ _!`Register` ~ ``; returnPage = "/"; break; } case "login": { try { parameters = request.decodePostData(); discussionLogin(parameters); return response.redirect(parameters.get("url", "/")); } catch (Exception e) { discussionLoginForm(parameters, e.msg); breadcrumbs ~= title = _!`Login error`; tools ~= `` ~ _!`Register` ~ ``; returnPage = "/"; break; } } case "register": { try { parameters = request.decodePostData(); discussionRegister(parameters); return response.redirect(parameters.get("url", "/")); } catch (Exception e) { discussionRegisterForm(parameters, e.msg); breadcrumbs ~= title = _!`Registration error`; tools ~= `` ~ _!`Register` ~ ``; returnPage = "/"; break; } } case "logout": { enforce(user.isLoggedIn(), _!"Not logged in"); user.logOut(); if ("url" in parameters) return response.redirect(parameters["url"]); else return response.serveText("OK"); } case "settings": breadcrumbs ~= title = _!"Settings"; discussionSettings(parameters, request.method == "POST" ? request.decodePostData() : UrlParameters.init); break; case "change-password": breadcrumbs ~= _!"Settings"; breadcrumbs ~= _!"Account"; breadcrumbs ~= title = _!"Change Password"; discussionChangePassword(request.method == "POST" ? request.decodePostData() : UrlParameters.init); break; case "export-account": breadcrumbs ~= _!"Settings"; breadcrumbs ~= _!"Account"; breadcrumbs ~= title = _!"Export Data"; if (auto result = discussionExportAccount(request.method == "POST" ? request.decodePostData() : UrlParameters.init)) { response.headers["Content-Disposition"] = "attachment; filename=%(%s%)" .format([user.getName ~ ".json"]); return response.serveJson(result); } break; case "delete-account": breadcrumbs ~= _!"Settings"; breadcrumbs ~= _!"Account"; breadcrumbs ~= title = _!"Delete Account"; discussionDeleteAccount(request.method == "POST" ? request.decodePostData() : UrlParameters.init); break; case "help": breadcrumbs ~= title = _!"Help"; html.put(readText(optimizedPath("web/help-%s.htt".format(currentLanguage))) .parseTemplate( (string name) { switch (name) { case "about" : return site.about; default: throw new Exception("Unknown variable in help template: " ~ name); } } )); break; // dlang.org front page iframes case "frame-discussions": bodyClass = "frame"; breadcrumbs ~= title = _!"Forum activity summary"; discussionFrameDiscussions(); break; case "frame-announcements": bodyClass = "frame"; breadcrumbs ~= title = _!"Forum activity summary"; discussionFrameAnnouncements(); break; case "feed": { enforce(path.length > 1, _!"Feed type not specified"); enforce(path[1]=="posts" || path[1]=="threads", _!"Unknown feed type"); bool threadsOnly = path[1] == "threads"; string groupUrlName; if (path.length > 2) groupUrlName = path[2]; auto hours = to!int(parameters.get("hours", text(FEED_HOURS_DEFAULT))); enforce(hours <= FEED_HOURS_MAX, _!"hours parameter exceeds limit"); auto groupInfo = getGroupInfoByUrl(groupUrlName); if (groupUrlName && !groupInfo) groupInfo = getGroupInfo(groupUrlName); if (groupUrlName && !groupInfo) throw new NotFoundException(_!"No such group"); return getFeed(groupInfo, threadsOnly, hours).getResponse(request); } case "github-webhook": foreach (service; services!GitHub) service.handleWebHook(request); return response.serveText("DFeed OK\n"); case "js": case "css": case "images": case "files": case "ircstats": case "favicon.ico": case ".well-known": return serveFile(response, pathStr[1..$]); case "robots.txt": return serveFile(response, config.indexable ? "robots_public.txt" : "robots_private.txt"); case "static": enforce(path.length > 2); return serveFile(response, path[2..$].join("/")); case "static-bundle": enforce(path.length > 2); return makeBundle(path[1], path[2..$].join("/")); default: return response.writeError(HttpStatusCode.NotFound); } } catch (Redirect r) { cookies = user.save(); return response.redirect(r.url); } catch (CaughtException e) { //return response.writeError(HttpStatusCode.InternalServerError, "Unprocessed exception: " ~ e.msg); if (cast(NotFoundException) e) { breadcrumbs ~= title = _!"Not Found"; status = HttpStatusCode.NotFound; } else { breadcrumbs ~= title = _!"Error"; status = HttpStatusCode.InternalServerError; } auto text = encodeHtmlEntities(e.msg).replace("\n", "
"); debug text ~= `
` ~ encodeHtmlEntities(e.toString()) ~ `
`; html.clear(); html.put( `` ~ `` ~ `` ~ `
`, encodeHtmlEntities(title), `
`, text, `
`); } assert(title, "No title"); assert(html.length, "No HTML"); if (breadcrumbs.length) breadcrumbs = [`` ~ _!`Index` ~ ``] ~ breadcrumbs; if (user.isLoggedIn()) tools ~= `` ~ _!`Log out` ~ ` ` ~ encodeHtmlEntities(user.getName()) ~ ``; else tools ~= `` ~ _!`Log in` ~ ``; tools ~= `` ~ _!`Settings` ~ ``; tools ~= `` ~ _!`Help` ~ ``; string toolStr = tools .map!(t => `
` ~ t ~ `
`) .join(" "); jsVars["toolsTemplate"] = toJson(toolStr); toolStr = toolStr.replace("__URL__", encodeUrlParameter(returnPage)); toolStr = `
` ~ toolStr ~ `
` ~ `
` ~ breadcrumbs.join(` » `) ~ `
` ; string htmlStr = cast(string) html.get(); // html contents will be overwritten on next request auto pendingNotice = userSettings.pendingNotice; if (pendingNotice) { userSettings.pendingNotice = null; auto parts = pendingNotice.findSplit(":"); auto kind = parts[0]; switch (kind) { case "draft-deleted": { auto draftID = parts[2]; htmlStr = `
` ~ _!`Draft discarded.` ~ ` ` ~ _!`Undo` ~ `
` ~ htmlStr; break; } case "settings-saved": htmlStr = `
` ~ _!`Settings saved.` ~ `
` ~ htmlStr; break; default: throw new Exception("Unknown kind of pending notice: " ~ kind); } } jsVars["enableKeyNav"] = userSettings.enableKeyNav; jsVars["autoOpen"] = userSettings.autoOpen; jsVars["localization"] = getJsStrings(); string[] extraJS; // Add jQuery fallback only if local copy exists import std.file : exists; static immutable jqueryPath = "/js/jquery-1.7.2.min.js"; if (resolveStaticFileBase(jqueryPath)) extraJS ~= `window.jQuery || document.write('\x3Cscript src="` ~ staticPath(jqueryPath) ~ `">\x3C/script>');`; if (jsVars.length) extraJS ~= "var %-(%s,%);".format(jsVars.byKeyValue.map!(pair => pair.key ~ "=" ~ pair.value)); cookies = user.save(); foreach (cookie; cookies) if (cookie.length > 4096 * 15/16) { htmlStr = `
` ~ _!`Warning: cookie size approaching RFC 2109 limit.` ~ _!`Please consider %screating an account%s to avoid losing your read post history.`.format( ``, `` ) ~ `
` ~ htmlStr; break; } string searchOptionStr; { struct SearchOption { string name, value; } SearchOption[] searchOptions; searchOptions ~= SearchOption(_!"Forums", "forum"); if (currentGroup) searchOptions ~= SearchOption(_!"%s group".format(currentGroup.publicName), "group:" ~ currentGroup.internalName.searchTerm); if (currentThread) searchOptions ~= SearchOption(_!"This thread", "threadmd5:" ~ currentThread.getDigestString!MD5().toLower()); foreach (i, option; searchOptions) searchOptionStr ~= ``; } // Disable HTTP caching, as we're serving dynamic content response.disableCache(); // Cache the template with resolved static resources and bundling static Cached!string pageTemplateCache; if (!pageTemplateCache.isValid) { Cached!string newCache; auto skelPath = optimizedPath("web/skel.htt"); auto page = readText(skelPath); newCache.addFile(skelPath); // Render navigation (now cacheable since it doesn't depend on currentGroup) page = renderNav(page); // First pass: resolve only static: placeholders, tracking files page = parseTemplate(page, (string name) { if (name.startsWith("static:")) { auto resourcePath = name[7..$]; auto resolvedBase = resolveStaticFileBase(resourcePath) .enforce("Static file not found: " ~ resourcePath); auto resolvedFile = resolvedBase ~ resourcePath; newCache.addFile(resolvedFile); return staticPath(resourcePath); } return null; // Don't touch other placeholders }); debug {} else { // Bundle the resolved static resources page = createBundles(page, re!``); page = createBundles(page, re!``); } newCache.value = page; pageTemplateCache = newCache; } auto page = pageTemplateCache.value; // Substitute remaining template variables with page-specific content string getVar(string name) { switch (name) { case "title" : return encodeHtmlEntities(title); case "content" : return htmlStr; case "extraheaders" : return extraHeaders.join(); case "extrajs" : return extraJS.join(); case "bodyclass" : return bodyClass; case "tools" : return toolStr; case "search-options" : return searchOptionStr; default: if (name.skipOver("active-group:")) { return (currentGroup && name == currentGroup.urlName) ? ` class="active"` : ``; } if (name.skipOver("static:")) return staticPath(name); throw new Exception("Unknown variable in template: " ~ name); } } page = parseTemplate(page, name => getVar(name).nonNull); response.serveData(page); response.setStatus(status); return response; } // Generic file-based cache import std.datetime.systime : SysTime; alias CacheKey = SysTime[string]; // fileName -> modificationTime struct Cached(T) { CacheKey key; T value; bool isValid() { import std.file : FileException; import dfeed.web.web.statics : timeLastModified; if (this is typeof(this).init) return false; foreach (fileName, mtime; key) try if (timeLastModified(fileName) != mtime) return false; catch (FileException) return false; // File no longer exists return true; } void addFile(string fileName) { import dfeed.web.web.statics : timeLastModified; key[fileName] = timeLastModified(fileName); } } string renderNav(string html) { auto nav = inferList(html, [[""], [""]]); auto nav2 = inferList(nav.itemSuffix, [["", "", ""], ["", "", ""]]); nav.itemSuffix = null; nav.varPrefix ~= null; return nav.render(groupHierarchy.filter!(set => set.visible).map!(set => [set.shortName, nav2.render(set.groups.map!(group => [ ``, "/group/" ~ group.urlName, group.navName ? group.navName : group.publicName, ]).array)] ).array); } static string parseTemplate(string data, scope string delegate(string) dictionary) { import ae.utils.textout; StringBuilder sb; sb.preallocate(data.length / 100 * 110); while (true) { auto startpos = data.indexOf(""); if (endpos * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Web server entry point. module dfeed.web.web.server; import std.functional : toDelegate; import dfeed.web.moderation : loadBanList; import dfeed.web.web.config; import dfeed.web.web.perf; import dfeed.web.web.request : onRequest; import ae.net.http.server : HttpServer; import ae.net.shutdown; import ae.sys.log : Logger, createLogger, asyncLogger; Logger log; HttpServer server; void startWebUI() { log = createLogger("Web").asyncLogger(); static if (measurePerformance) perfLog = createLogger("Performance"); loadBanList(); server = new HttpServer(); server.log = log; server.handleRequest = toDelegate(&onRequest); server.remoteIPHeader = config.remoteIPHeader; server.listen(config.listen.port, config.listen.addr); addShutdownHandler((scope const(char)[] reason){ server.close(); }); } ================================================ FILE: src/dfeed/web/web/site.d ================================================ /* Copyright (C) 2023 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Site web stuff. module dfeed.web.web.site; import dfeed.loc; import dfeed.web.web.page; private string getSiteNotice() { import std.file : readText; import dfeed.paths : resolveSiteFile; try return readText(resolveSiteFile("config/site-notice.html")); catch (Exception e) return null; } void putSiteNotice() { if (auto notice = getSiteNotice()) html.put(`
`, notice, `
`); } ================================================ FILE: src/dfeed/web/web/statics.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2021, 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Handling of static files and resources. module dfeed.web.web.statics; import core.time : MonoTime, seconds; import std.algorithm.comparison : max; import std.algorithm.iteration : map, reduce; import std.algorithm.searching : endsWith, canFind; import std.array : replace, split; import std.conv : to, text; import std.datetime.systime : SysTime; import std.exception : enforce; import std.file : exists; import std.format : format; import std.path : buildNormalizedPath, dirName, stripExtension, extension; import std.regex : replaceAll, replaceFirst; static import std.file; import std.regex : Regex, matchAll; import std.ascii : LetterCase; import std.digest.md : md5Of, toHexString; import ae.net.http.responseex : HttpResponseEx; import ae.sys.data : Data, DataVec; import ae.utils.meta : isDebug; import ae.utils.regex : re; import dfeed.paths : resolveSiteFileBase, resolveSiteFile, resolveStaticFileBase; import dfeed.web.web.config : config; /// Caching wrapper for file modification time. /// Caches stat calls for a few seconds to reduce filesystem overhead. SysTime timeLastModified(string path) { static if (is(MonoTimeImpl!(ClockType.coarse))) alias CoarseTime = MonoTimeImpl!(ClockType.coarse); else alias CoarseTime = MonoTime; static SysTime[string] cache; static CoarseTime cacheTime; enum expiration = isDebug ? 1.seconds : 5.seconds; auto now = CoarseTime.currTime(); if (now - cacheTime > expiration) { cacheTime = now; cache = null; } auto pcache = path in cache; if (pcache) return *pcache; return cache[path] = std.file.timeLastModified(path); } /// Cached content hash for cache-busting static URLs. /// Only recomputes hash when file modification time changes. /// Uses the timeLastModified cache to minimize stat calls. string fileContentHash(string path) { static struct CacheEntry { long mtime; string hash; } static CacheEntry[string] hashCache; auto mtime = timeLastModified(path).stdTime; if (auto entry = path in hashCache) if (entry.mtime == mtime) return entry.hash; // Compute hash - use first 16 hex chars of MD5 (64 bits) auto content = cast(ubyte[]) std.file.read(path); auto hash = md5Of(content).toHexString!(LetterCase.lower)[0..16].idup; hashCache[path] = CacheEntry(mtime, hash); return hash; } string staticPath(string path) { auto resolvedBase = resolveStaticFileBase(path) .enforce("Static file not found: " ~ path); auto resolvedFile = resolvedBase ~ path; auto url = "/static/" ~ fileContentHash(resolvedFile) ~ path; if (config.staticDomain !is null) url = "//" ~ config.staticDomain ~ url; return url; } /// Return `resource`, or `resource ~ ".min"` if it /// exists in `base` and is not older than `resource`. string optimizedPath(string base, string resource) { debug return resource; else { string optimizedResource = resource.stripExtension ~ ".min" ~ resource.extension; auto origPath = base ~ resource; auto optiPath = base ~ optimizedResource; if (exists(origPath) && exists(optiPath) && timeLastModified(optiPath) >= timeLastModified(origPath)) return optimizedResource; else return resource; } } /// Resolve the site location of and return the optimized version of `path`. string optimizedPath(string path) { auto resolvedBase = resolveSiteFileBase(path); auto relativePath = optimizedPath(resolvedBase, path); return resolvedBase ~ relativePath; } HttpResponseEx serveFile(HttpResponseEx response, string path) { auto resolvedBase = resolveStaticFileBase("/" ~ path); auto optimizedFile = optimizedPath(resolvedBase, path); response.cacheForever(); return response.serveFile(path, resolvedBase); } // *********************************************************************** string createBundles(string page, Regex!char re) { string[] paths; foreach (m; page.matchAll(re)) paths ~= m.captures[1]; if (paths.length == 0) return page; // Extract hashes and compute combined bundle hash // URL format: /static/<16-char-hash>/ // Positions: 0-7="/static/", 8-23=hash, 24="/", 25+=path import std.algorithm.iteration : joiner; import std.array : array; auto hashes = paths.map!(path => path[8..24]); auto combinedHash = md5Of(cast(ubyte[]) hashes.joiner.array) .toHexString!(LetterCase.lower)[0..16]; string bundleUrl = "/static-bundle/%s/%-(%s+%)".format(combinedHash, paths.map!(path => path[25..$])); int index; page = page.replaceAll!(captures => index++ ? null : captures[0].replace(captures[1], bundleUrl))(re); return page; } HttpResponseEx makeBundle(string time, string url) { static struct Bundle { string time; HttpResponseEx response; } static Bundle[string] cache; if (url !in cache || cache[url].time != time || isDebug) { auto bundlePaths = url.split("+"); enforce(bundlePaths.length > 0, "Empty bundle"); HttpResponseEx bundleResponse; foreach (n, bundlePath; bundlePaths) { auto pathResponse = new HttpResponseEx; serveFile(pathResponse, bundlePath); assert(pathResponse.data.length == 1); if (bundlePath.endsWith(".css")) { auto oldText = cast(string)pathResponse.data[0].contents; auto newText = fixCSS(oldText, bundlePath, n == 0); if (oldText !is newText) pathResponse.data = DataVec(Data(newText)); } if (!bundleResponse) bundleResponse = pathResponse; else bundleResponse.data ~= pathResponse.data[]; } cache[url] = Bundle(time, bundleResponse); } return cache[url].response.dup; } string fixCSS(string css, string path, bool first) { css = css.replaceFirst(re!(`@charset "utf-8";`, "i"), ``); if (first) css = `@charset "utf-8";` ~ css; css = css.replaceAll!(captures => captures[2].canFind("//") ? captures[0] : captures[0].replace(captures[2], staticPath(buildNormalizedPath(dirName("/" ~ path), captures[2]).replace(`\`, `/`))) )(re!`\burl\(('?)(.*?)\1\)`); return css; } ================================================ FILE: src/dfeed/web/web/user.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// User settings. module dfeed.web.web.user; import ae.utils.aa : aaGet; import ae.utils.text : randomString; import dfeed.loc; import dfeed.web.user : User, SettingType; User user; struct UserSettings { static SettingType[string] settingTypes; template userSetting(string name, string defaultValue, SettingType settingType) { @property string userSetting() { return user.get(name, defaultValue, settingType); } @property string userSetting(string newValue) { user.set(name, newValue, settingType); return newValue; } static this() { settingTypes[name] = settingType; } } template randomUserString(string name, SettingType settingType) { @property string randomUserString() { auto value = user.get(name, null, settingType); if (value is null) { value = randomString(); user.set(name, value, settingType); } return value; } } /// Posting details. Remembered when posting messages. alias name = userSetting!("name", null, SettingType.server); alias email = userSetting!("email", null, SettingType.server); /// ditto /// View mode. Can be changed in the settings. alias groupViewMode = userSetting!("groupviewmode", "basic", SettingType.client); /// Enable or disable keyboard hotkeys. Can be changed in the settings. alias enableKeyNav = userSetting!("enable-keynav", "true", SettingType.client); /// Enable or disable rendering Markdown content.. Can be changed in the settings. alias renderMarkdown = userSetting!("render-markdown", "true", SettingType.server); /// Whether messages are opened automatically after being focused /// (message follows focus). Can be changed in the settings. alias autoOpen = userSetting!("auto-open", "false", SettingType.client); /// Any pending notices that should be shown on the next page shown. alias pendingNotice = userSetting!("pending-notice", null, SettingType.session); /// Session management alias previousSession = userSetting!("previous-session", "0", SettingType.server); alias currentSession = userSetting!("current-session" , "0", SettingType.server); /// ditto alias sessionCanary = userSetting!("session-canary" , "0", SettingType.session); /// ditto /// A unique ID used to recognize both logged-in and anonymous users. alias id = randomUserString!("id", SettingType.server); /// Secret token used for CSRF protection. /// Visible in URLs. alias secret = randomUserString!("secret", SettingType.server); /// UI language. alias language = userSetting!("language", null, SettingType.server); void set(string name, string value) { user.set(name, value, settingTypes.aaGet(name)); } } UserSettings userSettings; static immutable allViewModes = ["basic", "narrow-index", "threaded", "horizontal-split", "vertical-split"]; string viewModeName(string viewMode) { switch (viewMode) { case "basic" : return _!"basic" ; case "narrow-index" : return _!"narrow-index" ; case "threaded" : return _!"threaded" ; case "horizontal-split": return _!"horizontal-split"; case "vertical-split" : return _!"vertical-split" ; default: throw new Exception(_!"Unknown view mode"); } } ================================================ FILE: src/dfeed/web/web/view/feed.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// ATOM feeds. module dfeed.web.web.view.feed; import core.time : dur; import std.array : join; import std.conv : text; import std.datetime.systime; import std.format : format; import ae.net.http.caching : CachedResource; import ae.sys.data : Data, DataVec; import ae.utils.feed : AtomFeedWriter; import dfeed.loc; import dfeed.database : query; import dfeed.groups : GroupInfo; import dfeed.message : Rfc850Post; import dfeed.sinks.cache : CachedSet; import dfeed.sinks.subscriptions : getSubscription; import dfeed.site : site; import dfeed.web.web.page : html; import dfeed.web.web.part.postbody : formatBody; import dfeed.web.web.perf; import dfeed.web.web.postinfo : getPost; enum FEED_HOURS_DEFAULT = 24; enum FEED_HOURS_MAX = 72; CachedSet!(string, CachedResource) feedCache; CachedResource getFeed(GroupInfo groupInfo, bool threadsOnly, int hours) { string feedUrl = site.proto ~ "://" ~ site.host ~ "/feed" ~ (threadsOnly ? "/threads" : "/posts") ~ (groupInfo ? "/" ~ groupInfo.urlName : "") ~ (hours!=FEED_HOURS_DEFAULT ? "?hours=" ~ text(hours) : ""); CachedResource getFeed() { auto title = groupInfo ? threadsOnly ? _!"Latest threads on %s".format(groupInfo.publicName) : _!"Latest posts on %s" .format(groupInfo.publicName) : threadsOnly ? _!"Latest threads" : _!"Latest posts" ; auto posts = getFeedPosts(groupInfo, threadsOnly, hours); auto feed = makeFeed(posts, feedUrl, title, groupInfo is null); return feed; } return feedCache(feedUrl, getFeed()); } Rfc850Post[] getFeedPosts(GroupInfo groupInfo, bool threadsOnly, int hours) { string PERF_SCOPE = "getFeedPosts(%s,%s,%s)".format(groupInfo ? groupInfo.internalName : "null", threadsOnly, hours); mixin(MeasurePerformanceMixin); auto since = (Clock.currTime() - dur!"hours"(hours)).stdTime; auto iterator = groupInfo ? threadsOnly ? query!"SELECT `Message` FROM `Posts` WHERE `ID` IN (SELECT `ID` FROM `Groups` WHERE `Time` > ? AND `Group` = ?) AND `ID` = `ThreadID`".iterate(since, groupInfo.internalName) : query!"SELECT `Message` FROM `Posts` WHERE `ID` IN (SELECT `ID` FROM `Groups` WHERE `Time` > ? AND `Group` = ?)".iterate(since, groupInfo.internalName) : threadsOnly ? query!"SELECT `Message` FROM `Posts` WHERE `Time` > ? AND `ID` = `ThreadID`".iterate(since) : query!"SELECT `Message` FROM `Posts` WHERE `Time` > ?".iterate(since) ; Rfc850Post[] posts; foreach (string message; iterator) posts ~= new Rfc850Post(message); return posts; } CachedResource makeFeed(Rfc850Post[] posts, string feedUrl, string feedTitle, bool addGroup) { AtomFeedWriter feed; feed.startFeed(feedUrl, feedTitle, Clock.currTime()); foreach (post; posts) { html.clear(); formatBody(post); auto postTitle = post.rawSubject; if (addGroup) postTitle = "[" ~ post.publicGroupNames().join(", ") ~ "] " ~ postTitle; feed.putEntry(post.url, postTitle, post.author, post.time, cast(string)html.get(), post.url); } feed.endFeed(); return new CachedResource(DataVec(Data(feed.xml.output.get())), "application/atom+xml"); } CachedResource getSubscriptionFeed(string subscriptionID) { string feedUrl = site.proto ~ "://" ~ site.host ~ "/subscription-feed/" ~ subscriptionID; CachedResource getFeed() { auto subscription = getSubscription(subscriptionID); auto title = _!"%s subscription (%s)".format(site.host, subscription.trigger.getTextDescription()); Rfc850Post[] posts; foreach (string messageID; query!"SELECT [MessageID] FROM [SubscriptionPosts] WHERE [SubscriptionID] = ? ORDER BY [Time] DESC LIMIT 50" .iterate(subscriptionID)) { auto post = getPost(messageID); if (post) posts ~= post; } return makeFeed(posts, feedUrl, title, true); } return feedCache(feedUrl, getFeed()); } ================================================ FILE: src/dfeed/web/web/view/group.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Group index. module dfeed.web.web.view.group; import std.algorithm.comparison; import std.algorithm.iteration; import std.algorithm.mutation : reverse; import std.algorithm.searching; import std.algorithm.sorting; import std.array : replicate, replace, array, join; import std.conv; import std.datetime.systime : SysTime; import std.datetime.timezone : UTC; import std.exception : enforce; import std.format; import ae.utils.text.html : encodeHtmlEntities; import ae.utils.xmllite : putEncodedEntities; import dfeed.loc; import dfeed.database : query; import dfeed.groups; import dfeed.message; import dfeed.sinks.cache; import dfeed.web.user : User; import dfeed.web.web.cache : postCountCache, getPostCounts; import dfeed.web.web.page : html; import dfeed.web.web.part.gravatar : getGravatarHash, putGravatar; import dfeed.web.web.part.pager : THREADS_PER_PAGE, getPageOffset, threadPager, indexToPage, getPageCount, getPageCount, pager; import dfeed.web.web.part.profile : profileUrl; import dfeed.web.web.part.strings : formatTinyTime, formatShortTime, formatLongTime, formatAbsoluteTime, summarizeTime, formatNumber; import dfeed.web.web.part.thread : formatThreadedPosts; import dfeed.web.web.postinfo : PostInfo, getPostInfo, getPost; import dfeed.web.web.statics : staticPath; import dfeed.web.web.user : user, userSettings; int[] getThreadPostIndexes(string id) { int[] result; foreach (int rowid; query!"SELECT `ROWID` FROM `Posts` WHERE `ThreadID` = ?".iterate(id)) result ~= rowid; return result; } CachedSet!(string, int[]) threadPostIndexCache; void newPostButton(GroupInfo groupInfo) { html.put( `
` ~ `
` ~ `` ~ `` ~ `
` ~ `
`); } void discussionGroup(GroupInfo groupInfo, int page) { enforce(page >= 1, _!"Invalid page"); struct Thread { string id; PostInfo* _firstPost, _lastPost; int postCount, unreadPostCount; /// Handle orphan posts @property PostInfo* firstPost() { return _firstPost ? _firstPost : _lastPost; } @property PostInfo* lastPost() { return _lastPost; } @property bool isRead() { return unreadPostCount==0; } } Thread[] threads; threads.reserve(THREADS_PER_PAGE); int getUnreadPostCount(string id) { auto posts = threadPostIndexCache(id, getThreadPostIndexes(id)); int count = 0; foreach (post; posts) if (!user.isRead(post)) count++; return count; } foreach (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))) foreach (int count; query!"SELECT COUNT(*) FROM `Posts` WHERE `ThreadID` = ?".iterate(firstPostID)) { if (count == 0 && user.getLevel() < User.Level.sysadmin) { // 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. continue; } threads ~= Thread(firstPostID, getPostInfo(firstPostID), getPostInfo(lastPostID), count, getUnreadPostCount(firstPostID)); } void summarizeThread(string tid, PostInfo* info, bool isRead) { if (info) with (*info) { putGravatar(getGravatarHash(info.authorEmail), author, profileUrl(author, authorEmail), _!`%s's profile`.format(author), `class="forum-postsummary-gravatar" `); html.put( // `` ~ `` ~ `
`, _!`by`, ` `), html.putEncodedEntities(author), html.put(`
`); return; } html.put(`
-
`); } void summarizeLastPost(PostInfo* info) { if (info) with (*info) { html.put( ``, summarizeTime(time), `` ~ ``); return; } html.put(`
-
`); } void summarizePostCount(ref Thread thread) { html.put(``); if (thread.unreadPostCount == 0) html ~= formatNumber(thread.postCount-1); else html.put(``, formatNumber(thread.postCount-1), ``); html.put(``); if (thread.unreadPostCount && thread.unreadPostCount != thread.postCount) html.put( `
(`, formatNumber(thread.unreadPostCount), ` new)`); } html.put( `` ~ ``, ``.replicate(3), `` ~ // Fixed layout dummies `` ~ ``); foreach (thread; threads) html.put( `` ~ `` ~ `` ~ `` ~ ``); threadPager(groupInfo, page); html.put( `
`), newPostButton(groupInfo), html.putEncodedEntities(groupInfo.publicName), html.put(`
`, _!`Thread / Thread Starter`, ``, _!`Last Post`, ``, _!`Replies`, `
`), summarizeThread(thread.id, thread.firstPost, thread.isRead), html.put(``), summarizeLastPost(thread.lastPost), html.put(``), summarizePostCount(thread), html.put(`
` ); } void discussionGroupNarrowIndex(GroupInfo groupInfo, int page) { enforce(page >= 1, _!"Invalid page"); struct Thread { string id; PostInfo* _firstPost, _lastPost; int postCount, unreadPostCount; /// Handle orphan posts @property PostInfo* firstPost() { return _firstPost ? _firstPost : _lastPost; } @property PostInfo* lastPost() { return _lastPost; } @property bool isRead() { return unreadPostCount == 0; } } Thread[] threads; threads.reserve(THREADS_PER_PAGE); int getUnreadPostCount(string id) { auto posts = threadPostIndexCache(id, getThreadPostIndexes(id)); int count = 0; foreach (post; posts) if (!user.isRead(post)) count++; return count; } foreach (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))) foreach (int count; query!"SELECT COUNT(*) FROM `Posts` WHERE `ThreadID` = ?".iterate(firstPostID)) { if (count == 0 && user.getLevel() < User.Level.sysadmin) { // 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. continue; } threads ~= Thread(firstPostID, getPostInfo(firstPostID), getPostInfo(lastPostID), count, getUnreadPostCount(firstPostID)); } void summarizeThread(ref Thread thread) { html.put(`
` ~ `` ~ `` ~ `
` ~ `` ~ `` ~ `
` ~ `
` ~ `
` ~ ``, formatNumber(thread.postCount - 1), `` ~ ``, formatNumber(thread.postCount - 1), " ", (thread.postCount - 1) == 1 ? _!"reply" : _!"replies"); html.put(`` ~ `
` ~ `
` ~ ``); if (thread.unreadPostCount && thread.unreadPostCount != thread.postCount) html.put(`(`, formatNumber(thread.unreadPostCount), `)`); html.put(`` ~ ``); if (thread.unreadPostCount && thread.unreadPostCount != thread.postCount) html.put(``, formatNumber(thread.unreadPostCount), ` `, _!`new`, ``); html.put(`` ~ `
` ~ `
`); if (thread.lastPost != null) { html.put(``); } else { html.put(`
` ~ `
-
` ~ `
-
` ~ `
`); } html.put(`
`); } html.put(`
` ~ `
` ~ `
`); html.putEncodedEntities(groupInfo.publicName); html.put(`
` ~ `` ~ `
`); html.put(`
`); foreach (thread; threads) if (thread.firstPost) summarizeThread(thread); html.put(`
`); threadPager(groupInfo, page); html.put(`
`); } // *********************************************************************** void discussionGroupThreaded(GroupInfo groupInfo, int page, bool narrow = false) { enforce(page >= 1, _!"Invalid page"); //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)) // foreach (string id, string parent, string author, string subject, long stdTime; query!"SELECT `ID`, `ParentID`, `Author`, `Subject`, `Time` FROM `Posts` WHERE `ThreadID` = ?".iterate(threadID)) PostInfo*[] posts; enum 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 ?)"; foreach (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))) posts ~= [PostInfo(rowid, id, null, parent, author, authorEmail, subject, SysTime(stdTime, UTC()))].ptr; // TODO: optimize? html.put( `` ~ ``, // ``, ``); threadPager(groupInfo, page, narrow ? 25 : 50); html.put(`
`), newPostButton(groupInfo), html.putEncodedEntities(groupInfo.publicName), html.put(`
Subject / AuthorTime
`); formatThreadedPosts(posts, narrow); html.put(`
`); } void discussionGroupSplit(GroupInfo groupInfo, int page) { html.put( `` ~ `` ~ `` ~ `
`); discussionGroupThreaded(groupInfo, page, true); html.put( `
`, _!`Loading...`, `
`, _!`Sorry, this view requires JavaScript.`, `
` ~ `
`); } void discussionGroupSplitFromPost(string id, out GroupInfo groupInfo, out int page, out string threadID) { auto post = getPost(id); enforce(post, _!"Post not found"); groupInfo = post.getGroup(); enforce(groupInfo, _!"Unknown group:" ~ " " ~ post.where); threadID = post.cachedThreadID; page = getThreadPage(groupInfo, threadID); discussionGroupSplit(groupInfo, page); } int getThreadPage(GroupInfo groupInfo, string thread) { int page = 0; foreach (long time; query!"SELECT `LastUpdated` FROM `Threads` WHERE `ID` = ? LIMIT 1".iterate(thread)) foreach (int threadIndex; query!"SELECT COUNT(*) FROM `Threads` WHERE `Group` = ? AND `LastUpdated` > ? ORDER BY `LastUpdated` DESC".iterate(groupInfo.internalName, time)) page = indexToPage(threadIndex, THREADS_PER_PAGE); enforce(page > 0, _!"Can't find thread's page"); return page; } // *********************************************************************** void formatVSplitPosts(PostInfo*[] postInfos, string selectedID = null) { /* html.put( `` ~ `Subject` ~ `From` ~ `` ); */ foreach (postInfo; postInfos) { html.put( `` ~ `` ~ `` ), html.putEncodedEntities(postInfo.subject), html.put( `` ~ `` ~ `` ~ `` ), html.putEncodedEntities(postInfo.author), html.put(`` ~ `` ~ `` ~ `
`, summarizeTime(postInfo.time, true), `
`, `` ~ `` ); } } enum POSTS_PER_GROUP_PAGE = 100; void discussionGroupVSplitList(GroupInfo groupInfo, int page) { enum postsPerPage = POSTS_PER_GROUP_PAGE; enforce(page >= 1, _!"Invalid page"); //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)) // foreach (string id, string parent, string author, string subject, long stdTime; query!"SELECT `ID`, `ParentID`, `Author`, `Subject`, `Time` FROM `Posts` WHERE `ThreadID` = ?".iterate(threadID)) PostInfo*[] posts; //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 ?"; //enum ViewSQL = "SELECT [Posts].[ROWID], [Posts].[ID], `ParentID`, `Author`, `AuthorEmail`, `Subject`, `Time` FROM `Posts` " // "INNER JOIN [Threads] ON `ThreadID`==[Threads].[ID] WHERE `Group` = ? ORDER BY `Time` DESC LIMIT ? OFFSET ?"; enum ViewSQL = "SELECT [Posts].[ROWID], [Posts].[ID], [ParentID], [Author], [AuthorEmail], [Subject], [Posts].[Time] FROM [Groups] " ~ "INNER JOIN [Posts] ON [Posts].[ID]==[Groups].[ID] WHERE [Group] = ? ORDER BY [Groups].[Time] DESC LIMIT ? OFFSET ?"; foreach (int rowid, string id, string parent, string author, string authorEmail, string subject, long stdTime; query!ViewSQL.iterate(groupInfo.internalName, postsPerPage, getPageOffset(page, postsPerPage))) posts ~= [PostInfo(rowid, id, null, parent, author, authorEmail, subject, SysTime(stdTime, UTC()))].ptr; // TODO: optimize? posts.reverse(); html.put( `` ~ ``, // ``, ``); groupPostPager(groupInfo, page); html.put(`
`), newPostButton(groupInfo), html.putEncodedEntities(groupInfo.publicName), html.put(`
Subject / AuthorTime
` ~ ``, ``.replicate(3), `` // Fixed layout dummies ); formatVSplitPosts(posts); html.put(`
`); } void discussionGroupVSplit(GroupInfo groupInfo, int page) { html.put( `` ~ `` ~ `` ~ `
`); discussionGroupVSplitList(groupInfo, page); html.put( `
`, _!`Loading...`, `
`, _!`Sorry, this view requires JavaScript.`, `
` ~ `
`); } int getVSplitPostPage(GroupInfo groupInfo, string id) { int page = 0; foreach (long time; query!"SELECT [Time] FROM [Groups] WHERE [ID] = ? LIMIT 1".iterate(id)) foreach (int threadIndex; query!"SELECT COUNT(*) FROM [Groups] WHERE [Group] = ? AND [Time] > ? ORDER BY [Time] DESC".iterate(groupInfo.internalName, time)) page = indexToPage(threadIndex, POSTS_PER_GROUP_PAGE); enforce(page > 0, _!"Can't find post's page"); return page; } void discussionGroupVSplitFromPost(string id, out GroupInfo groupInfo, out int page, out string threadID) { auto post = getPost(id); enforce(post, _!"Post not found"); groupInfo = post.getGroup(); threadID = post.cachedThreadID; page = getVSplitPostPage(groupInfo, id); discussionGroupVSplit(groupInfo, page); } void groupPostPager(GroupInfo groupInfo, int page) { auto postCounts = postCountCache(getPostCounts()); auto postCount = postCounts.get(groupInfo.internalName, 0); auto pageCount = getPageCount(postCount, POSTS_PER_GROUP_PAGE); pager(`/group/` ~ groupInfo.urlName, page, pageCount, 50); } ================================================ FILE: src/dfeed/web/web/view/index.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Front page. module dfeed.web.web.view.index; import core.time; import std.array : split, replicate; import std.conv : to, text; import std.datetime.systime : Clock, SysTime; import std.format : format; import std.random : uniform; import ae.net.ietf.url : encodeUrlParameter; import ae.utils.meta; import ae.utils.text.html : encodeHtmlEntities; import ae.utils.xmllite : putEncodedEntities; import dfeed.loc; import dfeed.database; import dfeed.message; import dfeed.groups; import dfeed.sinks.cache; import dfeed.sinks.subscriptions; import dfeed.site : site; import dfeed.web.web.cache; import dfeed.web.web.page : html; import dfeed.web.web.part.strings : formatNumber, formatDuration, summarizeTime; import dfeed.web.web.perf; import dfeed.web.web.postinfo : getPostInfo; import dfeed.web.web.user : user, userSettings; Cached!int totalPostCountCache, totalThreadCountCache; void discussionIndexHeader() { auto now = Clock.currTime(); if (now - SysTime(userSettings.sessionCanary.to!long) > 4.hours) { userSettings.previousSession = userSettings.currentSession; userSettings.currentSession = userSettings.sessionCanary = now.stdTime.text; } long previousSession = userSettings.previousSession.to!long; string name = user.isLoggedIn() ? user.getName() : userSettings.name.length ? userSettings.name.split(' ')[0] : _!`Guest`; html.put( `
` ~ `

`), html.putEncodedEntities(site.name), html.put(`

` ~ `

`, previousSession ? _!`Welcome back,` : _!`Welcome,`, ` `), html.putEncodedEntities(name), html.put(`.

` ~ `
    ` ); string[][3] bits; if (user.isLoggedIn()) { auto subscriptions = getUserSubscriptions(user.getName()); int numSubscriptions, numNewSubscriptions; foreach (subscription; subscriptions) { auto c = subscription.getUnreadCount(); if (subscription.trigger.type == "reply") if (c) bits[0] ~= `
  • ` ~ _!`You have %d %s to %syour posts%s.`.format( c, `` ~ plural!`new reply`(c) ~ ``, ``.format(encodeHtmlEntities(encodeUrlParameter(userSettings.email))), ``, ) ~ `
  • `; else bits[2] ~= `
  • ` ~ _!`No new %sreplies%s to %syour posts%s.` .format( ``.format(encodeHtmlEntities(subscription.id)), ``, ``.format(encodeHtmlEntities(encodeUrlParameter(userSettings.email))), ``, ) ~ `
  • `; else { numSubscriptions++; if (c) { numNewSubscriptions++; bits[1] ~= `
  • ` ~ _!`You have %d %s matching your %s%s subscription%s (%s).`.format( c, `` ~ plural!`unread post`(c) ~ ``, ``, subscription.trigger.typeName, ``, subscription.trigger.getDescription(), ) ~ `
  • `; } } } if (numSubscriptions && !numNewSubscriptions) bits[2] ~= `
  • ` ~ _!`No new posts matching your %s%s%s.`.format( ``, plural!"subscription"(numSubscriptions), ``, ) ~ `
  • `; } else { int hasPosts = 0; if (userSettings.email) hasPosts = query!"SELECT EXISTS(SELECT 1 FROM [Posts] WHERE [AuthorEmail] = ? LIMIT 1)".iterate(userSettings.email).selectValue!int; if (hasPosts) bits[2] ~= `
  • ` ~ _!`If you %screate an account%s, you can track replies to %syour posts%s.` .format( ``, ``, ``.format(encodeHtmlEntities(encodeUrlParameter(userSettings.email))), ``, ) ~ `
  • `; else bits[0] ~= `
  • ` ~ _!`You can read and post on this forum without %screating an account%s, but creating an account offers %sa few benefits%s.` .format( ``, ``, ``, ``, ) ~ `
  • `; } SysTime cutOff = previousSession ? SysTime(previousSession) : now - 24.hours; int numThreads = query!"SELECT COUNT(*) FROM [Threads] WHERE [Created] >= ?".iterate(cutOff.stdTime).selectValue!int; int numPosts = query!"SELECT COUNT(*) FROM [Posts] WHERE [Time] >= ?".iterate(cutOff.stdTime).selectValue!int; int numUsers = query!"SELECT COUNT(DISTINCT [AuthorEmail]) FROM [Posts] INDEXED BY [PostTimeAuthorEmail] WHERE [Time] >= ?".iterate(cutOff.stdTime).selectValue!int; bits[(numThreads || numPosts) ? 1 : 2] ~= "
  • " ~ ( (numThreads || numPosts) ? _!"%d %s %-(%s and %)" .format( numUsers, plural!`user has created`(numUsers), (numThreads ? [`%s %s`.format(cutOff.stdTime, formatNumber(numThreads), plural!"thread"(numThreads))] : []) ~ (numPosts ? [`%s %s` .format(cutOff.stdTime, formatNumber(numPosts ), plural!"post" (numPosts ))] : []) ) : _!"No new forum activity" ) ~ " " ~ ( previousSession ? _!"since your last visit (%s).".format(formatDuration(now - cutOff)) : _!"in the last 24 hours." ) ~ "
  • " ; auto totalPosts = totalPostCountCache (query!"SELECT Max([RowID]) FROM [Posts]" .iterate().selectValue!int); auto totalThreads = totalThreadCountCache(query!"SELECT Max([RowID]) FROM [Threads]".iterate().selectValue!int); auto totalUsers = query!"SELECT Max([RowID]) FROM [Users]" .iterate().selectValue!int ; bits[2] ~= "
  • " ~ _!`There are %s %s, %s %s, and %s %s on this forum.` .format( formatNumber(totalPosts) , plural!"post" (totalPosts ), formatNumber(totalThreads), plural!"thread" (totalThreads), formatNumber(totalUsers) , plural!"registered user"(totalUsers ), ) ~ "
  • "; auto numRead = user.countRead(); if (numRead) bits[2] ~= "
  • " ~ _!`You have read a total of %s %s during your %s.`.format( formatNumber(numRead), plural!"forum post"(numRead), plural!"visit"(previousSession ? pluralMany : 1), ) ~ "
  • "; bits[2] ~= "
  • " ~ _!"Random tip:" ~ " " ~ randomTip() ~ "
  • "; foreach (bitGroup; bits[]) foreach (bit; bitGroup) html.put(bit); html.put( `
` ~ `
` ); //html.put("

Random tip: " ~ tips[uniform(0, $)] ~ "

"); } bool hasAlsoVia() { import std.algorithm.searching : any; return groupHierarchy.any!(set => set.groups.any!(group => group.alsoVia.length > 0)); } string randomTip() { static immutable string[] defaultTips = [ `This forum has several different view modes. Try them to find one you like best. You can change the view mode in the settings.`, `This forum supports keyboard shortcuts. Press ? to view them.`, `You can focus a message with j/k and press u to mark it as unread, to remind you to read it later.`, `The avatars on this forum are provided by Gravatar, which allows associating a global avatar with an email address.`, `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.`, `If you create a Gravatar profile with the email address you post with, it will be accessible when clicking your avatar.`, // `You don't need to create an account to post on this forum, but doing so offers a few benefits.`, `To subscribe to a thread, click the "Subscribe" link on that thread's first post. You need to be logged in to create subscriptions.`, `To search the forum, use the search widget at the top, or you can visit the search page directly.`, `This forum is open-source! Read or fork the code on GitHub.`, `If you encounter a bug or need a missing feature, you can create an issue on GitHub.`, ]; static 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.`; immutable numTips = defaultTips.length + (hasAlsoVia() ? 1 : 0); auto index = uniform(0, numTips); if (index < defaultTips.length) { final switch (index) { foreach (n; RangeTuple!(defaultTips.length)) case n: return _!(defaultTips[n]); } } else return _!alsoViaTip; } string[string] getLastPosts() { enum PERF_SCOPE = "getLastPosts"; mixin(MeasurePerformanceMixin); string[string] lastPosts; foreach (set; groupHierarchy) foreach (group; set.groups) foreach (string id; query!"SELECT `ID` FROM `Groups` WHERE `Group`=? ORDER BY `Time` DESC LIMIT 1".iterate(group.internalName)) lastPosts[group.internalName] = id; return lastPosts; } Cached!(string[string]) lastPostCache; void discussionIndex() { discussionIndexHeader(); auto threadCounts = threadCountCache(getThreadCounts()); auto postCounts = postCountCache(getPostCounts()); auto lastPosts = lastPostCache(getLastPosts()); string summarizePost(string postID) { auto info = getPostInfo(postID); if (info) with (*info) return `` ~ `
` ~ _!`by` ~ ` ` ~ encodeHtmlEntities(author) ~ `
` ~ `` ~ summarizeTime(time) ~ ``; return `
-
`; } html.put( `` ~ ``, ``.replicate(5), `` // Fixed layout dummies ); foreach (set; groupHierarchy) { if (!set.visible) continue; html.put( `` ~ `` ); foreach (group; set.groups) { html.put( `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ ``, ); } } html.put(`
`), html.putEncodedEntities(set.name), html.put(`
`, _!`Group`, ``, _!`Last Post`, ``, _!`Threads`, ``, _!`Posts`, ``, _!`Also via`, `
` ~ ``), html.putEncodedEntities(group.publicName), html.put(`` ~ `
`), html.putEncodedEntities(group.description), html.put(`
` ~ `
`, group.internalName in lastPosts ? summarizePost( lastPosts[group.internalName]) : `
-
`, `
`, group.internalName in threadCounts ? formatNumber (threadCounts[group.internalName]) : `-`, ``, group.internalName in postCounts ? formatNumber ( postCounts[group.internalName]) : `-`, `` ); foreach (i, av; group.alsoVia.values) html.put(i ? `
` : ``, ``, av.name, ``); html.put( `
`); } ================================================ FILE: src/dfeed/web/web/view/login.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Login and registration. module dfeed.web.web.view.login; import std.exception : enforce; import ae.net.ietf.url : UrlParameters, encodeUrlParameter; import ae.utils.aa : aaGet; import ae.utils.xmllite : putEncodedEntities; import dfeed.loc; import dfeed.web.web.page : html; import dfeed.web.web.user : user; void discussionLoginForm(UrlParameters parameters, string errorMessage = null) { html.put(`
` ~ `` ~ `` ~ ``); if (errorMessage) html.put(``); else html.put( ``); html.put(`
`, _!`Log in`, `
`); if ("url" in parameters) html.put(``); html.put( `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `
`), html.putEncodedEntities(errorMessage), html.put(`
` ~ ``, _!`Register`, ` `, _!`to keep your preferences
and read post history on the server.` ~ `
`); } void discussionLogin(UrlParameters parameters) { user.logIn(aaGet(parameters, "username"), aaGet(parameters, "password"), !!("remember" in parameters)); } void discussionRegisterForm(UrlParameters parameters, string errorMessage = null) { html.put(`
` ~ `` ~ `` ~ ``); if (errorMessage) html.put(``); else html.put( ``); html.put(`
`, _!`Register`, `
`); if ("url" in parameters) html.put(``); html.put( `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `
`), html.putEncodedEntities(errorMessage), html.put(`
` ~ _!`Please pick your password carefully.` ~ `
` ~ _!`There are no password recovery options.` ~ `
`); } void discussionRegister(UrlParameters parameters) { enforce(aaGet(parameters, "password") == aaGet(parameters, "password2"), _!"Passwords do not match"); user.register(aaGet(parameters, "username"), aaGet(parameters, "password"), !!("remember" in parameters)); } ================================================ FILE: src/dfeed/web/web/view/moderation.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2025, 2026 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Moderation views. module dfeed.web.web.view.moderation; import std.algorithm.iteration : map, filter, uniq; import std.algorithm.searching : canFind, findSplit; import std.algorithm.sorting : sort; import std.array : array, join, empty; import std.conv : text; import std.datetime.systime : Clock; import std.exception : enforce; import std.format : format; import std.string : capitalize, strip; import std.typecons : Yes, No; import ae.net.ietf.url : UrlParameters; import ae.utils.json : jsonParse; import ae.utils.meta : identity; import ae.utils.sini : loadIni; import ae.utils.text : splitAsciiLines; import ae.utils.text.html : encodeHtmlEntities; import ae.utils.xmllite : putEncodedEntities; import dfeed.database : query, selectValue; import dfeed.groups : getGroupInfo; import dfeed.loc; import dfeed.mail : sendMail; import dfeed.message : Rfc850Post, idToUrl; import dfeed.paths : resolveSiteFile; import dfeed.site : site; import dfeed.sources.newsgroups : NntpConfig; import dfeed.web.captcha.common : getCaptchaResponseFromField; import dfeed.web.posting : PostDraft, PostProcess; import dfeed.web.user : User; import dfeed.web.web.draft : getDraft, draftToPost; import dfeed.web.web.moderation : findPostingLog, moderatePost, approvePost, getUnbanPreviewByKey, unbanPoster, UnbanTree, DeletedPostInfo, findDeletedPostInfo; import dfeed.web.web.page : html, Redirect; import dfeed.web.web.part.post : formatPost; import dfeed.web.web.posting : postDraft; import dfeed.web.web.user : user, userSettings; struct JourneyEvent { string timestamp; string type; // "captcha", "spam_check", "moderation", "posted", "info", "log_file", "approval", "page_visit", "referrer" string message; bool success; // true for success, false for failure string details; // additional details like spamicity value string sourceFile; // log file name int lineNumber; // line number in log file (1-based) string url; // for page_visit events, the URL to link to } JourneyEvent[] parsePostingJourney(string messageID) { import std.file : exists, read, dirEntries, SpanMode; import std.algorithm : filter, startsWith, endsWith, map; import std.algorithm.mutation : reverse; import std.array : array; import std.string : split, indexOf; import std.regex : matchFirst; import std.process : execute; import std.path : baseName; JourneyEvent[] events; // Extract the post ID from message ID if (!messageID.startsWith("<") || !messageID.endsWith(">")) return events; auto messageIDclean = messageID[1..$-1]; auto atPos = messageIDclean.indexOf("@"); if (atPos < 0) return events; auto postID = messageIDclean[0..atPos]; if (postID.length != 20) return events; // Helper function to extract previous pid from log content (from [ServerVar] pid: xxx) string extractPreviousPid(string content) { foreach (line; content.split("\n")) { if (line.indexOf("[ServerVar] pid:") >= 0) { auto pidMatch = line.matchFirst(`\[ServerVar\] pid: ([a-z]{20})`); if (pidMatch) return pidMatch[1]; } } return null; } // Helper function to find log file for a given pid string findLogForPid(string pid) { version (Windows) { auto matches = dirEntries("logs", "*PostProcess-" ~ pid ~ ".log", SpanMode.depth).array; return matches.length > 0 ? matches[0].name : null; } else { auto result = execute(["find", "logs/", "-name", "*PostProcess-" ~ pid ~ ".log"]); if (result.status == 0) { auto files = result.output.split("\n").filter!(f => f.length > 0).array; return files.length > 0 ? files[0] : null; } return null; } } // Track which log files are related and why struct RelatedLog { string file; string evidence; // Why this log was included } RelatedLog[] relatedLogs; // Find the primary log and follow the pid chain backwards auto primaryLog = findLogForPid(postID); if (primaryLog is null) return events; // Follow the chain of previous pids string currentPid = postID; string currentLog = primaryLog; string nextPid = postID; // For building the "leads to" evidence while (currentLog !is null) { if (!exists(currentLog)) break; string evidence; if (currentPid == postID) evidence = "Primary log (post ID: " ~ postID ~ ")"; else evidence = "Previous attempt (retry led to: " ~ nextPid ~ ")"; relatedLogs ~= RelatedLog(currentLog, evidence); // Look for previous pid in this log auto content = cast(string)read(currentLog); auto prevPid = extractPreviousPid(content); if (prevPid is null || prevPid == currentPid) break; // Find the log for the previous pid nextPid = currentPid; currentPid = prevPid; currentLog = findLogForPid(prevPid); } // Reverse so oldest attempt is first relatedLogs.reverse(); // Search for approval events in the Banned log file (same date as the PostProcess log) void searchBannedLog(string pid, string postProcessLogFile) { // Replace "PostProcess-xxx.log" with "Banned.log" to get the Banned log for the same date auto bannedLogFile = postProcessLogFile.matchFirst(`^(.* - )PostProcess-[a-z]+\.log$`); if (!bannedLogFile) return; auto logPath = bannedLogFile[1] ~ "Banned.log"; if (!exists(logPath)) return; auto content = cast(string)read(logPath); auto logFileName = baseName(logPath); int lineNum = 0; foreach (line; content.split("\n")) { lineNum++; if (line.length < 30 || line[0] != '[') continue; // Look for approval entries containing our post ID auto approvalMatch = line.matchFirst(`\[([^\]]+)\] User ([^ ]+) is approving draft ([^ ]+) \(post ` ~ pid ~ `\) titled "(.*)" by "(.*)"`); if (approvalMatch) { auto timestamp = approvalMatch[1]; auto moderator = approvalMatch[2]; events ~= JourneyEvent(timestamp, "approval", "Approved by moderator", true, "Moderator: " ~ moderator, logFileName, lineNum); return; // Found it } } } // Parse Web.log for page visits matching IP and User-Agent // Returns the web events separately (not added to main events array) JourneyEvent[] parseWebLog(string postProcessLogFile, string ip, string userAgent) { JourneyEvent[] webEvents; if (ip.length == 0) return webEvents; // Replace "PostProcess-xxx.log" with "Web.log" to get the Web log for the same date auto webLogFile = postProcessLogFile.matchFirst(`^(.* - )PostProcess-[a-z]+\.log$`); if (!webLogFile) return webEvents; auto logPath = webLogFile[1] ~ "Web.log"; if (!exists(logPath)) return webEvents; auto content = cast(string)read(logPath); auto logFileName = baseName(logPath); int lineNum = 0; // Track referrers we've already added to avoid duplicates bool[string] seenReferrers; foreach (line; content.split("\n")) { lineNum++; if (line.length < 30 || line[0] != '[') continue; // Parse log line: [timestamp] \tIP\tSTATUS\tTIME\tMETHOD\tURL\tCONTENT-TYPE[\tREFERER\tUSER-AGENT] auto closeBracket = line.indexOf("]"); if (closeBracket < 0) continue; auto timestamp = line[1..closeBracket]; auto rest = line[closeBracket + 2 .. $]; auto fields = rest.split("\t"); if (fields.length < 7) continue; // Field indices (first field is empty for alignment): // [0]=empty, [1]=IP, [2]=STATUS, [3]=TIME, [4]=METHOD, [5]=URL, [6]=CONTENT-TYPE, [7]=REFERER, [8]=USER-AGENT auto logIP = fields[1]; auto status = fields[2]; auto method = fields[4]; auto url = fields[5]; auto contentType = fields[6]; string referer = fields.length > 7 ? fields[7] : "-"; string logUserAgent = fields.length > 8 ? fields[8] : ""; // Check if this matches our user's IP if (logIP != ip) continue; // If we have a User-Agent to match, check it (but don't require it) if (userAgent.length > 0 && logUserAgent.length > 0 && logUserAgent != userAgent) continue; // Only interested in text/html pages (GET and POST requests) // Also include POST redirects (3xx status with no content type) if (method != "GET" && method != "POST") continue; bool isRedirect = status.length >= 1 && status[0] == '3'; if (!contentType.startsWith("text/html") && !(method == "POST" && isRedirect)) continue; // Skip static resources if (url.canFind("/static/")) continue; // Extract just the path from the URL for display string displayPath = url; auto hostEnd = url.indexOf("://"); if (hostEnd >= 0) { auto pathStart = url.indexOf("/", hostEnd + 3); if (pathStart >= 0) displayPath = url[pathStart .. $]; } // Check for external referrer (not from same site) if (referer != "-" && referer.length > 0) { // Check if referrer is external (doesn't contain our host) auto urlHost = url.indexOf("://"); string ourHost; if (urlHost >= 0) { auto hostStart = urlHost + 3; auto hostEndPos = url.indexOf("/", hostStart); ourHost = hostEndPos >= 0 ? url[hostStart .. hostEndPos] : url[hostStart .. $]; } bool isExternal = ourHost.length > 0 && !referer.canFind(ourHost); if (isExternal && referer !in seenReferrers) { seenReferrers[referer] = true; auto evt = JourneyEvent(timestamp, "referrer", "External referrer", true, "", logFileName, lineNum); evt.url = referer; webEvents ~= evt; } } // Add page visit event auto eventMessage = method == "POST" ? "Form submission" : "Page visit"; auto evt = JourneyEvent(timestamp, "page_visit", eventMessage, true, displayPath, logFileName, lineNum); evt.url = url; webEvents ~= evt; } return webEvents; } // Track IP and User-Agent for web log correlation string userIP; string userAgent; // Parse each log file foreach (ref related; relatedLogs) { auto logFile = related.file; if (!exists(logFile)) continue; auto content = cast(string)read(logFile); auto logFileName = baseName(logFile); // Extract post ID from filename to show which attempt this is string logPostID; auto m = logFile.matchFirst(`PostProcess-([a-z]{20})\.log`); if (m) logPostID = m.captures[1]; // Add log file header with evidence events ~= JourneyEvent("", "log_file", logFileName, true, related.evidence, logFileName, 0); int lineNum = 0; foreach (line; content.split("\n")) { lineNum++; if (line.length < 30 || line[0] != '[') continue; // Extract timestamp auto closeBracket = line.indexOf("]"); if (closeBracket < 0) continue; auto timestamp = line[1..closeBracket]; auto message = line[closeBracket + 2 .. $]; // Parse different event types if (message.startsWith("IP: ")) { userIP = message[4..$]; events ~= JourneyEvent(timestamp, "info", "IP Address", true, message[4..$], logFileName, lineNum); } else if (message.startsWith("[Header] User-Agent: ")) { userAgent = message[21..$]; } else if (message.startsWith("CAPTCHA OK")) { events ~= JourneyEvent(timestamp, "captcha", "CAPTCHA solved successfully", true, "", logFileName, lineNum); } else if (message.startsWith(" CAPTCHA question: ")) { auto jsonStr = message[20..$]; string question; try question = jsonParse!string(jsonStr); catch (Exception) question = jsonStr; // Fallback if not valid JSON events ~= JourneyEvent(timestamp, "captcha", "CAPTCHA question", true, question, logFileName, lineNum); } else if (message.startsWith("[Form] ")) { // Check if this form field is a CAPTCHA response auto formContent = message[7..$]; auto colonPos = formContent.indexOf(": "); if (colonPos >= 0) { auto fieldName = formContent[0..colonPos]; auto fieldValue = formContent[colonPos + 2..$]; auto captchaResponse = getCaptchaResponseFromField(fieldName, fieldValue); if (captchaResponse !is null) events ~= JourneyEvent(timestamp, "captcha", "CAPTCHA answer", true, captchaResponse, logFileName, lineNum); } } else if (message.startsWith("CAPTCHA failed: ")) { events ~= JourneyEvent(timestamp, "captcha", "CAPTCHA failed", false, message[16..$], logFileName, lineNum); } else if (message.startsWith("Spam check failed (spamicity: ")) { auto spamMatch = message.matchFirst(`Spam check failed \(spamicity: ([\d.]+)\): (.+)`); if (spamMatch) events ~= JourneyEvent(timestamp, "spam_check", "Spam check failed", false, "Spamicity: " ~ spamMatch[1] ~ " - " ~ spamMatch[2], logFileName, lineNum); } else if (message.startsWith("Spam check OK (spamicity: ")) { auto spamMatch = message.matchFirst(`Spam check OK \(spamicity: ([\d.]+)\)`); if (spamMatch) events ~= JourneyEvent(timestamp, "spam_check", "Spam check passed", true, "Spamicity: " ~ spamMatch[1], logFileName, lineNum); } else if (message.startsWith("User is trusted, skipping spam check")) { events ~= JourneyEvent(timestamp, "spam_check", "Trusted user, spam check skipped", true, "", logFileName, lineNum); } else if (message.startsWith("Got reply from spam checker ")) { auto checkerMatch = message.matchFirst(`Got reply from spam checker [^:]+\.([^.:]+): spamicity ([\d.]+) \(([^)]*)\)`); if (checkerMatch) { auto checkerName = checkerMatch[1]; auto spamicity = checkerMatch[2]; auto detail = checkerMatch[3]; auto detailStr = detail.length > 0 ? " (" ~ detail ~ ")" : ""; events ~= JourneyEvent(timestamp, "spam_detail", checkerName, true, "Spamicity: " ~ spamicity ~ detailStr, logFileName, lineNum); } } else if (message.startsWith("Quarantined for moderation: ")) { events ~= JourneyEvent(timestamp, "moderation", "Quarantined for moderation", false, message[28..$], logFileName, lineNum); } else if (message.startsWith("< Message-ID: <")) { events ~= JourneyEvent(timestamp, "posted", "Post created with Message-ID", true, message[15..$-1], logFileName, lineNum); // Remove "< Message-ID: <" and final ">" } } } // Search for approval event if (primaryLog !is null) searchBannedLog(postID, primaryLog); // Parse Web.log for page visits (returns separate array, doesn't modify events) JourneyEvent[] webEvents; if (primaryLog !is null) webEvents = parseWebLog(primaryLog, userIP, userAgent); // Interleave web events between PostProcess log sections based on timestamps if (webEvents.length > 0) { // Sort web events by timestamp webEvents.sort!((a, b) => a.timestamp < b.timestamp); // Split events into sections (each section starts with a log_file header) struct LogSection { size_t startIdx; // Index of log_file header in events array size_t endIdx; // Index after last event in this section string firstTimestamp; // First non-header event timestamp } LogSection[] sections; for (size_t i = 0; i < events.length; i++) { if (events[i].type == "log_file") { LogSection section; section.startIdx = i; // Find the end of this section (next log_file header or end of array) size_t j = i + 1; while (j < events.length && events[j].type != "log_file") { if (section.firstTimestamp.length == 0 && events[j].timestamp.length > 0) section.firstTimestamp = events[j].timestamp; j++; } section.endIdx = j; sections ~= section; i = j - 1; // Continue from end of section } } // Rebuild events with web events interleaved JourneyEvent[] newEvents; size_t webIdx = 0; foreach (sectionIdx, ref section; sections) { // Insert web events that occurred before this section's first event while (webIdx < webEvents.length && (section.firstTimestamp.length == 0 || webEvents[webIdx].timestamp < section.firstTimestamp)) { newEvents ~= webEvents[webIdx]; webIdx++; } // Add this section's events for (size_t i = section.startIdx; i < section.endIdx; i++) newEvents ~= events[i]; } // Add any remaining web events after all sections while (webIdx < webEvents.length) { newEvents ~= webEvents[webIdx]; webIdx++; } events = newEvents; } return events; } void renderJourneyTimeline(JourneyEvent[] events) { import std.conv : to; if (events.length == 0) return; html.put( `` ~ `
` ~ `

User Journey

` ~ `
` ); foreach (event; events) { string cssClass; if (event.type == "log_file") cssClass = "log_file"; else if (event.type == "spam_detail") cssClass = "spam_detail"; else if (event.type == "approval") cssClass = "approval"; else if (event.type == "page_visit") cssClass = "page_visit"; else if (event.type == "referrer") cssClass = "referrer"; else if (event.success) cssClass = "success"; else if (event.type == "info") cssClass = "info"; else cssClass = "failure"; html.put(`
`); // Show source file:line for regular events if (event.type != "log_file" && event.sourceFile.length > 0 && event.lineNumber > 0) { html.put(``); html.putEncodedEntities(event.sourceFile ~ ":" ~ event.lineNumber.to!string); html.put(``); } if (event.timestamp.length > 0) { html.put(``); html.putEncodedEntities(event.timestamp); html.put(` `); } html.put(``); html.putEncodedEntities(event.message); html.put(``); if (event.details.length > 0 || event.url.length > 0) { html.put(``); } html.put(`
`); } html.put(`
`); } void discussionModeration(Rfc850Post post, UrlParameters postVars) { if (postVars == UrlParameters.init) { // Display user journey timeline auto journeyEvents = parsePostingJourney(post.id); renderJourneyTimeline(journeyEvents); auto sinkNames = post.xref .map!(x => x.group.getGroupInfo()) .filter!(g => g.sinkType == "nntp") .map!(g => g.sinkName[]) .array.sort.uniq ; auto deleteCommands = sinkNames .map!(sinkName => loadIni!NntpConfig(resolveSiteFile("config/sources/nntp/" ~ sinkName ~ ".ini")).deleteCommand) .filter!identity ; html.put( `
` ~ `` ~ `` ~ `
` ~ _!`Perform which moderation actions on this post?` ~ `
` ~ `
` ~ `` ~ `
`, findPostingLog(post.id) ? `` ~ `
` : ``, !deleteCommands.empty ? `` ~ `
` : ``, `` ~ `
`, _!`Reason:`, `
` ~ `` ~ `
` ); } else { if (postVars.get("secret", "") != userSettings.secret) throw new Exception(_!"XSRF secret verification failed. Are your cookies enabled?"); string messageID = postVars.get("id", ""); string userName = user.getName(); string reason = postVars.get("reason", ""); bool deleteLocally = postVars.get("delete" , "No") == "Yes"; bool ban = postVars.get("ban" , "No") == "Yes"; bool delSource = postVars.get("delsource", "No") == "Yes"; bool callSinks = postVars.get("callsinks", "No") == "Yes"; if (deleteLocally || ban || delSource) moderatePost( messageID, reason, userName, deleteLocally ? Yes.deleteLocally : No.deleteLocally, ban ? Yes.ban : No.ban , delSource ? Yes.deleteSource : No.deleteSource , callSinks ? Yes.callSinks : No.callSinks , (string s) { html.put(encodeHtmlEntities(s) ~ "
"); }, ); else html.put("No actions specified!"); } } void discussionModerationDeleted(string messageID) { html.put( `
` ~ _!`This post is not in the database.` ~ `
` ); // Display user journey timeline (from PostProcess logs) auto journeyEvents = parsePostingJourney(messageID); if (!journeyEvents.empty) { renderJourneyTimeline(journeyEvents); } // Look for deleted post info in Deleted.log auto deletedInfo = findDeletedPostInfo(messageID); bool hasDeletedInfo = deletedInfo.messageContent.length > 0 || deletedInfo.postsRow.length > 0; if (hasDeletedInfo) { html.put(`

`, _!`Deletion Record`, `

`); html.put(``); } } void deletePostApi(string group, int artNum) { string messageID; foreach (string id; query!"SELECT [ID] FROM [Groups] WHERE [Group] = ? AND [ArtNum] = ?".iterate(group, artNum)) messageID = id; enforce(messageID, "No such article in this group"); string reason = "API call"; string userName = "API"; moderatePost( messageID, reason, userName, Yes.deleteLocally, No.ban, No.deleteSource, Yes.callSinks, (string s) { html.put(s ~ "\n"); }, ); } private void discussionFlagPageImpl(bool flag)(Rfc850Post post, UrlParameters postParams) { static immutable string[2] actions = ["unflag", "flag"]; bool isFlagged = query!`SELECT COUNT(*) FROM [Flags] WHERE [Username]=? AND [PostID]=?`.iterate(user.getName(), post.id).selectValue!int > 0; if (postParams == UrlParameters.init) { if (flag == isFlagged) { html.put( `
` ~ _!(`It looks like you've already ` ~ actions[flag] ~ `ged this post.`), ` `, _!(`Would you like to %s` ~ actions[!flag] ~ ` it%s?`).format( ``, ``, ), `
`); } else { html.put( `
`, _!(`Are you sure you want to ` ~ actions[flag] ~ ` this post?`), `
`); formatPost(post, null, false); html.put( `
` ~ `` ~ `` ~ `` ~ `
`); } } else { enforce(postParams.get("secret", "") == userSettings.secret, _!"XSRF secret verification failed. Are your cookies enabled?"); enforce(user.getLevel() >= User.Level.canFlag, _!"You can't flag posts!"); enforce(user.createdAt() < post.time, _!"You can't flag this post!"); if ("flag" in postParams) { enforce(flag != isFlagged, _!("You've already " ~ actions[flag] ~ "ged this post.")); if (flag) query!`INSERT INTO [Flags] ([PostID], [Username], [Date]) VALUES (?, ?, ?)`.exec(post.id, user.getName(), Clock.currTime.stdTime); else query!`DELETE FROM [Flags] WHERE [PostID]=? AND [Username]=?`.exec(post.id, user.getName()); html.put( `
`, _!(`Post ` ~ actions[flag] ~ `ged.`), `
` ~ `
` ~ `` ~ `` ~ `
`); static if (flag) { auto subject = "%s flagged %s's post in the thread \"%s\"".format( user.getName(), post.author, post.subject, ); foreach (mod; site.moderators) sendMail(q"EOF From: %1$s To: %3$s Subject: %4$s Content-Type: text/plain; charset=utf-8 Howdy %5$s, %4$s: %6$s://%7$s%8$s Here is the message that was flagged: ---------------------------------------------- %9$-(%s %) ---------------------------------------------- If you believe this message should be deleted, you can click here to do so: %6$s://%7$s%10$s All the best, %1$s ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ You are receiving this message because you are configured as a site moderator on %2$s. To stop receiving messages like this, please ask the administrator of %1$s to remove you from the list of moderators. . EOF" .format( /* 1*/ site.name.length ? site.name : site.host, /* 2*/ site.host, /* 3*/ mod, /* 4*/ subject, /* 5*/ mod.canFind("<") ? mod.findSplit("<")[0].findSplit(" ")[0] : mod.findSplit("@")[0], /* 6*/ site.proto, /* 7*/ site.host, /* 8*/ idToUrl(post.id), /* 9*/ post.content.strip.splitAsciiLines.map!(line => line.length ? "> " ~ line : ">"), /*10*/ idToUrl(post.id, "delete"), )); } } else throw new Redirect(idToUrl(post.id)); } } void discussionFlagPage(Rfc850Post post, bool flag, UrlParameters postParams) { if (flag) discussionFlagPageImpl!true (post, postParams); else discussionFlagPageImpl!false(post, postParams); } void discussionApprovePage(string draftID, UrlParameters postParams) { auto draft = getDraft(draftID); if (draft.status == PostDraft.Status.sent && "pid" in draft.serverVars) { html.put(_!`This message has already been posted.`, ` ` ~ ``, _!`You can view it here.`, ``); return; } enforce(draft.status == PostDraft.Status.moderation, _!"This is not a post in need of moderation. Its status is currently:" ~ " " ~ text(draft.status)); if (postParams == UrlParameters.init) { // Display user journey timeline if we have a pid if ("pid" in draft.serverVars) { auto messageID = PostProcess.pidToMessageID(draft.serverVars["pid"]); auto journeyEvents = parsePostingJourney(messageID); renderJourneyTimeline(journeyEvents); } html.put( `
`, _!`Are you sure you want to approve this post?`, `
`); auto post = draftToPost(draft); formatPost(post, null, false); html.put( `
` ~ `` ~ `` ~ `` ~ `
`); } else { enforce(postParams.get("secret", "") == userSettings.secret, _!"XSRF secret verification failed. Are your cookies enabled?"); if ("approve" in postParams) { auto pid = approvePost(draftID, user.getName()); html.put(_!`Post approved!`, ` `, _!`View posting`, ``); } else throw new Redirect("/"); } } void discussionUnbanByKeyPage(string key, UrlParameters postParams) { import std.algorithm.iteration : map; import std.conv : to; if (postParams == UrlParameters.init) { auto tree = getUnbanPreviewByKey(key); if (tree.allNodes.length == 0) { html.put( `
`, _!`The specified key is not banned.`, `
` ~ `
` ~ `` ~ `` ~ `` ~ `
`); return; } html.put( `
`, _!`Select which keys to unban:`, `
` ~ ``); void renderNode(UnbanTree.Node* node, int depth = 0) { html.put(`
  • `); html.put(`
    `); if (node.children.length > 0) { html.put(`
      `); foreach (child; node.children) renderNode(child, depth + 1); html.put(`
    `); } html.put(`
  • `); } html.put( `
    ` ~ `` ~ ``); html.put(`
      `); foreach (root; tree.roots) renderNode(root); html.put(`
    `); html.put( `` ~ `` ~ `
    `); } else { enforce(postParams.get("secret", "") == userSettings.secret, _!"XSRF secret verification failed. Are your cookies enabled?"); if ("unban" in postParams) { // Collect all checked keys string[] keysToUnban; foreach (name, value; postParams) if (name == "key") keysToUnban ~= value; enforce(keysToUnban.length > 0, _!"No keys selected to unban"); // Use the lookup key as a dummy ID for logging auto lookupKey = postParams.get("lookup-key", key); unbanPoster(user.getName(), "", keysToUnban); html.put(format(_!`Unbanned %d key(s)!`, keysToUnban.length), ` `, _!`Unban another key`, ``); } else throw new Redirect("/"); } } ================================================ FILE: src/dfeed/web/web/view/post.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Formatting posts. module dfeed.web.web.view.post; import std.algorithm.iteration : map; import std.array : array, join, replicate; import std.conv : text; import std.exception : enforce; import std.format; import ae.net.ietf.message : Rfc850Message; import ae.utils.text.html : encodeHtmlEntities; import ae.utils.xmllite : putEncodedEntities; import dfeed.database : query; import dfeed.groups : GroupInfo; import dfeed.loc; import dfeed.message : Rfc850Post, idToUrl, idToFragment, getGroup; import dfeed.web.web.page : html; import dfeed.web.web.part.gravatar : getGravatarHash, putGravatar; import dfeed.web.web.part.post : getParentLink, miniPostInfo, getPostActions, postActions, postLink, formatPost, formatPostParts; import dfeed.web.web.part.profile : profileUrl; import dfeed.web.web.part.postbody : formatBody; import dfeed.web.web.part.strings : formatShortTime, summarizeTime, formatAbsoluteTime; import dfeed.web.web.part.thread : discussionThreadOverview; import dfeed.web.web.postinfo : PostInfo, getPost, idToThreadUrl, getPostInfo; import dfeed.web.web.user : user; // *********************************************************************** void discussionVSplitPost(string id) { auto post = getPost(id); enforce(post, _!"Post not found"); formatPost(post, null); } // *********************************************************************** struct InfoRow { string name, value; } /// Alternative post formatting, with the meta-data header on top void formatSplitPost(Rfc850Post post, bool footerNav) { scope(success) user.setRead(post.rowid, true); InfoRow[] infoRows; string parentLink; infoRows ~= InfoRow(_!"From", encodeHtmlEntities(post.author)); // infoRows ~= InfoRow(_!"Date", format("%s (%s)", formatAbsoluteTime(post.time), formatShortTime(post.time, false))); infoRows ~= InfoRow(_!"Date", formatAbsoluteTime(post.time)); if (post.parentID) { auto parent = post.parentID ? getPostInfo(post.parentID) : null; if (parent) { parentLink = postLink(parent.rowid, parent.id, parent.author); infoRows ~= InfoRow(_!"In reply to", parentLink); } } string[] replies; foreach (int rowid, string id, string author; query!"SELECT `ROWID`, `ID`, `Author` FROM `Posts` WHERE ParentID = ?".iterate(post.id)) replies ~= postLink(rowid, id, author); if (replies.length) infoRows ~= InfoRow(_!"Replies", `` ~ replies.join(`, `) ~ ``); auto partList = formatPostParts(post); if (partList.length) infoRows ~= InfoRow(_!"Attachments", partList.join(", ")); string gravatarHash = getGravatarHash(post.authorEmail); with (post.msg) { html.put( `
    ` ~ `` ~ `` ~ `` ~ `` ~ `
    ` ~ `
    `, summarizeTime(time), `
    ` ~ ``, encodeHtmlEntities(rawSubject), `` ~ `
    ` ~ `` ~ `` ~ `
    `); miniPostInfo(post, null); html.put( `
    `, ), formatBody(post), html.put( (error ? `` ~ encodeHtmlEntities(error) ~ `` : ``), `
    ` ); postFooter(footerNav, infoRows[1..$]); html.put( `
    ` ~ `
    ` ~ `
    ` ); } } void postFooter(bool footerNav, InfoRow[] infoRows) { html.put( ``, (footerNav ? `` : null), ``, (footerNav ? `` : null), `` ); } void discussionSplitPost(string id) { auto post = getPost(id); enforce(post, _!"Post not found"); formatSplitPost(post, true); } void discussionSinglePost(string id, out GroupInfo groupInfo, out string title, out string authorEmail, out string threadID) { auto post = getPost(id); enforce(post, _!"Post not found"); groupInfo = post.getGroup(); enforce(groupInfo, _!"Unknown group"); title = post.subject; authorEmail = post.authorEmail; threadID = post.cachedThreadID; formatSplitPost(post, false); discussionThreadOverview(threadID, id); } ================================================ FILE: src/dfeed/web/web/view/search.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Search form and results. module dfeed.web.web.view.search; import core.time : Duration, days; import std.algorithm.iteration : map, filter; import std.algorithm.mutation : stripLeft; import std.algorithm.searching : startsWith, canFind, findSplit; import std.array : replace, split, join, replicate; import std.conv : to; import std.exception : enforce; import std.format : format; import std.functional : not; import std.range.primitives : empty; import std.string : strip, indexOf; import ae.net.ietf.url : UrlParameters, encodeUrlParameters; import ae.utils.exception : CaughtException; import ae.utils.meta : I; import ae.utils.text : segmentByWhitespace; import ae.utils.text.html : encodeHtmlEntities; import ae.utils.time.parse : parseTime; import ae.utils.xmllite : putEncodedEntities; import dfeed.loc; import dfeed.database : query; import dfeed.groups : getGroupInfoByPublicName; import dfeed.message : Rfc850Post, idToFragment, idToUrl; import dfeed.sinks.messagedb : searchTerm; import dfeed.web.web.page : html, Redirect; import dfeed.web.web.part.gravatar : getGravatarHash, putGravatar; import dfeed.web.web.part.pager; import dfeed.web.web.part.post : miniPostInfo; import dfeed.web.web.part.profile : profileUrl; import dfeed.web.web.part.strings : summarizeTime; import dfeed.web.web.postinfo : getPost; import dfeed.web.web.user : user; /// Delimiters for formatSearchSnippet. enum searchDelimPrefix = "\U000FDeed"; // Private Use Area character enum searchDelimStartMatch = searchDelimPrefix ~ "\x01"; enum searchDelimEndMatch = searchDelimPrefix ~ "\x02"; enum searchDelimEllipses = searchDelimPrefix ~ "\x03"; enum searchDelimLength = searchDelimPrefix.length + 1; void discussionSearch(UrlParameters parameters) { // HTTP form parameters => search string (visible in form, ?q= parameter) => search query (sent to database) string[] terms; if (string searchScope = parameters.get("scope", null)) { if (searchScope.startsWith("dlang.org")) throw new Redirect("https://www.google.com/search?" ~ encodeUrlParameters(["sitesearch" : searchScope, "q" : parameters.get("q", null)])); else if (searchScope == "forum") {} else if (searchScope.startsWith("group:") || searchScope.startsWith("threadmd5:")) terms ~= searchScope; } terms ~= parameters.get("q", null); if (parameters.get("exact", null).length) terms ~= '"' ~ parameters["exact"].replace(`"`, ``) ~ '"'; if (parameters.get("not", null).length) foreach (word; parameters["not"].split) terms ~= "-" ~ word.stripLeft('-'); foreach (param; ["group", "author", "authoremail", "subject", "content", "newthread"]) if (parameters.get(param, null).length) foreach (word; parameters[param].split) { if (param == "group") word = word.getGroupInfoByPublicName.I!(gi => gi ? gi.internalName.searchTerm : word); terms ~= param ~ ":" ~ word; } if (parameters.get("startdate", null).length || parameters.get("enddate", null).length) terms ~= "date:" ~ parameters.get("startdate", null) ~ ".." ~ parameters.get("enddate", null); auto searchString = terms.map!strip.filter!(not!empty).join(" "); bool doSearch = searchString.length > 0; string autoFocus = doSearch ? "" : " autofocus"; if ("advsearch" in parameters) { html.put( `
    ` ~ `

    `, _!`Advanced Search`, `

    ` ~ `

    `, _!`Find posts with...`, `

    ` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `
    `, _!`all these words:` , `
    `, _!`this exact phrase:` , `
    `, _!`none of these words:` , `
    `, _!`posted in the group:` , `
    `, _!`posted by:` , `
    `, _!`posted by (email):` , `
    `, _!`in threads titled:` , `
    `, _!`containing:` , `
    `, _!`posted between:` , ` `, _!`and`, `
    `, _!`posted as new thread:`, `
    ` ~ `
    ` ~ `` ~ `` ~ `
    ` ); doSearch = false; } else { html.put( `
    ` ~ `

    `, _!`Search`, `

    ` ~ `` ~ `` ~ `` ~ `
    ` ); } if (doSearch) try { long startDate = 0; long endDate = long.max; terms = searchString.split(); string[] queryTerms; foreach (term; terms) if (term.startsWith("date:") && term.canFind("..")) { long parseDate(string date, Duration offset, long def) { if (!date.length) return def; else try return (date.parseTime!`Y-m-d` + offset).stdTime; catch (Exception e) throw new Exception(_!"Invalid date: %s (%s)".format(date, e.msg)); } auto dates = term.findSplit(":")[2].findSplit(".."); startDate = parseDate(dates[0], 0.days, startDate); endDate = parseDate(dates[2], 1.days, endDate); } else if (term.startsWith("time:") && term.canFind("..")) { long parseTime(string time, long def) { return time.length ? time.to!long : def; } auto times = term.findSplit(":")[2].findSplit(".."); startDate = parseTime(times[0], startDate); endDate = parseTime(times[2], endDate); } else queryTerms ~= term; enforce(startDate < endDate, _!"Start date must be before end date"); auto queryString = queryTerms.join(' '); int page = parameters.get("page", "1").to!int; enforce(page >= 1, _!"Invalid page number"); enum postsPerPage = 10; int n = 0; enum queryCommon = "SELECT [ROWID], snippet([PostSearch], '" ~ searchDelimStartMatch ~ "', '" ~ searchDelimEndMatch ~ "', '" ~ searchDelimEllipses ~ "', 6) " ~ "FROM [PostSearch]"; auto iterator = queryTerms.length ? (startDate == 0 && endDate == long.max) ? query!(queryCommon ~ " WHERE [PostSearch] MATCH ? ORDER BY [Time] DESC LIMIT ? OFFSET ?") .iterate(queryString, postsPerPage + 1, (page-1)*postsPerPage) : query!(queryCommon ~ " WHERE [PostSearch] MATCH ? AND [Time] BETWEEN ? AND ? ORDER BY [Time] DESC LIMIT ? OFFSET ?") .iterate(queryString, startDate, endDate, postsPerPage + 1, (page-1)*postsPerPage) : query!("SELECT [ROWID], '' FROM [Posts] WHERE [Time] BETWEEN ? AND ? ORDER BY [Time] DESC LIMIT ? OFFSET ?") .iterate(startDate, endDate, postsPerPage + 1, (page-1)*postsPerPage) ; foreach (int rowid, string snippet; iterator) { //html.put(`
    `, snippet, `
    `); string messageID; foreach (string id; query!"SELECT [ID] FROM [Posts] WHERE [ROWID] = ?".iterate(rowid)) messageID = id; if (!messageID) continue; // Can occur with deleted posts n++; if (n > postsPerPage) break; auto post = getPost(messageID); if (post) { if (!snippet.length) // No MATCH (date only) { enum maxWords = 20; auto segments = post.newContent.segmentByWhitespace; if (segments.length < maxWords*2) snippet = segments.join(); else snippet = segments[0..maxWords*2-1].join() ~ searchDelimEllipses; } formatSearchResult(post, snippet); } } if (n == 0) html.put(`

    `, _!`Your search -`, ` `), html.putEncodedEntities(searchString), html.put(` `, _!`- did not match any forum posts.`, `

    `); if (page != 1 || n > postsPerPage) { html.put(``); pager("?" ~ encodeUrlParameters(["q" : searchString]), page, n > postsPerPage ? int.max : page); html.put(`
    `); } } catch (CaughtException e) html.put(`
    `, _!`Error:`, ` `), html.putEncodedEntities(e.msg), html.put(`
    `); } void formatSearchSnippet(string s) { while (true) { auto i = s.indexOf(searchDelimPrefix); if (i < 0) break; html.putEncodedEntities(s[0..i]); string delim = s[i..i+searchDelimLength]; s = s[i+searchDelimLength..$]; switch (delim) { case searchDelimStartMatch: html.put(`` ); break; case searchDelimEndMatch : html.put(`` ); break; case searchDelimEllipses : html.put(`...`); break; default: break; } } html.putEncodedEntities(s); } void formatSearchResult(Rfc850Post post, string snippet) { string gravatarHash = getGravatarHash(post.authorEmail); with (post.msg) { html.put( `
    ` ~ `` ~ ``, ``.replicate(2), `` ~ // Fixed layout dummies `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `
    ` ~ `
    `, summarizeTime(time), `
    `, encodeHtmlEntities(post.publicGroupNames().join(", ")), ` » ` ~ `` ~ `
    ` ~ `
    `), formatSearchSnippet(snippet), html.put(`
    `, (error ? `` ~ encodeHtmlEntities(error) ~ `` : ``), `
    ` ~ `
    ` ); } } ================================================ FILE: src/dfeed/web/web/view/settings.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2021 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// User settings. module dfeed.web.web.view.settings; import std.algorithm.iteration; import std.algorithm.searching; import std.conv; import std.exception; import std.format; import ae.net.ietf.url; import ae.utils.aa : aaGet; import ae.utils.json; import ae.utils.text.html : encodeHtmlEntities; import ae.utils.xmllite; import dfeed.loc; import dfeed.sinks.subscriptions; import dfeed.site : site; import dfeed.web.markdown : haveMarkdown; import dfeed.web.web.page : html, Redirect; import dfeed.web.web.request : currentRequest; import dfeed.web.web.user; string settingsReferrer; void discussionSettings(UrlParameters getVars, UrlParameters postVars) { settingsReferrer = postVars.get("referrer", currentRequest.headers.get("Referer", null)); if (postVars) { if (postVars.get("secret", "") != userSettings.secret) throw new Exception(_!"XSRF secret verification failed. Are your cookies enabled?"); auto actions = postVars.keys.filter!(name => name.startsWith("action-")); enforce(!actions.empty, _!"No action specified"); auto action = actions.front[7..$]; if (action == "cancel") throw new Redirect(settingsReferrer ? settingsReferrer : "/"); else if (action == "save") { // Inputs foreach (setting; ["groupviewmode", "language"]) if (setting in postVars) userSettings.set(setting, postVars[setting]); // Checkboxes foreach (setting; ["enable-keynav", "auto-open"]) userSettings.set(setting, setting in postVars ? "true" : "false"); if (haveMarkdown()) userSettings.set("render-markdown", "render-markdown" in postVars ? "true" : "false"); userSettings.pendingNotice = "settings-saved"; throw new Redirect(settingsReferrer ? settingsReferrer : "/settings"); } else if (action == "subscription-cancel") {} else if (action.skipOver("subscription-edit-")) { auto subscriptionID = action; return discussionSubscriptionEdit(getUserSubscription(user.getName(), subscriptionID)); } else if (action.skipOver("subscription-view-")) throw new Redirect("/subscription-posts/" ~ action); else if (action.skipOver("subscription-feed-")) throw new Redirect("/subscription-feed/" ~ action); else if (action == "subscription-save" || action == "subscription-undelete") { string message; if (action == "subscription-undelete") message = _!"Subscription undeleted."; else if (subscriptionExists(postVars.get("id", null))) message = _!"Subscription saved."; else message = _!"Subscription created."; auto subscription = Subscription(user.getName(), postVars); try { subscription.save(); html.put(`
    `, message, `
    `); } catch (Exception e) { html.put(`
    `), html.putEncodedEntities(e.msg), html.put(`
    `); return discussionSubscriptionEdit(subscription); } } else if (action.skipOver("subscription-delete-")) { auto subscriptionID = action; enforce(subscriptionExists(subscriptionID), _!"This subscription doesn't exist."); html.put( `
    `, _!`Subscription deleted.`, ` ` ~ `` ~ `
    ` ~ `
    ` ); // Replicate the entire edit form here (but make it invisible), // so that saving the subscription recreates it on the server. discussionSubscriptionEdit(getUserSubscription(user.getName(), subscriptionID)); html.put( `
    ` ); getUserSubscription(user.getName(), subscriptionID).remove(); } else if (action == "subscription-create-content") return discussionSubscriptionEdit(createSubscription(user.getName(), "content")); else throw new Exception(_!"Unknown action:" ~ " " ~ action); } html.put( `
    ` ~ `

    `, _!`Settings`, `

    ` ~ `` ~ `` ~ `

    `, _!`User Interface`, `

    `, _!`Language:`, `
    ` ~ _!`View mode:`, `
    ` ~ `` ~ ` (?)
    ` ~ `` ~ `` ~ `` ~ `
    `); if (haveMarkdown) html.put( `` ~ `` ~ `` ~ ` (?)
    `); html.put( `

    ` ~ `` ~ `` ~ `

    ` ~ `
    ` ~ `

    `, _!`Subscriptions`, `

    ` ); if (user.isLoggedIn()) { auto subscriptions = getUserSubscriptions(user.getName()); if (subscriptions.length) { html.put(``); html.put(``); foreach (subscription; subscriptions) { html.put( `` ~ `` ~ `` ~ `` ~ `` ~ `` ~ `` ); } html.put( `
    `, _!`Subscription`, ``, _!`Actions`, `
    `), subscription.trigger.putDescription(html), html.put(`
    ` ); } else html.put(`

    `, _!`You have no subscriptions.`, `

    `); html.put( `

    ` ); } else html.put(`

    `, _!`Please %slog in%s to manage your subscriptions and account settings.`.format(``, ``), `

    `); html.put( `
    ` ~ `
    ` ~ `` ~ `` ~ `
    ` ); if (user.isLoggedIn()) { html.put( `
    ` ~ `

    `, _!`Account settings`, `

    ` ~ `` ~ `` ~ `` ~ `` ~ `
    `, _!`Change the password used to log in to this account.` , `
    `, _!`Download a file containing all data tied to this account.` , `
    `, _!`Permanently delete this account.` , `
    ` ); } } void discussionSubscriptionEdit(Subscription subscription) { html.put( `
    ` ~ `

    `, _!`Edit subscription`, `

    ` ~ `` ~ `` ~ `` ~ `

    `, _!`Condition`, `

    ` ~ `` ); subscription.trigger.putEditHTML(html); html.put( `

    `, _!`Actions`, `

    ` ); foreach (action; subscription.actions) action.putEditHTML(html); html.put( `

    ` ~ `` ~ `` ~ `

    ` ~ `
    ` ); } void discussionChangePassword(UrlParameters postVars) { enforce(user.isLoggedIn(), _!"This action is only meaningful for logged-in users."); html.put(`

    `, _!`Change password`, `

    `); if ("old-password" !in postVars) { html.put( `

    `, _!`Here you can change the password used to log in to this %s account.`.format(encodeHtmlEntities(site.name)), `

    ` ~ `

    `, _!`Please pick your new password carefully, as there are no password recovery options.`, `

    ` ~ `
    ` ~ `` ~ `` ~ `` ~ `` ~ `
    `, _!`Current password:` , `
    `, _!`New password:` , `
    `, _!`New password (confirm):`, `
    ` ~ `` ~ `` ~ `
    ` ); } else { if (postVars.get("secret", "") != userSettings.secret) throw new Exception(_!"XSRF secret verification failed"); user.checkPassword(postVars.aaGet("old-password")).enforce(_!"The current password you entered is incorrect"); enforce(postVars.aaGet("new-password") == postVars.aaGet("new-password-2"), _!"New passwords do not match"); user.changePassword(postVars.aaGet("new-password")); html.put( `

    `, _!`Password successfully changed.`, `

    ` ); } } JSONFragment discussionExportAccount(UrlParameters postVars) { enforce(user.isLoggedIn(), _!"This action is only meaningful for logged-in users."); html.put(`

    `, _!`Export account data`, `

    `); if ("do-export" !in postVars) { html.put( `

    `, _!`Here you can export the information regarding your account from the %s database.`.format(encodeHtmlEntities(site.name)), `

    ` ~ `
    ` ~ `` ~ `` ~ `
    ` ); return JSONFragment.init; } else { if (postVars.get("secret", "") != userSettings.secret) throw new Exception(_!"XSRF secret verification failed"); auto data = user.exportData; return data.toJson.JSONFragment; } } void discussionDeleteAccount(UrlParameters postVars) { enforce(user.isLoggedIn(), _!"This action is only meaningful for logged-in users."); html.put(`

    `, _!`Delete account`, `

    `); if ("username" !in postVars) { html.put( `

    `, _!`Here you can permanently delete your %s account and associated data from the database.`.format(encodeHtmlEntities(site.name)), `

    ` ~ `

    `, _!`After deletion, the account username will become available for registration again.`, `

    ` ~ `

    `, _!`To confirm deletion, please enter your account username and password.`, `

    ` ~ `
    ` ~ `` ~ `` ~ `` ~ `
    `, _!`Account username:`, `
    `, _!`Account password:`, `
    ` ~ `` ~ `` ~ `
    ` ); } else { if (postVars.get("secret", "") != userSettings.secret) throw new Exception(_!"XSRF secret verification failed"); enforce(postVars.aaGet("username") == user.getName(), _!"The username you entered does not match the current logged-in account"); user.checkPassword(postVars.aaGet("password")).enforce(_!"The password you entered is incorrect"); user.deleteAccount(); html.put( `

    `, _!`Account successfully deleted!`, `

    ` ); } } ================================================ FILE: src/dfeed/web/web/view/subscription.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// User subscriptions. module dfeed.web.web.view.subscription; import std.conv : text; import std.format; import ae.utils.xmllite : putEncodedEntities; import dfeed.loc; import dfeed.database : query, selectValue; import dfeed.sinks.subscriptions; import dfeed.web.web.page : html; import dfeed.web.web.part.pager : POSTS_PER_PAGE, pager, getPageCount; import dfeed.web.web.part.post : formatPost; import dfeed.web.web.postinfo : getPost; import dfeed.web.web.user; void discussionSubscriptionPosts(string subscriptionID, int page, out string title) { auto subscription = getUserSubscription(user.getName(), subscriptionID); title = _!"View subscription:" ~ " " ~ subscription.trigger.getTextDescription(); enum postsPerPage = POSTS_PER_PAGE; html.put(`

    `); html.putEncodedEntities(title); if (page != 1) html.put(" ", _!"(page %d)".format(page)); html.put("

    "); auto postCount = query!"SELECT COUNT(*) FROM [SubscriptionPosts] WHERE [SubscriptionID] = ?".iterate(subscriptionID).selectValue!int; if (postCount == 0) { html.put(`

    `, _!`It looks like there's nothing here! No posts matched this subscription so far.`, `

    `); } foreach (string messageID; query!"SELECT [MessageID] FROM [SubscriptionPosts] WHERE [SubscriptionID] = ? ORDER BY [Time] DESC LIMIT ? OFFSET ?" .iterate(subscriptionID, postsPerPage, (page-1)*postsPerPage)) { auto post = getPost(messageID); if (post) formatPost(post, null); else query!"DELETE FROM [SubscriptionPosts] WHERE [SubscriptionID] = ? AND [MessageID] = ?".exec(subscriptionID, messageID); } if (page != 1 || postCount > postsPerPage) { html.put(``); pager(null, page, getPageCount(postCount, postsPerPage)); html.put(`
    `); } html.put( `
    ` ~ `` ~ `` ~ `
    ` ~ `
    ` ); } void discussionSubscriptionUnsubscribe(string subscriptionID) { auto subscription = getSubscription(subscriptionID); subscription.unsubscribe(); html.put( `

    `, _!`Unsubscribe`, `

    ` ~ `

    `, _!`This subscription has been deactivated.`, `

    ` ~ `

    `, _!`If you did not intend to do this, you can reactivate the subscription's actions on your %ssettings page%s.`.format(``, ``), `

    ` ); } ================================================ FILE: src/dfeed/web/web/view/thread.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Formatting threads. module dfeed.web.web.view.thread; import std.conv : text; import std.datetime.systime : SysTime; import std.exception : enforce; import std.format; import ae.utils.xmllite : putEncodedEntities; import dfeed.loc; import dfeed.database : query; import dfeed.groups : GroupInfo; import dfeed.message : idToUrl, Rfc850Post, getGroup; import dfeed.web.web.page : html, NotFoundException; import dfeed.web.web.part.pager : pager, getPageCount, POSTS_PER_PAGE; import dfeed.web.web.part.post : formatPost; import dfeed.web.web.part.thread; import dfeed.web.web.postinfo : PostInfo, getPostInfo; import dfeed.web.web.user : user; // *********************************************************************** void postPager(string threadID, int page, int postCount) { pager(idToUrl(threadID, "thread"), page, getPageCount(postCount, POSTS_PER_PAGE)); } int getPostCount(string threadID) { foreach (int count; query!"SELECT COUNT(*) FROM `Posts` WHERE `ThreadID` = ?".iterate(threadID)) return count; assert(0); } int getPostThreadIndex(string threadID, SysTime postTime) { foreach (int index; query!"SELECT COUNT(*) FROM `Posts` WHERE `ThreadID` = ? AND `Time` < ? ORDER BY `Time` ASC".iterate(threadID, postTime.stdTime)) return index; assert(0); } int getPostThreadIndex(string postID) { auto post = getPostInfo(postID); enforce(post, _!"No such post:" ~ " " ~ postID); return getPostThreadIndex(post.threadID, post.time); } string getPostAtThreadIndex(string threadID, int index) { foreach (string id; query!"SELECT `ID` FROM `Posts` WHERE `ThreadID` = ? ORDER BY `Time` ASC LIMIT 1 OFFSET ?".iterate(threadID, index)) return id; throw new NotFoundException(format(_!"Post #%d of thread %s not found", index, threadID)); } void discussionThread(string id, int page, out GroupInfo groupInfo, out string title, out string authorEmail, bool markAsRead) { enforce(page >= 1, _!"Invalid page"); auto postCount = getPostCount(id); if (page == 1 && postCount > 2) { // Expandable overview html.put( `` ~ ``, `
    `); auto pageCount = getPageCount(postCount, POSTS_PER_PAGE); if (pageCount > 1) { html.put( `
    `, _!`Jump to page:`, ` 1 ` ); auto threadUrl = idToUrl(id, "thread"); void pageLink(int n) { auto nStr = text(n); html.put(``, nStr, ` `); } if (pageCount < 4) { foreach (p; 2..pageCount+1) pageLink(p); } else { pageLink(2); html.put(`… `); pageLink(pageCount); html.put( ` ` ~ `
    ` ~ `
    ` ~ _!`Page`, ` ` ~ `
    ` ~ `
    ` ); } html.put( `
    ` ); } html.put( ``, _!`Thread overview`, `` ~ `
    `); formatThreadedPosts(getThreadPosts(id), false); html.put(`
    `); } Rfc850Post[] posts; foreach (int rowid, string postID, string message; query!"SELECT `ROWID`, `ID`, `Message` FROM `Posts` WHERE `ThreadID` = ? ORDER BY `Time` ASC LIMIT ? OFFSET ?" .iterate(id, POSTS_PER_PAGE, (page-1)*POSTS_PER_PAGE)) posts ~= new Rfc850Post(message, postID, rowid, id); Rfc850Post[string] knownPosts; foreach (post; posts) knownPosts[post.id] = post; enforce(posts.length, _!"Thread not found"); groupInfo = posts[0].getGroup(); title = posts[0].subject; authorEmail = posts[0].authorEmail; html.put(`
    `); foreach (post; posts) formatPost(post, knownPosts, markAsRead); html.put(`
    `); if (page > 1 || postCount > POSTS_PER_PAGE) { html.put(``); postPager(id, page, postCount); html.put(`
    `); } } string discussionFirstUnread(string threadID) { foreach (int rowid, string id; query!"SELECT `ROWID`, `ID` FROM `Posts` WHERE `ThreadID` = ? ORDER BY `Time` ASC".iterate(threadID)) if (!user.isRead(rowid)) return idToUrl(id); auto numPages = getPageCount(getPostCount(threadID), POSTS_PER_PAGE); enforce(numPages, _!"Thread not found"); return idToUrl(threadID, "thread", numPages); } ================================================ FILE: src/dfeed/web/web/view/userprofile.d ================================================ /* Copyright (C) 2025 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// User profile view. module dfeed.web.web.view.userprofile; import std.algorithm.sorting : sort; import std.datetime.systime : SysTime; import std.datetime.timezone : UTC; import std.format : format; import ae.net.ietf.url : encodeUrlParameter; import ae.utils.text.html : encodeHtmlEntities; import dfeed.database : query, selectValue; import dfeed.groups : getGroupInfo; import dfeed.loc; import dfeed.message : idToUrl; import dfeed.web.web.page : html, NotFoundException; import dfeed.web.web.part.gravatar : getGravatarHash, putGravatar; import dfeed.web.web.part.profile : getProfileHash, profileUrl; import dfeed.web.web.part.strings : summarizeTime, formatShortTime; import dfeed.web.web.statics : staticPath; import dfeed.web.web.user : user; /// Look up author name and email from a profile hash. /// Returns null if not found. string[2] lookupAuthorByHash(string profileHash) { // Iterate through distinct (Author, AuthorEmail) pairs and find matching hash. // This is O(n) in the number of unique authors, but fast in practice // since hash computation is cheap and we stop at first match. foreach (string author, string email; query!"SELECT DISTINCT [Author], [AuthorEmail] FROM [Posts]".iterate()) { if (getProfileHash(author, email) == profileHash) return [author, email]; } return [null, null]; } /// Display user profile page. void discussionUserProfile(string profileHash, out string title, out string author) { auto authorInfo = lookupAuthorByHash(profileHash); author = authorInfo[0]; string authorEmail = authorInfo[1]; if (author is null) throw new NotFoundException(_!"User not found"); title = author; // Get post count and time range int postCount; long firstPostTime, lastPostTime; foreach (int count, long minTime, long maxTime; query!"SELECT COUNT(*), MIN([Time]), MAX([Time]) FROM [Posts] WHERE [Author] = ? AND [AuthorEmail] = ?" .iterate(author, authorEmail)) { postCount = count; firstPostTime = minTime; lastPostTime = maxTime; } // Get thread vs reply breakdown int threadCount = query!"SELECT COUNT(*) FROM [Posts] WHERE [Author] = ? AND [AuthorEmail] = ? AND ([ParentID] IS NULL OR [ParentID] = '')" .iterate(author, authorEmail) .selectValue!int; int replyCount = postCount - threadCount; // Get most active group string mostActiveGroup; int mostActiveGroupCount; foreach (string grp, int cnt; query!"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" .iterate(author, authorEmail)) { mostActiveGroup = grp; mostActiveGroupCount = cnt; } string gravatarHash = getGravatarHash(authorEmail); // Profile header html.put(``); // user-profile } ================================================ FILE: src/dfeed/web/web/view/widgets.d ================================================ /* Copyright (C) 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2020, 2022 Vladimir Panteleev * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ /// Rendering of iframe widgets. module dfeed.web.web.view.widgets; import std.algorithm.searching; import std.format : format; import ae.utils.xmllite : putEncodedEntities; import dfeed.loc; import dfeed.sinks.cache; import dfeed.web.web.page : html; import dfeed.web.web.perf; import dfeed.web.web.part.gravatar : getGravatarHash, putGravatar; import dfeed.web.web.part.profile : profileUrl; import dfeed.web.web.part.strings : summarizeTime; import dfeed.web.web.postinfo : PostInfo, getPostInfo; import dfeed.web.web.statics; import dfeed.database; import dfeed.message; import dfeed.web.web.config : config; import dfeed.web.web.user : user; Cached!(ActiveDiscussion[]) activeDiscussionsCache; Cached!(string[]) latestAnnouncementsCache; enum framePostsLimit = 10; static struct ActiveDiscussion { string id; int postCount; } ActiveDiscussion[] getActiveDiscussions() { enum PERF_SCOPE = "getActiveDiscussions"; mixin(MeasurePerformanceMixin); enum postCountLimit = 10; ActiveDiscussion[] result; foreach (string group, string firstPostID; query!"SELECT [Group], [ID] FROM [Threads] ORDER BY [Created] DESC LIMIT 100".iterate()) { if (config.activeDiscussionExclude.canFind(group)) continue; int postCount; foreach (int count; query!"SELECT COUNT(*) FROM `Posts` WHERE `ThreadID` = ?".iterate(firstPostID)) postCount = count; if (postCount < postCountLimit) continue; result ~= ActiveDiscussion(firstPostID, postCount); if (result.length == framePostsLimit) break; } return result; } string[] getLatestAnnouncements() { enum PERF_SCOPE = "getLatestAnnouncements"; mixin(MeasurePerformanceMixin); if (!config.announceGroup.length) return null; string[] result; foreach (string firstPostID; query!"SELECT [ID] FROM [Threads] WHERE [Group] = ? ORDER BY [RowID] DESC LIMIT ?".iterate(config.announceGroup, framePostsLimit)) result ~= firstPostID; return result; } void summarizeFrameThread(PostInfo* info, string infoText) { if (info) with (*info) { putGravatar(getGravatarHash(info.authorEmail), author, profileUrl(author, authorEmail), _!`%s's profile`.format(author), `target="_top" class="forum-postsummary-gravatar" `); html.put( ``), html.putEncodedEntities(subject), html.put(`
    ` ~ `
    `, infoText, `
    `, _!`by`, ` `), html.putEncodedEntities(author), html.put(`` ); return; } html.put(`
    -
    `); } void discussionFrameAnnouncements() { if (!config.announceGroup.length) { html.put(`
    `, _!`Announcements widget not configured`, `
    `); return; } auto latestAnnouncements = latestAnnouncementsCache(getLatestAnnouncements()); html.put(``); foreach (row; latestAnnouncements) if (auto info = getPostInfo(row)) html.put(``); html.put(`
    ` ~ `` ~ ``, _!`Latest announcements`, `` ~ `
    `), summarizeFrameThread(info, summarizeTime(info.time)), html.put(`
    `); } void discussionFrameDiscussions() { auto activeDiscussions = activeDiscussionsCache(getActiveDiscussions()); html.put(``); foreach (row; activeDiscussions) if (auto info = getPostInfo(row.id)) html.put(``); html.put(`
    `, _!`Active discussions`, `
    `), summarizeFrameThread(info, "%d posts".format(row.postCount)), html.put(`
    `); } ================================================ FILE: tests/captcha-screenshot.spec.ts ================================================ import { test, expect } from "@playwright/test"; import { execSync } from "child_process"; import path from "path"; const PROJECT_ROOT = path.resolve(__dirname, ".."); const DB_PATH = process.env.DFEED_DB || path.join(PROJECT_ROOT, "data/db/dfeed.s3db"); test("capture moderator journey view", { timeout: 30000 }, async ({ page, context, baseURL }) => { const timestamp = Date.now(); const modUsername = `mod${timestamp}`; // Step 1: Register a moderator user first await page.goto("/registerform"); await page.fill("#loginform-username", modUsername); await page.fill("#loginform-password", "testpass123"); await page.fill("#loginform-password2", "testpass123"); await page.click('input[type="submit"]'); await page.waitForURL("**/"); console.log(`Registered moderator user: ${modUsername}`); // Step 2: Update user to full moderator level (100) via SQL // Level 90 = canApproveDrafts, Level 100 = canModerate (can access /moderate/ page) const sqlCmd = `UPDATE Users SET Level=100 WHERE Username='${modUsername}';`; console.log(`Running SQL: ${sqlCmd}`); execSync(`sqlite3 "${DB_PATH}" "${sqlCmd}"`); console.log("User promoted to moderator"); // Step 3: Create a moderated draft as anonymous user // Clear cookies to act as new anonymous user await context.clearCookies(); await page.goto("/newpost/test"); await expect(page.locator("#postform")).toBeVisible(); // Fill form with hardspamtest (triggers CAPTCHA AND moderation) await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", `hardspamtest ${timestamp}`); await page.fill("#postform-text", "Testing CAPTCHA question logging and moderation journey"); // Submit to trigger CAPTCHA await page.click('input[name="action-send"]'); // Wait for CAPTCHA const captchaCheckbox = page.locator('input[name="dummy_captcha_checkbox"]'); await expect(captchaCheckbox).toBeVisible(); // Capture the draft ID (did) from the hidden form field before solving CAPTCHA const draftId = await page.locator('input[name="did"]').inputValue(); console.log(`Draft ID (did): ${draftId}`); // Screenshot 1: CAPTCHA challenge await page.screenshot({ path: path.join(__dirname, "screenshot-captcha-1-challenge.png"), fullPage: true }); // Solve CAPTCHA await captchaCheckbox.check(); await page.click('input[name="action-send"]'); // Wait for moderation notice await expect(page.locator("body")).toContainText("approved by a moderator", { timeout: 10000 }); // Screenshot 2: Moderation notice await page.screenshot({ path: path.join(__dirname, "screenshot-captcha-2-moderation-notice.png"), fullPage: true }); // Step 4: Log in as moderator await page.goto("/loginform"); await page.fill("#loginform-username", modUsername); await page.fill("#loginform-password", "testpass123"); await page.click('input[type="submit"]'); await page.waitForURL("**/"); // Step 5: Navigate to the moderation approval page and approve the post if (draftId) { await page.goto(`/approve-moderated-draft/${draftId}`); await page.waitForTimeout(500); // Screenshot 3: Approval confirmation page await page.screenshot({ path: path.join(__dirname, "screenshot-captcha-3-approval-page.png"), fullPage: true }); // Click Approve button await page.click('input[name="approve"]'); await page.waitForTimeout(1000); // Screenshot 4: Post approved confirmation await page.screenshot({ path: path.join(__dirname, "screenshot-captcha-4-post-approved.png"), fullPage: true }); // Extract the posting link to get the message ID const viewLink = await page.locator('a:has-text("View posting")').getAttribute('href'); console.log(`View posting link: ${viewLink}`); if (viewLink) { // Get the post ID from /posting/ const postIdMatch = viewLink.match(/posting\/([a-z]+)/); if (postIdMatch) { const postId = postIdMatch[1]; // The message ID format is const encodedMessageId = encodeURIComponent(`${postId}@localhost`); // Navigate to the moderation page to see the journey view await page.goto(`/moderate/${encodedMessageId}`); await page.waitForTimeout(500); // Screenshot 5: User Journey view with CAPTCHA question/answer await page.screenshot({ path: path.join(__dirname, "screenshot-captcha-5-journey-view.png"), fullPage: true }); console.log("Captured user journey view with CAPTCHA question/answer"); } } } }); ================================================ FILE: tests/deleted-post-moderation.spec.ts ================================================ import { test, expect } from "@playwright/test"; import { execSync } from "child_process"; import path from "path"; const PROJECT_ROOT = path.resolve(__dirname, ".."); const DB_PATH = process.env.DFEED_DB || path.join(PROJECT_ROOT, "data/db/dfeed.s3db"); test.describe("Deleted Post Moderation", () => { test("shows user journey and deletion record for deleted post", async ({ page, context }) => { const timestamp = Date.now(); const modUsername = `mod${timestamp}`; const testSubject = `Delete Test ${timestamp}`; const testBody = `This post will be deleted ${timestamp}`; // Step 1: Register a moderator user await page.goto("/registerform"); await page.fill("#loginform-username", modUsername); await page.fill("#loginform-password", "testpass123"); await page.fill("#loginform-password2", "testpass123"); await page.click('input[type="submit"]'); await page.waitForURL("**/"); // Promote user to moderator level (100) const sqlCmd = `UPDATE Users SET Level=100 WHERE Username='${modUsername}';`; execSync(`sqlite3 "${DB_PATH}" "${sqlCmd}"`); // Step 2: Create a post that triggers CAPTCHA (so we have journey data) await context.clearCookies(); await page.goto("/newpost/test"); await expect(page.locator("#postform")).toBeVisible(); // Use "spamtest" to trigger CAPTCHA but NOT hard moderation await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", `spamtest ${testSubject}`); await page.fill("#postform-text", testBody); // Submit to trigger CAPTCHA await page.click('input[name="action-send"]'); // Wait for CAPTCHA and solve it const captchaCheckbox = page.locator('input[name="dummy_captcha_checkbox"]'); await expect(captchaCheckbox).toBeVisible(); await captchaCheckbox.check(); await page.click('input[name="action-send"]'); // Wait for redirect to the posted thread await expect(page).toHaveURL(/\/(thread|post)\//, { timeout: 10000 }); // Extract the post ID from the URL const url = page.url(); const postIdMatch = url.match(/\/(thread|post)\/([a-z]+)/); expect(postIdMatch).toBeTruthy(); const postId = postIdMatch![2]; const encodedMessageId = encodeURIComponent(`${postId}@localhost`); // Step 3: Log in as moderator await page.goto("/loginform"); await page.fill("#loginform-username", modUsername); await page.fill("#loginform-password", "testpass123"); await page.click('input[type="submit"]'); await page.waitForURL("**/"); // Step 4: Go to moderation page and delete the post await page.goto(`/moderate/${encodedMessageId}`); // Verify the post content is shown (before deletion) await expect(page.locator("#deleteform-message")).toBeVisible(); await expect(page.locator("#deleteform-message")).toContainText(testBody); // Verify user journey is shown before deletion const journeyBeforeDeletion = page.locator(".journey-timeline"); await expect(journeyBeforeDeletion).toBeVisible(); // Delete the post (only local copy, don't ban) await page.check("#deleteform-delete"); await page.fill('input[name="reason"]', "test deletion"); await page.click('input[type="submit"]'); // Verify deletion confirmation await expect(page.locator("body")).toContainText("Post deleted"); // Step 5: Visit the moderation page again for the now-deleted post await page.goto(`/moderate/${encodedMessageId}`); // Verify "not in database" notice is shown await expect(page.locator(".forum-notice")).toContainText("not in the database"); // Verify User Journey section still exists (from PostProcess logs) const journeySection = page.locator(".journey-timeline"); await expect(journeySection).toBeVisible(); // Verify CAPTCHA events are still visible in journey const captchaQuestion = journeySection.locator(".journey-event", { has: page.locator(".journey-message", { hasText: "CAPTCHA question" }) }); await expect(captchaQuestion.first()).toBeVisible(); // Verify Deletion Record section is shown await expect(page.locator("body")).toContainText("Deletion Record"); // Verify deletion metadata await expect(page.locator("body")).toContainText("Deleted by:"); await expect(page.locator("body")).toContainText(modUsername); await expect(page.locator("body")).toContainText("Reason:"); await expect(page.locator("body")).toContainText("test deletion"); // Verify original message content is preserved await expect(page.locator("#deleteform-message")).toBeVisible(); await expect(page.locator("#deleteform-message")).toContainText(testBody); }); test("shows appropriate message for non-existent post with no logs", async ({ page, context }) => { const timestamp = Date.now(); const modUsername = `mod${timestamp}`; // Register and promote to moderator await page.goto("/registerform"); await page.fill("#loginform-username", modUsername); await page.fill("#loginform-password", "testpass123"); await page.fill("#loginform-password2", "testpass123"); await page.click('input[type="submit"]'); await page.waitForURL("**/"); const sqlCmd = `UPDATE Users SET Level=100 WHERE Username='${modUsername}';`; execSync(`sqlite3 "${DB_PATH}" "${sqlCmd}"`); // Log in as moderator await page.goto("/loginform"); await page.fill("#loginform-username", modUsername); await page.fill("#loginform-password", "testpass123"); await page.click('input[type="submit"]'); await page.waitForURL("**/"); // Visit moderation page for a non-existent message ID const fakeMessageId = encodeURIComponent(`nonexistent${timestamp}@localhost`); await page.goto(`/moderate/${fakeMessageId}`); // Should show "not in database" notice await expect(page.locator(".forum-notice")).toContainText("not in the database"); // Should NOT show journey timeline (no logs exist) await expect(page.locator(".journey-timeline")).not.toBeVisible(); // Should NOT show deletion record (never existed) await expect(page.locator("body")).not.toContainText("Deletion Record"); }); }); ================================================ FILE: tests/deleted-post-screenshot.spec.ts ================================================ import { test, expect } from "@playwright/test"; import { execSync } from "child_process"; import path from "path"; const PROJECT_ROOT = path.resolve(__dirname, ".."); const DB_PATH = process.env.DFEED_DB || path.join(PROJECT_ROOT, "data/db/dfeed.s3db"); test("capture deleted post moderation view", { timeout: 60000 }, async ({ page, context }) => { const timestamp = Date.now(); const modUsername = `mod${timestamp}`; // Step 1: Register a moderator user await page.goto("/registerform"); await page.fill("#loginform-username", modUsername); await page.fill("#loginform-password", "testpass123"); await page.fill("#loginform-password2", "testpass123"); await page.click('input[type="submit"]'); await page.waitForURL("**/"); console.log(`Registered moderator user: ${modUsername}`); // Promote to moderator level const sqlCmd = `UPDATE Users SET Level=100 WHERE Username='${modUsername}';`; execSync(`sqlite3 "${DB_PATH}" "${sqlCmd}"`); console.log("User promoted to moderator"); // Step 2: Create a post as anonymous user (with CAPTCHA to generate journey logs) await context.clearCookies(); await page.goto("/newpost/test"); await expect(page.locator("#postform")).toBeVisible(); // Use "spamtest" to trigger CAPTCHA but NOT hard moderation (so post goes through) await page.fill("#postform-name", "Test Spammer"); await page.fill("#postform-email", "spammer@example.com"); await page.fill("#postform-subject", `spamtest Delete Demo ${timestamp}`); 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."); // Submit to trigger CAPTCHA await page.click('input[name="action-send"]'); // Solve CAPTCHA const captchaCheckbox = page.locator('input[name="dummy_captcha_checkbox"]'); await expect(captchaCheckbox).toBeVisible(); await captchaCheckbox.check(); await page.click('input[name="action-send"]'); // Wait for redirect to the posted thread await expect(page).toHaveURL(/\/(thread|post)\//, { timeout: 10000 }); // Extract the post ID from the URL const url = page.url(); const postIdMatch = url.match(/\/(thread|post)\/([a-z]+)/); expect(postIdMatch).toBeTruthy(); const postId = postIdMatch![2]; const encodedMessageId = encodeURIComponent(`${postId}@localhost`); console.log(`Created post with ID: ${postId}`); // Step 3: Log in as moderator await page.goto("/loginform"); await page.fill("#loginform-username", modUsername); await page.fill("#loginform-password", "testpass123"); await page.click('input[type="submit"]'); await page.waitForURL("**/"); // Step 4: Go to moderation page and take screenshot before deletion await page.goto(`/moderate/${encodedMessageId}`); await page.waitForTimeout(500); // Screenshot 1: Moderation page before deletion await page.screenshot({ path: path.join(__dirname, "screenshot-deleted-1-before.png"), fullPage: true }); console.log("Screenshot 1: Moderation page before deletion"); // Step 5: Delete the post await page.check("#deleteform-delete"); await page.fill('input[name="reason"]', "spam demo"); await page.click('input[type="submit"]'); // Wait for deletion confirmation await expect(page.locator("body")).toContainText("Post deleted"); await page.waitForTimeout(500); // Screenshot 2: Deletion confirmation await page.screenshot({ path: path.join(__dirname, "screenshot-deleted-2-confirmation.png"), fullPage: true }); console.log("Screenshot 2: Deletion confirmation"); // Step 6: Visit the moderation page again for the deleted post await page.goto(`/moderate/${encodedMessageId}`); await page.waitForTimeout(500); // Screenshot 3: Deleted post moderation view with journey and deletion record await page.screenshot({ path: path.join(__dirname, "screenshot-deleted-3-after.png"), fullPage: true }); console.log("Screenshot 3: Deleted post moderation view"); // Verify the key elements are present await expect(page.locator(".forum-notice")).toContainText("not in the database"); await expect(page.locator(".journey-timeline")).toBeVisible(); await expect(page.locator("body")).toContainText("Deletion Record"); await expect(page.locator("body")).toContainText(`Deleted by:`); await expect(page.locator("body")).toContainText(modUsername); }); ================================================ FILE: tests/index.spec.ts ================================================ import { test, expect } from '@playwright/test'; test('index page loads successfully', async ({ page }) => { const response = await page.goto('/'); // Verify the page loads with a successful status expect(response?.status()).toBe(200); // Verify we're on a DFeed instance by checking for expected content await expect(page.locator('body')).toBeVisible(); }); ================================================ FILE: tests/package.json ================================================ { "name": "dfeed-e2e-tests", "version": "1.0.0", "description": "End-to-end tests for DFeed using Playwright", "scripts": { "test": "playwright test" }, "devDependencies": { "@playwright/test": "^1.40.0" } } ================================================ FILE: tests/playwright.config.ts ================================================ import { defineConfig, devices } from '@playwright/test'; export default defineConfig({ testDir: '.', timeout: 30000, retries: 0, use: { baseURL: process.env.DFEED_URL || 'http://localhost:8080', trace: 'on-first-retry', }, projects: [ { name: 'default', testIgnore: /.*-screenshot\.spec\.ts$/, use: { ...devices['Desktop Firefox'], }, }, { name: 'screenshots', testMatch: /.*-screenshot\.spec\.ts$/, use: { ...devices['Desktop Firefox'], }, }, ], }); ================================================ FILE: tests/posting.spec.ts ================================================ import { test, expect } from "@playwright/test"; test.describe("Posting", () => { test("can create a new thread in local group", async ({ page }) => { // Generate unique identifiers for this test const timestamp = Date.now(); const testSubject = `Test Thread ${timestamp}`; const testBody = `This is a test message body created at ${timestamp}`; const testName = "Test User"; const testEmail = "test@example.com"; // Navigate to the test group await page.goto("/group/test"); await expect(page.locator("body")).toBeVisible(); // Click "Create thread" button await page.click( 'input[value="Create thread"], input[alt="Create thread"]' ); // Wait for the new post form await expect(page.locator("#postform")).toBeVisible(); // Fill in the form await page.fill("#postform-name", testName); await page.fill("#postform-email", testEmail); await page.fill("#postform-subject", testSubject); await page.fill("#postform-text", testBody); // Submit the form await page.click('input[name="action-send"]'); // Wait for redirect to the posted thread await expect(page).toHaveURL(/\/(thread|post)\//); // Verify the post content is visible await expect(page.locator("body")).toContainText(testSubject); await expect(page.locator("body")).toContainText(testBody); }); test("thread appears in group listing after posting", async ({ page }) => { // Generate unique identifiers for this test const timestamp = Date.now(); const testSubject = `Listing Test ${timestamp}`; const testBody = `Message for listing test ${timestamp}`; // Create a new thread await page.goto("/newpost/test"); await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", testSubject); await page.fill("#postform-text", testBody); await page.click('input[name="action-send"]'); // Wait for posting to complete await expect(page).toHaveURL(/\/(thread|post)\//); // Navigate to group listing await page.goto("/group/test"); // Verify the new thread appears in the listing await expect(page.locator("body")).toContainText(testSubject); }); test("posting form renders correctly", async ({ page }) => { // Navigate to new post form await page.goto("/newpost/test"); // Verify form elements are present await expect(page.locator("#postform")).toBeVisible(); await expect(page.locator("#postform-name")).toBeVisible(); await expect(page.locator("#postform-email")).toBeVisible(); await expect(page.locator("#postform-subject")).toBeVisible(); await expect(page.locator("#postform-text")).toBeVisible(); await expect(page.locator('input[name="action-send"]')).toBeVisible(); await expect(page.locator('input[name="action-save"]')).toBeVisible(); }); test("can preview post before sending", async ({ page }) => { const timestamp = Date.now(); const testSubject = `Preview Test ${timestamp}`; const testBody = `Preview message body ${timestamp}`; await page.goto("/newpost/test"); // Fill in the form await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", testSubject); await page.fill("#postform-text", testBody); // Click "Save and preview" button await page.click('input[name="action-save"]'); // Verify preview is shown - the page should show the message content await expect(page.locator("body")).toContainText(testBody); // Form should still be visible for editing await expect(page.locator("#postform")).toBeVisible(); }); test("spam detection triggers CAPTCHA challenge", async ({ page }) => { const timestamp = Date.now(); // "spamtest" in subject triggers SimpleChecker's spam detection const testSubject = `spamtest ${timestamp}`; const testBody = `Testing CAPTCHA flow ${timestamp}`; await page.goto("/newpost/test"); // Fill in the form with spam-triggering subject await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", testSubject); await page.fill("#postform-text", testBody); // Submit the form await page.click('input[name="action-send"]'); // Should be challenged with CAPTCHA (dummy checkbox) // The page should show the CAPTCHA checkbox const captchaCheckbox = page.locator('input[name="dummy_captcha_checkbox"]'); await expect(captchaCheckbox).toBeVisible(); // Should show "I am not a robot" text await expect(page.locator("body")).toContainText("I am not a robot"); // Form should still be visible with our data preserved await expect(page.locator("#postform")).toBeVisible(); await expect(page.locator("#postform-subject")).toHaveValue(testSubject); }); test("solving CAPTCHA allows post submission", async ({ page }) => { const timestamp = Date.now(); // "spamtest" in subject triggers SimpleChecker's spam detection const testSubject = `spamtest solved ${timestamp}`; const testBody = `Testing CAPTCHA solution ${timestamp}`; await page.goto("/newpost/test"); // Fill in the form with spam-triggering subject await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", testSubject); await page.fill("#postform-text", testBody); // Submit the form - should trigger CAPTCHA await page.click('input[name="action-send"]'); // Wait for CAPTCHA checkbox to appear const captchaCheckbox = page.locator('input[name="dummy_captcha_checkbox"]'); await expect(captchaCheckbox).toBeVisible(); // Check the CAPTCHA checkbox await captchaCheckbox.check(); // Submit again with CAPTCHA solved await page.click('input[name="action-send"]'); // Should redirect to the posted thread await expect(page).toHaveURL(/\/(thread|post)\//); // Verify the post content is visible await expect(page.locator("body")).toContainText(testSubject); await expect(page.locator("body")).toContainText(testBody); }); test("CAPTCHA-solved post appears in group listing", async ({ page }) => { const timestamp = Date.now(); const testSubject = `spamtest listing ${timestamp}`; const testBody = `CAPTCHA listing test ${timestamp}`; await page.goto("/newpost/test"); // Fill in and submit with spam-triggering subject await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", testSubject); await page.fill("#postform-text", testBody); await page.click('input[name="action-send"]'); // Solve CAPTCHA const captchaCheckbox = page.locator('input[name="dummy_captcha_checkbox"]'); await expect(captchaCheckbox).toBeVisible(); await captchaCheckbox.check(); await page.click('input[name="action-send"]'); // Wait for posting to complete await expect(page).toHaveURL(/\/(thread|post)\//); // Navigate to group listing await page.goto("/group/test"); // Verify the new thread appears in the listing await expect(page.locator("body")).toContainText(testSubject); }); test("hard spam detection triggers CAPTCHA challenge", async ({ page }) => { const timestamp = Date.now(); // "hardspamtest" in subject triggers certainlySpam (1.0) response const testSubject = `hardspamtest ${timestamp}`; const testBody = `Testing hard spam moderation flow ${timestamp}`; await page.goto("/newpost/test"); // Fill in the form with hard spam-triggering subject await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", testSubject); await page.fill("#postform-text", testBody); // Submit the form await page.click('input[name="action-send"]'); // Should be challenged with CAPTCHA (dummy checkbox) const captchaCheckbox = page.locator('input[name="dummy_captcha_checkbox"]'); await expect(captchaCheckbox).toBeVisible(); // Should show "I am not a robot" text await expect(page.locator("body")).toContainText("I am not a robot"); }); test("hard spam post is quarantined after solving CAPTCHA", async ({ page, }) => { const timestamp = Date.now(); // "hardspamtest" in subject triggers certainlySpam (1.0) response const testSubject = `hardspamtest moderated ${timestamp}`; const testBody = `Testing hard spam quarantine ${timestamp}`; await page.goto("/newpost/test"); // Fill in the form with hard spam-triggering subject await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", testSubject); await page.fill("#postform-text", testBody); // Submit the form - should trigger CAPTCHA await page.click('input[name="action-send"]'); // Wait for CAPTCHA checkbox to appear const captchaCheckbox = page.locator('input[name="dummy_captcha_checkbox"]'); await expect(captchaCheckbox).toBeVisible(); // Check the CAPTCHA checkbox await captchaCheckbox.check(); // Submit again with CAPTCHA solved await page.click('input[name="action-send"]'); // Should NOT redirect to thread - should show moderation message // The URL should stay on posting page (not redirect to thread) await expect(page).not.toHaveURL(/\/(thread|post)\//); // Should show moderation message await expect(page.locator("body")).toContainText( "approved by a moderator" ); }); test("quarantined post does not appear in group listing", async ({ page }) => { const timestamp = Date.now(); const testSubject = `hardspamtest hidden ${timestamp}`; const testBody = `This post should be hidden ${timestamp}`; await page.goto("/newpost/test"); // Fill in and submit with hard spam-triggering subject await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", testSubject); await page.fill("#postform-text", testBody); await page.click('input[name="action-send"]'); // Solve CAPTCHA const captchaCheckbox = page.locator('input[name="dummy_captcha_checkbox"]'); await expect(captchaCheckbox).toBeVisible(); await captchaCheckbox.check(); await page.click('input[name="action-send"]'); // Should show moderation message await expect(page.locator("body")).toContainText( "approved by a moderator" ); // Navigate to group listing await page.goto("/group/test"); // Verify the quarantined thread does NOT appear in the listing await expect(page.locator("body")).not.toContainText(testSubject); }); }); test.describe("Registered User Experience", () => { test("registered user data persists after clearing cookies and signing back in", async ({ page, context, }) => { const timestamp = Date.now(); const testUsername = `testuser${timestamp}`; const testPassword = "testpass123"; const testName = `Test User ${timestamp}`; const testEmail = `test${timestamp}@example.com`; const testSubject = `Registered User Test ${timestamp}`; const testBody = `Testing registered user persistence ${timestamp}`; // Step 1: Register a new user await page.goto("/registerform"); await expect(page.locator("#registerform")).toBeVisible(); await page.fill("#loginform-username", testUsername); await page.fill("#loginform-password", testPassword); await page.fill("#loginform-password2", testPassword); await page.click('input[type="submit"]'); // Should be redirected after successful registration await expect(page).not.toHaveURL(/registerform/); // Step 2: Make a post (this saves name/email to user settings) await page.goto("/newpost/test"); await expect(page.locator("#postform")).toBeVisible(); await page.fill("#postform-name", testName); await page.fill("#postform-email", testEmail); await page.fill("#postform-subject", testSubject); await page.fill("#postform-text", testBody); await page.click('input[name="action-send"]'); // Wait for posting to complete and verify post is displayed await expect(page).toHaveURL(/\/(thread|post)\//); await expect(page.locator("body")).toContainText(testSubject); await expect(page.locator("body")).toContainText(testBody); // Step 3: Clear cookies (simulating browser close/cookie expiration) await context.clearCookies(); // Step 4: Sign back in await page.goto("/loginform"); await expect(page.locator("#loginform")).toBeVisible(); await page.fill("#loginform-username", testUsername); await page.fill("#loginform-password", testPassword); // Ensure "Remember me" is checked for persistent session await page.check("#loginform-remember"); await page.click('input[type="submit"]'); // Wait for navigation to complete (either redirect or error page) await page.waitForLoadState("networkidle"); // Verify we're redirected (not on login page) await expect(page).not.toHaveURL(/\/login/); // Verify user is logged in by checking for logout link with username await expect( page.locator(`a:has-text("Log out ${testUsername}")`) ).toBeVisible(); // Step 5a: Check that the post is marked as read await page.goto("/group/test"); // Find the link to our test post - it should have the "forum-read" class const postLink = page.locator(`a:has-text("${testSubject}")`).first(); await expect(postLink).toBeVisible(); await expect(postLink).toHaveClass(/forum-read/); // Step 5b: Check that posting form has same user details pre-filled await page.goto("/newpost/test"); await expect(page.locator("#postform")).toBeVisible(); // Verify name and email are pre-filled with the same values await expect(page.locator("#postform-name")).toHaveValue(testName); await expect(page.locator("#postform-email")).toHaveValue(testEmail); }); }); ================================================ FILE: tests/user-journey.spec.ts ================================================ import { test, expect } from "@playwright/test"; import { execSync } from "child_process"; import path from "path"; const PROJECT_ROOT = path.resolve(__dirname, ".."); const DB_PATH = process.env.DFEED_DB || path.join(PROJECT_ROOT, "data/db/dfeed.s3db"); test.describe("User Journey", () => { test("shows CAPTCHA question and answer on approval and moderation pages", async ({ page, context }) => { const timestamp = Date.now(); const modUsername = `mod${timestamp}`; // Step 1: Register a moderator user await page.goto("/registerform"); await page.fill("#loginform-username", modUsername); await page.fill("#loginform-password", "testpass123"); await page.fill("#loginform-password2", "testpass123"); await page.click('input[type="submit"]'); await page.waitForURL("**/"); // Promote user to moderator level (100) const sqlCmd = `UPDATE Users SET Level=100 WHERE Username='${modUsername}';`; execSync(`sqlite3 "${DB_PATH}" "${sqlCmd}"`); // Step 2: Create a moderated post as anonymous user await context.clearCookies(); await page.goto("/newpost/test"); await expect(page.locator("#postform")).toBeVisible(); // Fill form with hardspamtest (triggers CAPTCHA AND moderation) await page.fill("#postform-name", "Test User"); await page.fill("#postform-email", "test@example.com"); await page.fill("#postform-subject", `hardspamtest ${timestamp}`); await page.fill("#postform-text", "Testing CAPTCHA question and answer in user journey"); // Submit to trigger CAPTCHA await page.click('input[name="action-send"]'); // Wait for CAPTCHA and solve it const captchaCheckbox = page.locator('input[name="dummy_captcha_checkbox"]'); await expect(captchaCheckbox).toBeVisible(); // Capture the draft ID before solving CAPTCHA const draftId = await page.locator('input[name="did"]').inputValue(); expect(draftId).toBeTruthy(); // Solve CAPTCHA and submit await captchaCheckbox.check(); await page.click('input[name="action-send"]'); // Wait for moderation notice await expect(page.locator("body")).toContainText("approved by a moderator", { timeout: 10000 }); // Step 3: Log in as moderator await page.goto("/loginform"); await page.fill("#loginform-username", modUsername); await page.fill("#loginform-password", "testpass123"); await page.click('input[type="submit"]'); await page.waitForURL("**/"); // Step 4: Check the approval page has User Journey with CAPTCHA info await page.goto(`/approve-moderated-draft/${draftId}`); // Verify User Journey section exists const journeySection = page.locator(".journey-timeline"); await expect(journeySection).toBeVisible(); // Verify CAPTCHA question is shown (use .journey-message for more specific matching) const captchaQuestion = journeySection.locator(".journey-event", { has: page.locator(".journey-message", { hasText: "CAPTCHA question" }) }); await expect(captchaQuestion.first()).toBeVisible(); await expect(captchaQuestion.first()).toContainText("Dummy CAPTCHA"); // Verify CAPTCHA answer is shown const captchaAnswer = journeySection.locator(".journey-event", { has: page.locator(".journey-message", { hasText: "CAPTCHA answer" }) }); await expect(captchaAnswer.first()).toBeVisible(); await expect(captchaAnswer.first()).toContainText("checked", { ignoreCase: true }); // Step 5: Approve the post await page.click('input[name="approve"]'); await expect(page.locator("body")).toContainText("Post approved"); // Get the posting link to find the message ID const viewLink = await page.locator('a:has-text("View posting")').getAttribute('href'); expect(viewLink).toBeTruthy(); const postIdMatch = viewLink!.match(/posting\/([a-z]+)/); expect(postIdMatch).toBeTruthy(); const postId = postIdMatch![1]; const encodedMessageId = encodeURIComponent(`${postId}@localhost`); // Step 6: Check the moderation page for the live post also has User Journey with CAPTCHA info await page.goto(`/moderate/${encodedMessageId}`); // Verify User Journey section exists const moderationJourney = page.locator(".journey-timeline"); await expect(moderationJourney).toBeVisible(); // Verify CAPTCHA question is shown (use .journey-message for more specific matching) const modCaptchaQuestion = moderationJourney.locator(".journey-event", { has: page.locator(".journey-message", { hasText: "CAPTCHA question" }) }); await expect(modCaptchaQuestion.first()).toBeVisible(); await expect(modCaptchaQuestion.first()).toContainText("Dummy CAPTCHA"); // Verify CAPTCHA answer is shown const modCaptchaAnswer = moderationJourney.locator(".journey-event", { has: page.locator(".journey-message", { hasText: "CAPTCHA answer" }) }); await expect(modCaptchaAnswer.first()).toBeVisible(); await expect(modCaptchaAnswer.first()).toContainText("checked", { ignoreCase: true }); // Verify approval event is shown const approvalEvent = moderationJourney.locator(".journey-event.approval"); await expect(approvalEvent).toBeVisible(); await expect(approvalEvent).toContainText("Approved by moderator"); await expect(approvalEvent).toContainText(`Moderator: ${modUsername}`); }); });