├── .github └── FUNDING.yml ├── .gitignore ├── COPYING ├── CREDITS ├── README ├── autoload.php ├── config.example.ini ├── etc └── nginx │ └── conf.d │ └── weblog.example.conf ├── index.php └── src ├── Config.php ├── Controller ├── Abstract │ └── AbstractController.php ├── FeedController.php ├── PostController.php └── Router.php ├── Exception └── NotFoundException.php ├── Model ├── Entity │ ├── Author.php │ └── Post.php ├── Enum │ ├── Beautify.php │ ├── ContentType.php │ └── ShowUrls.php ├── PostCollection.php ├── PostRepository.php └── Route.php └── Utils ├── ContentFormatter.php ├── Factory.php ├── FeedGenerator.php ├── HttpUtils.php ├── Logger.php ├── StringUtils.php ├── TextUtils.php └── Validator.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: coignard 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | config.ini 2 | .DS_Store 3 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | GNU AFFERO GENERAL PUBLIC LICENSE 2 | Version 3, 19 November 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU Affero General Public License is a free, copyleft license for 11 | software and other kinds of works, specifically designed to ensure 12 | cooperation with the community in the case of network server software. 13 | 14 | The licenses for most software and other practical works are designed 15 | to take away your freedom to share and change the works. By contrast, 16 | our General Public Licenses are intended to guarantee your freedom to 17 | share and change all versions of a program--to make sure it remains free 18 | software for all its users. 19 | 20 | When we speak of free software, we are referring to freedom, not 21 | price. Our General Public Licenses are designed to make sure that you 22 | have the freedom to distribute copies of free software (and charge for 23 | them if you wish), that you receive source code or can get it if you 24 | want it, that you can change the software or use pieces of it in new 25 | free programs, and that you know you can do these things. 26 | 27 | Developers that use our General Public Licenses protect your rights 28 | with two steps: (1) assert copyright on the software, and (2) offer 29 | you this License which gives you legal permission to copy, distribute 30 | and/or modify the software. 31 | 32 | A secondary benefit of defending all users' freedom is that 33 | improvements made in alternate versions of the program, if they 34 | receive widespread use, become available for other developers to 35 | incorporate. Many developers of free software are heartened and 36 | encouraged by the resulting cooperation. However, in the case of 37 | software used on network servers, this result may fail to come about. 38 | The GNU General Public License permits making a modified version and 39 | letting the public access it on a server without ever releasing its 40 | source code to the public. 41 | 42 | The GNU Affero General Public License is designed specifically to 43 | ensure that, in such cases, the modified source code becomes available 44 | to the community. It requires the operator of a network server to 45 | provide the source code of the modified version running there to the 46 | users of that server. Therefore, public use of a modified version, on 47 | a publicly accessible server, gives the public access to the source 48 | code of the modified version. 49 | 50 | An older license, called the Affero General Public License and 51 | published by Affero, was designed to accomplish similar goals. This is 52 | a different license, not a version of the Affero GPL, but Affero has 53 | released a new version of the Affero GPL which permits relicensing under 54 | this license. 55 | 56 | The precise terms and conditions for copying, distribution and 57 | modification follow. 58 | 59 | TERMS AND CONDITIONS 60 | 61 | 0. Definitions. 62 | 63 | "This License" refers to version 3 of the GNU Affero General Public License. 64 | 65 | "Copyright" also means copyright-like laws that apply to other kinds of 66 | works, such as semiconductor masks. 67 | 68 | "The Program" refers to any copyrightable work licensed under this 69 | License. Each licensee is addressed as "you". "Licensees" and 70 | "recipients" may be individuals or organizations. 71 | 72 | To "modify" a work means to copy from or adapt all or part of the work 73 | in a fashion requiring copyright permission, other than the making of an 74 | exact copy. The resulting work is called a "modified version" of the 75 | earlier work or a work "based on" the earlier work. 76 | 77 | A "covered work" means either the unmodified Program or a work based 78 | on the Program. 79 | 80 | To "propagate" a work means to do anything with it that, without 81 | permission, would make you directly or secondarily liable for 82 | infringement under applicable copyright law, except executing it on a 83 | computer or modifying a private copy. Propagation includes copying, 84 | distribution (with or without modification), making available to the 85 | public, and in some countries other activities as well. 86 | 87 | To "convey" a work means any kind of propagation that enables other 88 | parties to make or receive copies. Mere interaction with a user through 89 | a computer network, with no transfer of a copy, is not conveying. 90 | 91 | An interactive user interface displays "Appropriate Legal Notices" 92 | to the extent that it includes a convenient and prominently visible 93 | feature that (1) displays an appropriate copyright notice, and (2) 94 | tells the user that there is no warranty for the work (except to the 95 | extent that warranties are provided), that licensees may convey the 96 | work under this License, and how to view a copy of this License. If 97 | the interface presents a list of user commands or options, such as a 98 | menu, a prominent item in the list meets this criterion. 99 | 100 | 1. Source Code. 101 | 102 | The "source code" for a work means the preferred form of the work 103 | for making modifications to it. "Object code" means any non-source 104 | form of a work. 105 | 106 | A "Standard Interface" means an interface that either is an official 107 | standard defined by a recognized standards body, or, in the case of 108 | interfaces specified for a particular programming language, one that 109 | is widely used among developers working in that language. 110 | 111 | The "System Libraries" of an executable work include anything, other 112 | than the work as a whole, that (a) is included in the normal form of 113 | packaging a Major Component, but which is not part of that Major 114 | Component, and (b) serves only to enable use of the work with that 115 | Major Component, or to implement a Standard Interface for which an 116 | implementation is available to the public in source code form. A 117 | "Major Component", in this context, means a major essential component 118 | (kernel, window system, and so on) of the specific operating system 119 | (if any) on which the executable work runs, or a compiler used to 120 | produce the work, or an object code interpreter used to run it. 121 | 122 | The "Corresponding Source" for a work in object code form means all 123 | the source code needed to generate, install, and (for an executable 124 | work) run the object code and to modify the work, including scripts to 125 | control those activities. However, it does not include the work's 126 | System Libraries, or general-purpose tools or generally available free 127 | programs which are used unmodified in performing those activities but 128 | which are not part of the work. For example, Corresponding Source 129 | includes interface definition files associated with source files for 130 | the work, and the source code for shared libraries and dynamically 131 | linked subprograms that the work is specifically designed to require, 132 | such as by intimate data communication or control flow between those 133 | subprograms and other parts of the work. 134 | 135 | The Corresponding Source need not include anything that users 136 | can regenerate automatically from other parts of the Corresponding 137 | Source. 138 | 139 | The Corresponding Source for a work in source code form is that 140 | same work. 141 | 142 | 2. Basic Permissions. 143 | 144 | All rights granted under this License are granted for the term of 145 | copyright on the Program, and are irrevocable provided the stated 146 | conditions are met. This License explicitly affirms your unlimited 147 | permission to run the unmodified Program. The output from running a 148 | covered work is covered by this License only if the output, given its 149 | content, constitutes a covered work. This License acknowledges your 150 | rights of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not 153 | convey, without conditions so long as your license otherwise remains 154 | in force. You may convey covered works to others for the sole purpose 155 | of having them make modifications exclusively for you, or provide you 156 | with facilities for running those works, provided that you comply with 157 | the terms of this License in conveying all material for which you do 158 | not control copyright. Those thus making or running the covered works 159 | for you must do so exclusively on your behalf, under your direction 160 | and control, on terms that prohibit them from making any copies of 161 | your copyrighted material outside their relationship with you. 162 | 163 | Conveying under any other circumstances is permitted solely under 164 | the conditions stated below. Sublicensing is not allowed; section 10 165 | makes it unnecessary. 166 | 167 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 168 | 169 | No covered work shall be deemed part of an effective technological 170 | measure under any applicable law fulfilling obligations under article 171 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 172 | similar laws prohibiting or restricting circumvention of such 173 | measures. 174 | 175 | When you convey a covered work, you waive any legal power to forbid 176 | circumvention of technological measures to the extent such circumvention 177 | is effected by exercising rights under this License with respect to 178 | the covered work, and you disclaim any intention to limit operation or 179 | modification of the work as a means of enforcing, against the work's 180 | users, your or third parties' legal rights to forbid circumvention of 181 | technological measures. 182 | 183 | 4. Conveying Verbatim Copies. 184 | 185 | You may convey verbatim copies of the Program's source code as you 186 | receive it, in any medium, provided that you conspicuously and 187 | appropriately publish on each copy an appropriate copyright notice; 188 | keep intact all notices stating that this License and any 189 | non-permissive terms added in accord with section 7 apply to the code; 190 | keep intact all notices of the absence of any warranty; and give all 191 | recipients a copy of this License along with the Program. 192 | 193 | You may charge any price or no price for each copy that you convey, 194 | and you may offer support or warranty protection for a fee. 195 | 196 | 5. Conveying Modified Source Versions. 197 | 198 | You may convey a work based on the Program, or the modifications to 199 | produce it from the Program, in the form of source code under the 200 | terms of section 4, provided that you also meet all of these conditions: 201 | 202 | a) The work must carry prominent notices stating that you modified 203 | it, and giving a relevant date. 204 | 205 | b) The work must carry prominent notices stating that it is 206 | released under this License and any conditions added under section 207 | 7. This requirement modifies the requirement in section 4 to 208 | "keep intact all notices". 209 | 210 | c) You must license the entire work, as a whole, under this 211 | License to anyone who comes into possession of a copy. This 212 | License will therefore apply, along with any applicable section 7 213 | additional terms, to the whole of the work, and all its parts, 214 | regardless of how they are packaged. This License gives no 215 | permission to license the work in any other way, but it does not 216 | invalidate such permission if you have separately received it. 217 | 218 | d) If the work has interactive user interfaces, each must display 219 | Appropriate Legal Notices; however, if the Program has interactive 220 | interfaces that do not display Appropriate Legal Notices, your 221 | work need not make them do so. 222 | 223 | A compilation of a covered work with other separate and independent 224 | works, which are not by their nature extensions of the covered work, 225 | and which are not combined with it such as to form a larger program, 226 | in or on a volume of a storage or distribution medium, is called an 227 | "aggregate" if the compilation and its resulting copyright are not 228 | used to limit the access or legal rights of the compilation's users 229 | beyond what the individual works permit. Inclusion of a covered work 230 | in an aggregate does not cause this License to apply to the other 231 | parts of the aggregate. 232 | 233 | 6. Conveying Non-Source Forms. 234 | 235 | You may convey a covered work in object code form under the terms 236 | of sections 4 and 5, provided that you also convey the 237 | machine-readable Corresponding Source under the terms of this License, 238 | in one of these ways: 239 | 240 | a) Convey the object code in, or embodied in, a physical product 241 | (including a physical distribution medium), accompanied by the 242 | Corresponding Source fixed on a durable physical medium 243 | customarily used for software interchange. 244 | 245 | b) Convey the object code in, or embodied in, a physical product 246 | (including a physical distribution medium), accompanied by a 247 | written offer, valid for at least three years and valid for as 248 | long as you offer spare parts or customer support for that product 249 | model, to give anyone who possesses the object code either (1) a 250 | copy of the Corresponding Source for all the software in the 251 | product that is covered by this License, on a durable physical 252 | medium customarily used for software interchange, for a price no 253 | more than your reasonable cost of physically performing this 254 | conveying of source, or (2) access to copy the 255 | Corresponding Source from a network server at no charge. 256 | 257 | c) Convey individual copies of the object code with a copy of the 258 | written offer to provide the Corresponding Source. This 259 | alternative is allowed only occasionally and noncommercially, and 260 | only if you received the object code with such an offer, in accord 261 | with subsection 6b. 262 | 263 | d) Convey the object code by offering access from a designated 264 | place (gratis or for a charge), and offer equivalent access to the 265 | Corresponding Source in the same way through the same place at no 266 | further charge. You need not require recipients to copy the 267 | Corresponding Source along with the object code. If the place to 268 | copy the object code is a network server, the Corresponding Source 269 | may be on a different server (operated by you or a third party) 270 | that supports equivalent copying facilities, provided you maintain 271 | clear directions next to the object code saying where to find the 272 | Corresponding Source. Regardless of what server hosts the 273 | Corresponding Source, you remain obligated to ensure that it is 274 | available for as long as needed to satisfy these requirements. 275 | 276 | e) Convey the object code using peer-to-peer transmission, provided 277 | you inform other peers where the object code and Corresponding 278 | Source of the work are being offered to the general public at no 279 | charge under subsection 6d. 280 | 281 | A separable portion of the object code, whose source code is excluded 282 | from the Corresponding Source as a System Library, need not be 283 | included in conveying the object code work. 284 | 285 | A "User Product" is either (1) a "consumer product", which means any 286 | tangible personal property which is normally used for personal, family, 287 | or household purposes, or (2) anything designed or sold for incorporation 288 | into a dwelling. In determining whether a product is a consumer product, 289 | doubtful cases shall be resolved in favor of coverage. For a particular 290 | product received by a particular user, "normally used" refers to a 291 | typical or common use of that class of product, regardless of the status 292 | of the particular user or of the way in which the particular user 293 | actually uses, or expects or is expected to use, the product. A product 294 | is a consumer product regardless of whether the product has substantial 295 | commercial, industrial or non-consumer uses, unless such uses represent 296 | the only significant mode of use of the product. 297 | 298 | "Installation Information" for a User Product means any methods, 299 | procedures, authorization keys, or other information required to install 300 | and execute modified versions of a covered work in that User Product from 301 | a modified version of its Corresponding Source. The information must 302 | suffice to ensure that the continued functioning of the modified object 303 | code is in no case prevented or interfered with solely because 304 | modification has been made. 305 | 306 | If you convey an object code work under this section in, or with, or 307 | specifically for use in, a User Product, and the conveying occurs as 308 | part of a transaction in which the right of possession and use of the 309 | User Product is transferred to the recipient in perpetuity or for a 310 | fixed term (regardless of how the transaction is characterized), the 311 | Corresponding Source conveyed under this section must be accompanied 312 | by the Installation Information. But this requirement does not apply 313 | if neither you nor any third party retains the ability to install 314 | modified object code on the User Product (for example, the work has 315 | been installed in ROM). 316 | 317 | The requirement to provide Installation Information does not include a 318 | requirement to continue to provide support service, warranty, or updates 319 | for a work that has been modified or installed by the recipient, or for 320 | the User Product in which it has been modified or installed. Access to a 321 | network may be denied when the modification itself materially and 322 | adversely affects the operation of the network or violates the rules and 323 | protocols for communication across the network. 324 | 325 | Corresponding Source conveyed, and Installation Information provided, 326 | in accord with this section must be in a format that is publicly 327 | documented (and with an implementation available to the public in 328 | source code form), and must require no special password or key for 329 | unpacking, reading or copying. 330 | 331 | 7. Additional Terms. 332 | 333 | "Additional permissions" are terms that supplement the terms of this 334 | License by making exceptions from one or more of its conditions. 335 | Additional permissions that are applicable to the entire Program shall 336 | be treated as though they were included in this License, to the extent 337 | that they are valid under applicable law. If additional permissions 338 | apply only to part of the Program, that part may be used separately 339 | under those permissions, but the entire Program remains governed by 340 | this License without regard to the additional permissions. 341 | 342 | When you convey a copy of a covered work, you may at your option 343 | remove any additional permissions from that copy, or from any part of 344 | it. (Additional permissions may be written to require their own 345 | removal in certain cases when you modify the work.) You may place 346 | additional permissions on material, added by you to a covered work, 347 | for which you have or can give appropriate copyright permission. 348 | 349 | Notwithstanding any other provision of this License, for material you 350 | add to a covered work, you may (if authorized by the copyright holders of 351 | that material) supplement the terms of this License with terms: 352 | 353 | a) Disclaiming warranty or limiting liability differently from the 354 | terms of sections 15 and 16 of this License; or 355 | 356 | b) Requiring preservation of specified reasonable legal notices or 357 | author attributions in that material or in the Appropriate Legal 358 | Notices displayed by works containing it; or 359 | 360 | c) Prohibiting misrepresentation of the origin of that material, or 361 | requiring that modified versions of such material be marked in 362 | reasonable ways as different from the original version; or 363 | 364 | d) Limiting the use for publicity purposes of names of licensors or 365 | authors of the material; or 366 | 367 | e) Declining to grant rights under trademark law for use of some 368 | trade names, trademarks, or service marks; or 369 | 370 | f) Requiring indemnification of licensors and authors of that 371 | material by anyone who conveys the material (or modified versions of 372 | it) with contractual assumptions of liability to the recipient, for 373 | any liability that these contractual assumptions directly impose on 374 | those licensors and authors. 375 | 376 | All other non-permissive additional terms are considered "further 377 | restrictions" within the meaning of section 10. If the Program as you 378 | received it, or any part of it, contains a notice stating that it is 379 | governed by this License along with a term that is a further 380 | restriction, you may remove that term. If a license document contains 381 | a further restriction but permits relicensing or conveying under this 382 | License, you may add to a covered work material governed by the terms 383 | of that license document, provided that the further restriction does 384 | not survive such relicensing or conveying. 385 | 386 | If you add terms to a covered work in accord with this section, you 387 | must place, in the relevant source files, a statement of the 388 | additional terms that apply to those files, or a notice indicating 389 | where to find the applicable terms. 390 | 391 | Additional terms, permissive or non-permissive, may be stated in the 392 | form of a separately written license, or stated as exceptions; 393 | the above requirements apply either way. 394 | 395 | 8. Termination. 396 | 397 | You may not propagate or modify a covered work except as expressly 398 | provided under this License. Any attempt otherwise to propagate or 399 | modify it is void, and will automatically terminate your rights under 400 | this License (including any patent licenses granted under the third 401 | paragraph of section 11). 402 | 403 | However, if you cease all violation of this License, then your 404 | license from a particular copyright holder is reinstated (a) 405 | provisionally, unless and until the copyright holder explicitly and 406 | finally terminates your license, and (b) permanently, if the copyright 407 | holder fails to notify you of the violation by some reasonable means 408 | prior to 60 days after the cessation. 409 | 410 | Moreover, your license from a particular copyright holder is 411 | reinstated permanently if the copyright holder notifies you of the 412 | violation by some reasonable means, this is the first time you have 413 | received notice of violation of this License (for any work) from that 414 | copyright holder, and you cure the violation prior to 30 days after 415 | your receipt of the notice. 416 | 417 | Termination of your rights under this section does not terminate the 418 | licenses of parties who have received copies or rights from you under 419 | this License. If your rights have been terminated and not permanently 420 | reinstated, you do not qualify to receive new licenses for the same 421 | material under section 10. 422 | 423 | 9. Acceptance Not Required for Having Copies. 424 | 425 | You are not required to accept this License in order to receive or 426 | run a copy of the Program. Ancillary propagation of a covered work 427 | occurring solely as a consequence of using peer-to-peer transmission 428 | to receive a copy likewise does not require acceptance. However, 429 | nothing other than this License grants you permission to propagate or 430 | modify any covered work. These actions infringe copyright if you do 431 | not accept this License. Therefore, by modifying or propagating a 432 | covered work, you indicate your acceptance of this License to do so. 433 | 434 | 10. Automatic Licensing of Downstream Recipients. 435 | 436 | Each time you convey a covered work, the recipient automatically 437 | receives a license from the original licensors, to run, modify and 438 | propagate that work, subject to this License. You are not responsible 439 | for enforcing compliance by third parties with this License. 440 | 441 | An "entity transaction" is a transaction transferring control of an 442 | organization, or substantially all assets of one, or subdividing an 443 | organization, or merging organizations. If propagation of a covered 444 | work results from an entity transaction, each party to that 445 | transaction who receives a copy of the work also receives whatever 446 | licenses to the work the party's predecessor in interest had or could 447 | give under the previous paragraph, plus a right to possession of the 448 | Corresponding Source of the work from the predecessor in interest, if 449 | the predecessor has it or can get it with reasonable efforts. 450 | 451 | You may not impose any further restrictions on the exercise of the 452 | rights granted or affirmed under this License. For example, you may 453 | not impose a license fee, royalty, or other charge for exercise of 454 | rights granted under this License, and you may not initiate litigation 455 | (including a cross-claim or counterclaim in a lawsuit) alleging that 456 | any patent claim is infringed by making, using, selling, offering for 457 | sale, or importing the Program or any portion of it. 458 | 459 | 11. Patents. 460 | 461 | A "contributor" is a copyright holder who authorizes use under this 462 | License of the Program or a work on which the Program is based. The 463 | work thus licensed is called the contributor's "contributor version". 464 | 465 | A contributor's "essential patent claims" are all patent claims 466 | owned or controlled by the contributor, whether already acquired or 467 | hereafter acquired, that would be infringed by some manner, permitted 468 | by this License, of making, using, or selling its contributor version, 469 | but do not include claims that would be infringed only as a 470 | consequence of further modification of the contributor version. For 471 | purposes of this definition, "control" includes the right to grant 472 | patent sublicenses in a manner consistent with the requirements of 473 | this License. 474 | 475 | Each contributor grants you a non-exclusive, worldwide, royalty-free 476 | patent license under the contributor's essential patent claims, to 477 | make, use, sell, offer for sale, import and otherwise run, modify and 478 | propagate the contents of its contributor version. 479 | 480 | In the following three paragraphs, a "patent license" is any express 481 | agreement or commitment, however denominated, not to enforce a patent 482 | (such as an express permission to practice a patent or covenant not to 483 | sue for patent infringement). To "grant" such a patent license to a 484 | party means to make such an agreement or commitment not to enforce a 485 | patent against the party. 486 | 487 | If you convey a covered work, knowingly relying on a patent license, 488 | and the Corresponding Source of the work is not available for anyone 489 | to copy, free of charge and under the terms of this License, through a 490 | publicly available network server or other readily accessible means, 491 | then you must either (1) cause the Corresponding Source to be so 492 | available, or (2) arrange to deprive yourself of the benefit of the 493 | patent license for this particular work, or (3) arrange, in a manner 494 | consistent with the requirements of this License, to extend the patent 495 | license to downstream recipients. "Knowingly relying" means you have 496 | actual knowledge that, but for the patent license, your conveying the 497 | covered work in a country, or your recipient's use of the covered work 498 | in a country, would infringe one or more identifiable patents in that 499 | country that you have reason to believe are valid. 500 | 501 | If, pursuant to or in connection with a single transaction or 502 | arrangement, you convey, or propagate by procuring conveyance of, a 503 | covered work, and grant a patent license to some of the parties 504 | receiving the covered work authorizing them to use, propagate, modify 505 | or convey a specific copy of the covered work, then the patent license 506 | you grant is automatically extended to all recipients of the covered 507 | work and works based on it. 508 | 509 | A patent license is "discriminatory" if it does not include within 510 | the scope of its coverage, prohibits the exercise of, or is 511 | conditioned on the non-exercise of one or more of the rights that are 512 | specifically granted under this License. You may not convey a covered 513 | work if you are a party to an arrangement with a third party that is 514 | in the business of distributing software, under which you make payment 515 | to the third party based on the extent of your activity of conveying 516 | the work, and under which the third party grants, to any of the 517 | parties who would receive the covered work from you, a discriminatory 518 | patent license (a) in connection with copies of the covered work 519 | conveyed by you (or copies made from those copies), or (b) primarily 520 | for and in connection with specific products or compilations that 521 | contain the covered work, unless you entered into that arrangement, 522 | or that patent license was granted, prior to 28 March 2007. 523 | 524 | Nothing in this License shall be construed as excluding or limiting 525 | any implied license or other defenses to infringement that may 526 | otherwise be available to you under applicable patent law. 527 | 528 | 12. No Surrender of Others' Freedom. 529 | 530 | If conditions are imposed on you (whether by court order, agreement or 531 | otherwise) that contradict the conditions of this License, they do not 532 | excuse you from the conditions of this License. If you cannot convey a 533 | covered work so as to satisfy simultaneously your obligations under this 534 | License and any other pertinent obligations, then as a consequence you may 535 | not convey it at all. For example, if you agree to terms that obligate you 536 | to collect a royalty for further conveying from those to whom you convey 537 | the Program, the only way you could satisfy both those terms and this 538 | License would be to refrain entirely from conveying the Program. 539 | 540 | 13. Remote Network Interaction; Use with the GNU General Public License. 541 | 542 | Notwithstanding any other provision of this License, if you modify the 543 | Program, your modified version must prominently offer all users 544 | interacting with it remotely through a computer network (if your version 545 | supports such interaction) an opportunity to receive the Corresponding 546 | Source of your version by providing access to the Corresponding Source 547 | from a network server at no charge, through some standard or customary 548 | means of facilitating copying of software. This Corresponding Source 549 | shall include the Corresponding Source for any work covered by version 3 550 | of the GNU General Public License that is incorporated pursuant to the 551 | following paragraph. 552 | 553 | Notwithstanding any other provision of this License, you have 554 | permission to link or combine any covered work with a work licensed 555 | under version 3 of the GNU General Public License into a single 556 | combined work, and to convey the resulting work. The terms of this 557 | License will continue to apply to the part which is the covered work, 558 | but the work with which it is combined will remain governed by version 559 | 3 of the GNU General Public License. 560 | 561 | 14. Revised Versions of this License. 562 | 563 | The Free Software Foundation may publish revised and/or new versions of 564 | the GNU Affero General Public License from time to time. Such new versions 565 | will be similar in spirit to the present version, but may differ in detail to 566 | address new problems or concerns. 567 | 568 | Each version is given a distinguishing version number. If the 569 | Program specifies that a certain numbered version of the GNU Affero General 570 | Public License "or any later version" applies to it, you have the 571 | option of following the terms and conditions either of that numbered 572 | version or of any later version published by the Free Software 573 | Foundation. If the Program does not specify a version number of the 574 | GNU Affero General Public License, you may choose any version ever published 575 | by the Free Software Foundation. 576 | 577 | If the Program specifies that a proxy can decide which future 578 | versions of the GNU Affero General Public License can be used, that proxy's 579 | public statement of acceptance of a version permanently authorizes you 580 | to choose that version for the Program. 581 | 582 | Later license versions may give you additional or different 583 | permissions. However, no additional obligations are imposed on any 584 | author or copyright holder as a result of your choosing to follow a 585 | later version. 586 | 587 | 15. Disclaimer of Warranty. 588 | 589 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 590 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 591 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 592 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 593 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 594 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 595 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 596 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 597 | 598 | 16. Limitation of Liability. 599 | 600 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 601 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 602 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 603 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 604 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 605 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 606 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 607 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 608 | SUCH DAMAGES. 609 | 610 | 17. Interpretation of Sections 15 and 16. 611 | 612 | If the disclaimer of warranty and limitation of liability provided 613 | above cannot be given local legal effect according to their terms, 614 | reviewing courts shall apply local law that most closely approximates 615 | an absolute waiver of all civil liability in connection with the 616 | Program, unless a warranty or assumption of liability accompanies a 617 | copy of the Program in return for a fee. 618 | 619 | END OF TERMS AND CONDITIONS 620 | 621 | How to Apply These Terms to Your New Programs 622 | 623 | If you develop a new program, and you want it to be of the greatest 624 | possible use to the public, the best way to achieve this is to make it 625 | free software which everyone can redistribute and change under these terms. 626 | 627 | To do so, attach the following notices to the program. It is safest 628 | to attach them to the start of each source file to most effectively 629 | state the exclusion of warranty; and each file should have at least 630 | the "copyright" line and a pointer to where the full notice is found. 631 | 632 | 633 | Copyright (C) 634 | 635 | This program is free software: you can redistribute it and/or modify 636 | it under the terms of the GNU Affero General Public License as published by 637 | the Free Software Foundation, either version 3 of the License, or 638 | (at your option) any later version. 639 | 640 | This program is distributed in the hope that it will be useful, 641 | but WITHOUT ANY WARRANTY; without even the implied warranty of 642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 643 | GNU Affero General Public License for more details. 644 | 645 | You should have received a copy of the GNU Affero General Public License 646 | along with this program. If not, see . 647 | 648 | Also add information on how to contact you by electronic and paper mail. 649 | 650 | If your software can interact with users remotely through a computer 651 | network, you should also make sure that it provides a way for users to 652 | get its source. For example, if your program is a web application, its 653 | interface could display a "Source" link that leads users to an archive 654 | of the code. There are many ways you could offer source, and different 655 | solutions will be better for different programs; see section 13 for the 656 | specific requirements. 657 | 658 | You should also get your employer (if you work as a programmer) or school, 659 | if any, to sign a "copyright disclaimer" for the program, if necessary. 660 | For more information on this, and how to apply and follow the GNU AGPL, see 661 | . 662 | -------------------------------------------------------------------------------- /CREDITS: -------------------------------------------------------------------------------- 1 | Credits 2 | ======= 3 | 4 | René Coignard 5 | - Original author and maintainer of Weblog. 6 | 7 | nicolus 8 | - Added support for configuration via .ini file. 9 | 10 | vladsolntsev 11 | - Performed a comprehensive project refactoring. 12 | -------------------------------------------------------------------------------- /README: -------------------------------------------------------------------------------- 1 | Weblog 2 | ====== 3 | 4 | A minimalistic, plain text-based blog engine written in PHP, inspired by 5 | the simplicity and structure of the RFC format. No HTML, CSS, or JS 6 | included. The blog engine does not require a database to function. 7 | 8 | I created this project to run my personal weblog, so you can check out the 9 | project in action on my personal website: 10 | 11 | https://renecoignard.com/ 12 | 13 | This source code is licensed under the GNU AGPLv3+. See the COPYING file 14 | for more details. 15 | -------------------------------------------------------------------------------- /autoload.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | /** 25 | * Autoloads PHP classes by searching for class files in 'src/'. 26 | * 27 | * @param mixed $className The name of the class to load 28 | */ 29 | function customAutoloader(mixed $className): void 30 | { 31 | if (str_starts_with($className, 'Weblog\\')) { 32 | $className = substr($className, 6); 33 | } 34 | 35 | $baseDir = __DIR__.'/src'; 36 | $classPath = str_replace('\\', '/', $className).'.php'; 37 | $filePath = $baseDir.$classPath; 38 | 39 | if (file_exists($filePath) && 'php' === pathinfo($filePath, \PATHINFO_EXTENSION)) { 40 | require_once $filePath; 41 | } 42 | } 43 | 44 | spl_autoload_register('customAutoloader'); 45 | -------------------------------------------------------------------------------- /config.example.ini: -------------------------------------------------------------------------------- 1 | [Weblog] 2 | 3 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 4 | ; Weblog Configuration File ; 5 | ;;;;;;;;;;;;;;;;;;;;;;;;;;;;; 6 | ; This configuration file is used to set up various aspects of Weblog, 7 | ; a plain text-based blogging engine. Modify and rename this file to config.ini 8 | ; and place it in the root directory of your Weblog installation. 9 | 10 | ;;;;;;;;;;;;;;;;;;;;; 11 | ; General Settings ; 12 | ;;;;;;;;;;;;;;;;;;;;; 13 | 14 | ; The full name of the blog author. 15 | ; Default Value: None 16 | author_name = "" 17 | 18 | ; A short description or bio of the author or the blog. 19 | ; Default Value: None 20 | about_text = "" 21 | 22 | ; Alternative about text, useful for mobile or different contexts. 23 | ; Default Value: None 24 | about_text_alt = "" 25 | 26 | ; Email used for contact and in the footer for copyright information. 27 | ; Default Value: None 28 | author_email = "" 29 | 30 | ; The city or country of the author. 31 | ; Default Value: None 32 | author_location = "" 33 | 34 | ; The domain where your blog is hosted. 35 | ; Default Value: localhost 36 | domain = "localhost" 37 | 38 | ; Directory path where blog posts are stored. 39 | ; Default Value: weblog 40 | weblog_dir = "weblog" 41 | 42 | ; URL where the source code of the application can be accessed. 43 | ; This is required for compliance with GNU AGPLv3. 44 | ; Change the URL only if the code has been altered. 45 | ; Note: The source_code_url is intentionally left blank 46 | ; to ensure attentiveness to configuration details. 47 | ; Default Value: https://github.com/coignard/weblog 48 | source_code_url = "" 49 | 50 | ;;;;;;;;;;;;;;;;;;;;; 51 | ; Display Settings ; 52 | ;;;;;;;;;;;;;;;;;;;;; 53 | 54 | ; Maximum line width for content rendering, controls how text is wrapped. 55 | ; Default Value: 72 56 | line_width = 72 57 | 58 | ; Length of the prefix used in formatted text output, used for indenting text content. 59 | ; Default Value: 3 60 | prefix_length = 3 61 | 62 | ; Option to show "Powered by Weblog" in the footer. 63 | ; Default Value: On 64 | ; Possible Values: On, Off 65 | show_powered_by = On 66 | 67 | ; Configure how URLs are displayed on the main page. 68 | ; Default Value: Off 69 | ; Possible Values: Full, Short, Off 70 | show_urls = Off 71 | 72 | ; Whether to display the category for each post. 73 | ; Default Value: On 74 | ; Possible Values: On, Off 75 | show_category = On 76 | 77 | ; Whether to display the date for each post. 78 | ; Default Value: On 79 | ; Possible Values: On, Off 80 | show_date = On 81 | 82 | ; Whether to display copyright information in the footer. 83 | ; Default Value: On 84 | ; Possible Values: On, Off 85 | show_copyright = On 86 | 87 | ; Whether to show a separator line after "About" section. 88 | ; Default Value: Off 89 | ; Possible Values: On, Off 90 | show_separator = Off 91 | 92 | ; Determines if text content should be shortened. 93 | ; Default Value: Off 94 | ; Possible Values: On, Off 95 | shorten_date = Off 96 | 97 | ; Determines if text content should be beautified with typographic replacements. 98 | ; Default Value: RSS 99 | ; Possible Values: Off, All, Content, RSS 100 | beautify = "RSS" 101 | 102 | ; Whether to hide the selected posts indication. 103 | ; Default Value: Off 104 | ; Possible Values: On, Off 105 | hide_selected = On 106 | 107 | ; Whether to capitalize titles. 108 | ; Default Value: Off 109 | ; Possible Values: On, Off 110 | capitalize_titles = Off 111 | 112 | ;;;;;;;;;;;;;;;;;;;;; 113 | ; Logging Settings ; 114 | ;;;;;;;;;;;;;;;;;;;;; 115 | 116 | ; Determines if logging of requests is enabled. 117 | ; Default Value: Off 118 | ; Possible Values: On, Off 119 | enable_logging = Off 120 | 121 | ; Configure the file path where the access log will be stored. 122 | ; Default Value: /var/log/weblog/access.log 123 | log_file_path = "/var/log/weblog/access.log" 124 | 125 | ; List of filter words to exclude from the logs. 126 | ; Default Value: None 127 | log_filter_words = "" 128 | 129 | ; List of filter user agents to exclude from the logs. 130 | ; Default Value: None 131 | log_filter_agents = "" 132 | 133 | ;;;;;;;;;;;;;;;;;;;;; 134 | ; URL Rewrites ; 135 | ;;;;;;;;;;;;;;;;;;;;; 136 | 137 | [Rewrites] 138 | ; Map specific requests to different resources. 139 | ; Key is the original URL part, value is the new destination (can be internal or external). 140 | ; External destinations must start with "http://" or "https://". 141 | 142 | ; Redirect from a simple URL slug to an external URL. 143 | ;example-slug = "http://example.com/" 144 | 145 | ; Redirect from a dated blog post to its new URL slug. 146 | ;2023/08/17 = "lottie" 147 | 148 | ; Redirect an old category page to a new one. 149 | ;old-category-name = "new-category-name" 150 | 151 | ; Redirect to an internal path within the weblog directory. 152 | ;about = "about-the-author" -------------------------------------------------------------------------------- /etc/nginx/conf.d/weblog.example.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | 4 | root /var/www/weblog; 5 | index index.php; 6 | charset utf-8; 7 | merge_slashes off; 8 | 9 | server_name localhost; 10 | 11 | location = /favicon.ico { log_not_found off; access_log off; expires max; } 12 | location = /robots.txt { log_not_found off; access_log off; allow all; } 13 | 14 | error_page 404 = /index.php?go=404; 15 | 16 | access_log /var/log/nginx/weblog.access.log; 17 | error_log /var/log/nginx/weblog.error.log; 18 | 19 | location = /config.ini { 20 | deny all; 21 | return 404; 22 | } 23 | 24 | location = /autoload.php { 25 | deny all; 26 | return 404; 27 | } 28 | 29 | location /weblog/ { 30 | rewrite ^ /index.php?go=weblog last; 31 | } 32 | 33 | location ~* ^/src/(.*) { 34 | deny all; 35 | return 404; 36 | } 37 | 38 | location ~* ^/weblog/.+ { 39 | deny all; 40 | return 404; 41 | } 42 | 43 | location / { 44 | try_files $uri $uri/ @rewrite; 45 | } 46 | 47 | location /sitemap.xml { 48 | rewrite ^/sitemap.xml$ /index.php?go=sitemap.xml last; 49 | } 50 | 51 | location @rewrite { 52 | rewrite ^/(.*)/$ /index.php?go=$1 last; 53 | } 54 | 55 | location ~ \.php$ { 56 | include fastcgi-php.conf; 57 | fastcgi_pass php-fpm; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /index.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | require_once './autoload.php'; 25 | 26 | use Weblog\Utils\Factory; 27 | 28 | final class Weblog 29 | { 30 | public static function run(): void 31 | { 32 | Factory::createRouter()->route(); 33 | } 34 | } 35 | 36 | Weblog::run(); 37 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog; 25 | 26 | use Weblog\Model\Entity\Author; 27 | use Weblog\Model\Enum\ShowUrls; 28 | use Weblog\Model\Enum\Beautify; 29 | use Weblog\Utils\StringUtils; 30 | use Weblog\Utils\Validator; 31 | 32 | final class Config 33 | { 34 | private const VERSION = '1.18.9'; 35 | private const CONFIG_PATH = __DIR__.'/../config.ini'; 36 | 37 | /** 38 | * @var array 39 | */ 40 | private array $config = []; 41 | private static ?Config $instance = null; 42 | 43 | /** 44 | * Private constructor to prevent creating a new instance of the Config singleton. 45 | */ 46 | private function __construct( 47 | public Author $author = new Author(), 48 | public string $version = self::VERSION, 49 | public string $domain = 'localhost', 50 | public string $url = 'http://localhost', 51 | public int $lineWidth = 72, 52 | public int $prefixLength = 3, 53 | public string $weblogDir = __DIR__.'/../weblog/', 54 | public bool $showPoweredBy = true, 55 | public ShowUrls $showUrls = ShowUrls::OFF, 56 | public bool $showCategory = true, 57 | public bool $showDate = true, 58 | public bool $showCopyright = true, 59 | public bool $showSeparator = false, 60 | public bool $capitalizeTitles = false, 61 | public array $rewrites = [], 62 | public Beautify $beautify = Beautify::RSS, 63 | public bool $hideSelected = true, 64 | public bool $shortenDate = false, 65 | public bool $enableLogging = false, 66 | public string $logFilePath = '/var/log/weblog/access.log', 67 | public array $logFilterWords = [], 68 | public array $logFilterAgents = [], 69 | public string $sourceCodeUrl = 'https://github.com/coignard/weblog' 70 | ) { 71 | $this->loadConfig(); 72 | } 73 | 74 | /** 75 | * Returns a new Config instance. 76 | */ 77 | public static function get(): self 78 | { 79 | if (null === self::$instance) { 80 | self::$instance = new self(); 81 | } 82 | 83 | return self::$instance; 84 | } 85 | 86 | /** 87 | * Loads configuration from a config.ini file. 88 | */ 89 | private function loadConfig(): void 90 | { 91 | if (!file_exists(self::CONFIG_PATH)) { 92 | throw new \RuntimeException('Configuration file not found.'); 93 | } 94 | 95 | if (!$config = parse_ini_file(self::CONFIG_PATH, true)) { 96 | throw new \RuntimeException('Failed to parse configuration file.'); 97 | } 98 | 99 | $this->config = $config['Weblog']; 100 | 101 | if (isset($this->config['weblog_dir']) && !is_dir($this->getString('weblog_dir') ?? '')) { 102 | throw new \RuntimeException('Weblog directory not found.'); 103 | } 104 | 105 | $this->setAuthor(); 106 | 107 | $protocol = (!empty($_SERVER['HTTPS']) && 'off' !== $_SERVER['HTTPS'] || 443 === $_SERVER['SERVER_PORT']) ? 'https://' : 'http://'; 108 | 109 | $this->lineWidth = $this->getInt('line_width') ?? $this->lineWidth; 110 | $this->prefixLength = $this->getInt('prefix_length') ?? $this->prefixLength; 111 | $this->weblogDir = $this->getString('weblog_dir') ?? $this->weblogDir; 112 | $this->domain = $this->getString('domain') ?? $this->domain; 113 | $this->url = rtrim($protocol.$this->getString('domain'), '/'); 114 | $this->showPoweredBy = $this->getBool('show_powered_by') ?? $this->showPoweredBy; 115 | $this->showUrls = ShowUrls::tryFrom(is_bool($this->getString('show_urls')) ? ShowUrls::OFF->value : ($this->getString('show_urls') ?? '')) ?? ShowUrls::OFF; 116 | $this->showCategory = $this->getBool('show_category') ?? $this->showCategory; 117 | $this->showDate = $this->getBool('show_date') ?? $this->showDate; 118 | $this->showCopyright = $this->getBool('show_copyright') ?? $this->showCopyright; 119 | $this->showSeparator = $this->getBool('show_separator') ?? $this->showSeparator; 120 | $this->capitalizeTitles = $this->getBool('capitalize_titles') ?? $this->capitalizeTitles; 121 | $this->beautify = Beautify::tryFrom(is_bool($this->getString('beautify')) ? Beautify::OFF->value : ($this->getString('beautify') ?? '')) ?? Beautify::OFF; 122 | $this->hideSelected = $this->getBool('hide_selected') ?? $this->hideSelected; 123 | $this->shortenDate = $this->getBool('shorten_date') ?? $this->shortenDate; 124 | $this->enableLogging = $this->getBool('enable_logging') ?? $this->enableLogging; 125 | $this->logFilePath = $this->getString('log_file_path') ?? '/var/log/weblog/access.log'; 126 | $this->logFilterWords = array_map('trim', explode(',', $this->getString('log_filter_words') ?? '')); 127 | $this->logFilterAgents = array_map('trim', explode(',', $this->getString('log_filter_agents') ?? '')); 128 | $this->sourceCodeUrl = ($this->getString('source_code_url') ?? '') !== '' 129 | ? $this->getString('source_code_url') 130 | : throw new \RuntimeException('To comply with the GNU Affero General Public License (AGPL), please provide the source code URL in the config.ini.'); 131 | 132 | $this->rewrites = $config['Rewrites'] ?? $this->rewrites; 133 | 134 | $this->handleMobileDevice(); 135 | } 136 | 137 | private function handleMobileDevice(): void 138 | { 139 | if (false === Validator::isMobileDevice()) { 140 | return; 141 | } 142 | 143 | $this->lineWidth = (int) ($this->lineWidth / 2) + 6; 144 | $this->showCategory = false; 145 | $this->showDate = false; 146 | $this->showUrls = ShowUrls::OFF; 147 | } 148 | 149 | private function setAuthor(): void 150 | { 151 | $name = $this->getString('author_name') ?? $this->author->getName(); 152 | $email = $this->getString('author_email') ?? $this->author->getEmail(); 153 | $location = $this->getString('author_location') ?? $this->author->getLocation(); 154 | 155 | $aboutText = Validator::isMobileDevice() && isset($this->config['about_text_alt']) 156 | ? $this->getString('about_text_alt') 157 | : $this->getString('about_text'); 158 | $aboutText = StringUtils::sanitizeText($aboutText ?? $this->author->getAbout()); 159 | 160 | $this->author = new Author( 161 | name: $name, 162 | email: $email, 163 | location: $location, 164 | aboutText: $aboutText 165 | ); 166 | } 167 | 168 | private function getInt(string $key): ?int 169 | { 170 | return isset($this->config[$key]) && is_numeric($this->config[$key]) ? (int) ($this->config[$key]) : null; 171 | } 172 | 173 | private function getString(string $key): ?string 174 | { 175 | return isset($this->config[$key]) ? (string) ($this->config[$key]) : null; 176 | } 177 | 178 | private function getBool(string $key): ?bool 179 | { 180 | return isset($this->config[$key]) ? filter_var($this->config[$key], FILTER_VALIDATE_BOOLEAN) : null; 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/Controller/Abstract/AbstractController.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Controller\Abstract; 25 | 26 | use Weblog\Config; 27 | use Weblog\Model\Enum\ContentType; 28 | use Weblog\Model\PostRepository; 29 | use Weblog\Utils\TextUtils; 30 | use Weblog\Utils\Validator; 31 | 32 | abstract class AbstractController 33 | { 34 | public function __construct( 35 | protected readonly PostRepository $postRepository, 36 | protected ContentType $contentType = ContentType::TEXT, 37 | ) { 38 | $this->setHeaders($contentType); 39 | } 40 | 41 | /** 42 | * Handles the "Not Found" response with a randomized easter egg. 43 | */ 44 | public function handleNotFound(): void 45 | { 46 | http_response_code(404); 47 | 48 | if (1 === random_int(1, 10)) { 49 | echo <<showPoweredBy ? "\n\n\n\n" : "\n\n\n"; 75 | 76 | if (false === Config::get()->showCopyright) { 77 | return; 78 | } 79 | 80 | if (null === $year) { 81 | $dateRange = $this->postRepository->getPostYearsRange(); 82 | $copyrightText = TextUtils::formatCopyrightText($dateRange); 83 | } else { 84 | $copyrightText = sprintf('Copyright (c) %s %s', $year, Config::get()->author->getInformation()); 85 | } 86 | 87 | if (Validator::isMobileDevice()) { 88 | $copyrightText = sprintf('(c) %s', Config::get()->author->getEmail()); 89 | } 90 | 91 | echo TextUtils::centerText($copyrightText); 92 | 93 | if (Config::get()->showPoweredBy) { 94 | echo "\n\n"; 95 | $poweredByText = Validator::isMobileDevice() ? 96 | 'Powered by Weblog' : 97 | 'Powered by Weblog v'.Config::get()->version; 98 | echo TextUtils::centerText($poweredByText); 99 | } 100 | 101 | echo "\n\n\n"; 102 | } 103 | 104 | /** 105 | * Sets the content type header. 106 | * 107 | * @param ContentType $contentType enum 108 | */ 109 | public function setHeaders(ContentType $contentType): void 110 | { 111 | header(sprintf('Content-Type: %s; charset=utf-8', $contentType->value)); 112 | header(sprintf('X-Source-Code: %s', Config::get()->sourceCodeUrl)); 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Controller/FeedController.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Controller; 25 | 26 | use Weblog\Controller\Abstract\AbstractController; 27 | use Weblog\Exception\NotFoundException; 28 | use Weblog\Model\Enum\ContentType; 29 | use Weblog\Utils\FeedGenerator; 30 | 31 | final class FeedController extends AbstractController 32 | { 33 | /** 34 | * Renders the sitemap in XML format, listing all posts, including the main page. 35 | * Sorts posts from newest to oldest. 36 | */ 37 | public function renderSitemap(): void 38 | { 39 | $posts = $this->postRepository->fetchAllPosts(); 40 | $posts->sort(); 41 | $siteMap = FeedGenerator::generateSiteMap($posts); 42 | 43 | $dom = new \DOMDocument('1.0', 'UTF-8'); 44 | $dom->preserveWhiteSpace = false; 45 | $dom->formatOutput = true; 46 | 47 | $dom->loadXML($siteMap->asXML()); 48 | 49 | $this->setHeaders(ContentType::XML); 50 | echo $dom->saveXML(); 51 | } 52 | 53 | /** 54 | * Renders an RSS feed for the Weblog. 55 | */ 56 | public function renderRSS(): void 57 | { 58 | $posts = $this->postRepository->fetchAllPosts(); 59 | if ($posts->isEmpty()) { 60 | throw new NotFoundException(); 61 | } 62 | $rss = FeedGenerator::generateRSS($posts); 63 | 64 | $dom = new \DOMDocument('1.0', 'UTF-8'); 65 | $dom->preserveWhiteSpace = false; 66 | $dom->formatOutput = true; 67 | 68 | $dom->loadXML($rss->asXML()); 69 | 70 | $this->setHeaders(ContentType::XML); 71 | 72 | echo $dom->saveXML(); 73 | } 74 | 75 | /** 76 | * Renders an RSS feed for the given category. 77 | * 78 | * @param string $category the category to filter by 79 | */ 80 | public function renderRSSByCategory(string $category): void 81 | { 82 | $posts = $this->postRepository->fetchPostsByCategory($category); 83 | if ($posts->isEmpty()) { 84 | throw new NotFoundException(); 85 | } 86 | $rss = FeedGenerator::generateRSS($posts, $category); 87 | 88 | $dom = new \DOMDocument('1.0', 'UTF-8'); 89 | $dom->preserveWhiteSpace = false; 90 | $dom->formatOutput = true; 91 | 92 | $dom->loadXML($rss->asXML()); 93 | 94 | $this->setHeaders(ContentType::XML); 95 | 96 | echo $dom->saveXML(); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/Controller/PostController.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Controller; 25 | 26 | use Weblog\Config; 27 | use Weblog\Controller\Abstract\AbstractController; 28 | use Weblog\Exception\NotFoundException; 29 | use Weblog\Model\Entity\Post; 30 | use Weblog\Model\Enum\ShowUrls; 31 | use Weblog\Model\PostCollection; 32 | use Weblog\Utils\ContentFormatter; 33 | use Weblog\Utils\StringUtils; 34 | use Weblog\Utils\TextUtils; 35 | use Weblog\Utils\HttpUtils; 36 | 37 | final class PostController extends AbstractController 38 | { 39 | /** 40 | * Renders the home page. 41 | */ 42 | public function renderHome(): void 43 | { 44 | echo TextUtils::formatAboutHeader(); 45 | echo TextUtils::formatAboutText(); 46 | 47 | $this->renderPosts(); 48 | $this->renderFooter(); 49 | } 50 | 51 | /** 52 | * Displays posts. 53 | * 54 | * @param PostCollection $posts defaults to all 55 | * @param bool $showUrls indicates if we should append URLs to each post 56 | * @param bool $isPostNewline indicates if we should display additional newlines between posts (could be refactored) 57 | */ 58 | public function renderPosts(?PostCollection $posts = null, string $showUrls = 'Off', bool $isPostNewline = false): void 59 | { 60 | if (null === $posts) { 61 | $posts = $this->postRepository->fetchAllPosts(); 62 | } 63 | 64 | $lastIndex = $posts->count() - 1; 65 | foreach ($posts as $index => $post) { 66 | if (!$post instanceof Post) { 67 | continue; 68 | } 69 | 70 | if ($isPostNewline) { 71 | echo "\n\n\n\n"; 72 | } 73 | 74 | $this->renderPost($post, $showUrls); 75 | 76 | if ($index !== $lastIndex && !$isPostNewline) { 77 | echo "\n\n\n\n"; 78 | } 79 | } 80 | } 81 | 82 | /** 83 | * Retrieves the requested post based on the GET parameter, converting the title to a slug and handling .txt extension. 84 | * 85 | * @param string $postSlug the post's slug 86 | * 87 | * @return null|Post the post of the requested post or null if not found 88 | */ 89 | public function getRequestedPost(string $postSlug): ?Post 90 | { 91 | $postSlug = StringUtils::sanitize($postSlug); 92 | 93 | if (isset(Config::get()->rewrites[rtrim($postSlug, '/')])) { 94 | $redirectUrl = Config::get()->rewrites[$postSlug]; 95 | if (str_starts_with($redirectUrl, 'http://') || str_starts_with($redirectUrl, 'https://')) { 96 | HttpUtils::redirect($redirectUrl, 301); 97 | } else { 98 | HttpUtils::redirect(Config::get()->url . '/' . $redirectUrl . '/', 301); 99 | } 100 | 101 | exit; 102 | } 103 | 104 | return $this->postRepository->fetchPostInDirectory($postSlug); 105 | } 106 | 107 | /** 108 | * Renders a single post, including its header, content, and optionally a URL. 109 | * 110 | * @param bool $showUrls indicates if we should append URLs to each post 111 | */ 112 | public function renderPost(Post $post, string $showUrls = 'Off'): void 113 | { 114 | $title = ltrim($post->getTitle(), '.'); 115 | $category = ltrim($post->getCategory(), '.'); 116 | $date = $post->getDate()->format('j F Y'); 117 | 118 | $header = ContentFormatter::formatPostHeader($title, $category, $date); 119 | 120 | echo $header."\n\n\n"; 121 | 122 | echo ContentFormatter::formatPostContent($post->getContent()); 123 | 124 | if ($showUrls && (ShowUrls::FULL === Config::get()->showUrls | ShowUrls::SHORT === Config::get()->showUrls)) { 125 | $url = StringUtils::formatUrl($post->getSlug()); 126 | echo "\n ".$url."\n\n"; 127 | } 128 | } 129 | 130 | /** 131 | * Renders a single post, to be used in the full post view. 132 | */ 133 | public function renderFullPost(Post $post): void 134 | { 135 | echo "\n\n\n\n"; 136 | $this->renderPost($post); 137 | $this->renderFooter($post->getDate()->format('Y')); 138 | } 139 | 140 | /** 141 | * Renders a draft post. 142 | * 143 | * @param string $slug The slug of the draft to render. 144 | * @throws NotFoundException If the draft is not found. 145 | */ 146 | public function renderDraft(string $slug): void 147 | { 148 | $draft = $this->postRepository->fetchDraftBySlug($slug); 149 | if (null === $draft) { 150 | throw new NotFoundException(); 151 | } 152 | $this->renderFullPost($draft); 153 | } 154 | 155 | /** 156 | * Renders posts filtered by category. 157 | * 158 | * @param string $category category name from URL 159 | */ 160 | public function renderPostsByCategory(string $category): void 161 | { 162 | $posts = $this->postRepository->fetchPostsByCategory($category); 163 | 164 | if ($posts->isEmpty()) { 165 | throw new NotFoundException(); 166 | } 167 | 168 | echo "\n\n\n\n"; 169 | $this->renderPosts($posts); 170 | $this->renderFooter($posts->getYearRange()); 171 | } 172 | 173 | /** 174 | * Renders posts filtered by date. 175 | * 176 | * @param string $datePath date path from URL in format yyyy/mm/dd or yyyy/mm or yyyy 177 | */ 178 | public function renderPostsByDate(string $datePath): void 179 | { 180 | [$date, $precision] = StringUtils::extractDateFromPath($datePath); 181 | 182 | if (null === $date) { 183 | throw new NotFoundException(); 184 | } 185 | 186 | $posts = $this->postRepository->fetchPostsByDate($date, $precision); 187 | 188 | if ($posts->isEmpty()) { 189 | throw new NotFoundException(); 190 | } 191 | $this->renderPosts($posts, 'Off', true); 192 | $this->renderFooter($date->format('Y')); 193 | } 194 | 195 | /** 196 | * Renders a random post from all available posts. 197 | */ 198 | public function renderRandomPost(): void 199 | { 200 | $posts = $this->postRepository->fetchAllPosts(); 201 | 202 | if ($posts->isEmpty()) { 203 | throw new NotFoundException(); 204 | } 205 | 206 | $randomPost = $posts->getRandomPost(); 207 | $this->renderFullPost($randomPost); 208 | } 209 | 210 | /** 211 | * Renders the latest post. 212 | */ 213 | public function renderLatestPost(): void 214 | { 215 | $posts = $this->postRepository->fetchAllPosts(); 216 | if ($posts->isEmpty()) { 217 | throw new NotFoundException(); 218 | } 219 | $latestPost = $posts->getFirstPost(); 220 | $this->renderFullPost($latestPost); 221 | } 222 | 223 | /** 224 | * Renders posts from the last year. 225 | */ 226 | public function renderLatestYear(): void 227 | { 228 | $startOfYear = new \DateTimeImmutable('first day of January this year 00:00:00'); 229 | $today = new \DateTimeImmutable('now'); 230 | 231 | $posts = $this->postRepository->fetchPostsFromDateRange($startOfYear, $today); 232 | if ($posts->isEmpty()) { 233 | throw new NotFoundException(); 234 | } 235 | $this->renderPosts($posts, 'Off', true); 236 | $this->renderFooter($posts->getYearRange()); 237 | } 238 | 239 | /** 240 | * Renders posts from the last month. 241 | */ 242 | public function renderLatestMonth(): void 243 | { 244 | $startOfMonth = new \DateTimeImmutable('first day of this month 00:00:00'); 245 | $today = new \DateTimeImmutable('now'); 246 | 247 | $posts = $this->postRepository->fetchPostsFromDateRange($startOfMonth, $today); 248 | if ($posts->isEmpty()) { 249 | throw new NotFoundException(); 250 | } 251 | $this->renderPosts($posts, 'Off', true); 252 | $this->renderFooter($posts->getYearRange()); 253 | } 254 | 255 | /** 256 | * Renders posts from the last week. 257 | */ 258 | public function renderLatestWeek(): void 259 | { 260 | $today = new \DateTimeImmutable('now'); 261 | $startOfWeek = $today->modify('monday this week 00:00:00'); 262 | $endOfWeek = $today; 263 | 264 | $posts = $this->postRepository->fetchPostsFromDateRange($startOfWeek, $endOfWeek); 265 | if ($posts->isEmpty()) { 266 | throw new NotFoundException(); 267 | } 268 | $this->renderPosts($posts, 'Off', true); 269 | $this->renderFooter($posts->getYearRange()); 270 | } 271 | 272 | /** 273 | * Renders posts from the last day. 274 | */ 275 | public function renderLatestDay(): void 276 | { 277 | $today = new \DateTimeImmutable('today 00:00:00'); 278 | $now = new \DateTimeImmutable('now'); 279 | 280 | $posts = $this->postRepository->fetchPostsFromDateRange($today, $now); 281 | if ($posts->isEmpty()) { 282 | throw new NotFoundException(); 283 | } 284 | $this->renderPosts($posts, 'Off', true); 285 | $this->renderFooter($posts->getYearRange()); 286 | } 287 | 288 | /** 289 | * Renders the selected posts. 290 | * 291 | * Fetches and displays all posts marked as selected. If no selected posts are found, a NotFoundException is thrown. 292 | * 293 | * @throws NotFoundException if no selected posts are found. 294 | */ 295 | public function renderSelectedPosts(): void 296 | { 297 | $posts = $this->postRepository->fetchSelectedPosts(); 298 | if ($posts->isEmpty()) { 299 | throw new NotFoundException(); 300 | } 301 | $this->renderPosts($posts, 'Off', true); 302 | $this->renderFooter($posts->getYearRange()); 303 | } 304 | 305 | /** 306 | * Renders search results. 307 | * 308 | * @param string $query the search query 309 | */ 310 | public function renderSearchResults(string $query): void 311 | { 312 | $posts = $this->postRepository->searchPosts($query); 313 | if ($posts->isEmpty()) { 314 | throw new NotFoundException(); 315 | } 316 | $this->renderPosts($posts, 'Off', true); 317 | $this->renderFooter($posts->getYearRange()); 318 | } 319 | } 320 | -------------------------------------------------------------------------------- /src/Controller/Router.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Controller; 25 | 26 | use Weblog\Config; 27 | use Weblog\Model\Route; 28 | use Weblog\Utils\StringUtils; 29 | use Weblog\Utils\Validator; 30 | use Weblog\Utils\Logger; 31 | use Weblog\Utils\HttpUtils; 32 | 33 | final class Router 34 | { 35 | public function __construct( 36 | private readonly PostController $postController, 37 | private readonly FeedController $feedController, 38 | ) {} 39 | 40 | /** 41 | * Handles URIs to perform redirection based on predefined rules. 42 | * 43 | * @param string $uri The requested URI. 44 | * 45 | * @return bool Returns true if a redirection has been made, false otherwise. 46 | */ 47 | private function handleRedirectRoute(string $uri): bool 48 | { 49 | $scheme = HttpUtils::getScheme(); 50 | $host = HttpUtils::getHost(); 51 | 52 | // Do not redirect '/sitemap.xml' 53 | if ($uri === '/sitemap.xml') { 54 | return false; 55 | } 56 | 57 | // Normalize multiple slashes and redirect to a single slash version 58 | if (preg_match('#^([^.]*?\/)\/+(.*)$#', $uri, $matches)) { 59 | $normalizedPath = preg_replace('#/{2,}#', '/', "{$matches[1]}{$matches[2]}"); 60 | HttpUtils::redirect("{$scheme}://{$host}{$normalizedPath}"); 61 | return true; 62 | } 63 | 64 | // Remove trailing slash in .txt files and correct to full URL 65 | if (preg_match('#^/(.+)\.txt/$#', $uri, $matches)) { 66 | HttpUtils::redirect("{$scheme}://{$host}/{$matches[1]}.txt"); 67 | return true; 68 | } 69 | 70 | // Handle .txt extension for routing 71 | if (preg_match('#^/(.+)\.txt$#', $uri, $matches)) { 72 | $_GET['go'] = $matches[1]; 73 | return false; 74 | } 75 | 76 | // Ensure names that don't end in slash are redirected with a slash 77 | if (preg_match('#^/([^/]+)$#', $uri, $matches)) { 78 | HttpUtils::redirect("{$scheme}://{$host}/{$matches[1]}/"); 79 | return true; 80 | } 81 | 82 | // Year paths addition of trailing slash with full URL 83 | if (preg_match('#^/(\d{4})$#', $uri, $matches)) { 84 | HttpUtils::redirect("{$scheme}://{$host}/{$matches[1]}/"); 85 | return true; 86 | } 87 | 88 | // Ensure year/month paths end with slash with full URL 89 | if (preg_match('#^/(\d{4})/(\d{2})$#', $uri, $matches)) { 90 | HttpUtils::redirect("{$scheme}://{$host}/{$matches[1]}/{$matches[2]}/"); 91 | return true; 92 | } 93 | 94 | // Ensure year/month/day paths end with slash with full URL 95 | if (preg_match('#^/(\d{4})/(\d{2})/(\d{2})$#', $uri, $matches)) { 96 | HttpUtils::redirect("{$scheme}://{$host}/{$matches[1]}/{$matches[2]}/{$matches[3]}/"); 97 | return true; 98 | } 99 | 100 | // Ensure RSS category paths are canonical with full URL 101 | if (preg_match('#^/rss/([\w-]+)$#', $uri, $matches)) { 102 | HttpUtils::redirect("{$scheme}://{$host}/rss/{$matches[1]}/"); 103 | return true; 104 | } 105 | 106 | // Normalize latest path with trailing slash 107 | if (preg_match('#^/latest$#', $uri)) { 108 | HttpUtils::redirect("{$scheme}://{$host}/latest/"); 109 | return true; 110 | } 111 | 112 | // Normalize latest subpaths with trailing slash 113 | if (preg_match('#^/latest/([^/]+)$#', $uri, $matches)) { 114 | HttpUtils::redirect("{$scheme}://{$host}/latest/{$matches[1]}/"); 115 | return true; 116 | } 117 | 118 | // Normalize search paths with trailing slash 119 | if (preg_match('#^/search/(.*[^/])$#', $uri, $matches)) { 120 | HttpUtils::redirect("{$scheme}://{$host}/search/{$matches[1]}/"); 121 | return true; 122 | } 123 | 124 | // Handle "/search" path setting 'go' GET parameter 125 | if (preg_match('#^/search/(.*)/$#', $uri, $matches)) { 126 | $_GET['go'] = "search/{$matches[1]}"; 127 | return false; 128 | } 129 | 130 | // Normalize selected path with trailing slash 131 | if (preg_match('#^/selected$#', $uri)) { 132 | HttpUtils::redirect("{$scheme}://{$host}/selected/"); 133 | return true; 134 | } 135 | 136 | // Normalize draft paths with trailing slash 137 | if (preg_match('#^/drafts/([^/]+)$#', $uri, $matches)) { 138 | HttpUtils::redirect("{$scheme}://{$host}/drafts/{$matches[1]}/"); 139 | return true; 140 | } 141 | 142 | return false; 143 | } 144 | 145 | /** 146 | * Routes the request based on server parameters using a predefined set of routes. 147 | */ 148 | public function route(): void 149 | { 150 | $uri = $_SERVER['REQUEST_URI']; 151 | 152 | if ($this->handleRedirectRoute($uri)) { 153 | return; 154 | } 155 | 156 | if ($this->isFaviconRequest($_SERVER['REQUEST_URI'])) { 157 | $this->handleFaviconRequest(); 158 | return; 159 | } 160 | 161 | $routeKey = isset($_GET['go']) && is_string($_GET['go']) ? $this->sanitizeRouteKey($_GET['go']) : null; 162 | 163 | $requestedRoute = $routeKey !== null ? (Route::tryFrom($routeKey) ?? $routeKey) : null; 164 | 165 | if ($routeKey === null) { 166 | $this->postController->renderHome(); 167 | $this->logRequest(); 168 | return; 169 | } 170 | 171 | try { 172 | match ($requestedRoute) { 173 | Route::SITEMAP => $this->feedController->renderSitemap(), 174 | Route::RSS => $this->feedController->renderRSS(), 175 | Route::RANDOM => $this->postController->renderRandomPost(), 176 | Route::LATEST => $this->postController->renderLatestPost(), 177 | Route::LATEST_YEAR => $this->postController->renderLatestYear(), 178 | Route::LATEST_MONTH => $this->postController->renderLatestMonth(), 179 | Route::LATEST_WEEK => $this->postController->renderLatestWeek(), 180 | Route::LATEST_DAY => $this->postController->renderLatestDay(), 181 | default => $this->handleDynamicRoute($routeKey), 182 | }; 183 | $this->logRequest(); 184 | } catch (\Exception) { 185 | $this->postController->handleNotFound(); 186 | } 187 | } 188 | 189 | /** 190 | * Log the request using Logger. 191 | */ 192 | private function logRequest(): void 193 | { 194 | $status = http_response_code(); 195 | if (Config::get()->enableLogging && $status == 200) { 196 | Logger::getInstance(Config::get()->logFilePath)->log(); 197 | } 198 | } 199 | 200 | /** 201 | * Checks if the request is for favicon.ico 202 | * 203 | * @param string $uri The requested URI 204 | * @return bool 205 | */ 206 | private function isFaviconRequest(string $uri): bool 207 | { 208 | return preg_match('~^/[^/]*?/favicon\.ico$~', $uri) || preg_match('~^/favicon\.ico$~', $uri); 209 | } 210 | 211 | /** 212 | * Handles the favicon.ico request by serving the root favicon.ico 213 | */ 214 | private function handleFaviconRequest(): void 215 | { 216 | $faviconPath = $_SERVER['DOCUMENT_ROOT'] . '/favicon.ico'; 217 | 218 | if (file_exists($faviconPath)) { 219 | header('Content-Type: image/vnd.microsoft.icon'); 220 | readfile($faviconPath); 221 | } else { 222 | $this->postController->handleNotFound(); 223 | } 224 | } 225 | 226 | /** 227 | * Sanitizes the route key parameter. 228 | * 229 | * @param string $routeKey The route key to sanitize. 230 | * @return string The sanitized route key. 231 | */ 232 | private function sanitizeRouteKey(string $routeKey): string 233 | { 234 | $normalized = \Normalizer::normalize($routeKey, \Normalizer::FORM_D); 235 | $sanitized = preg_replace('/[\p{Mn}\p{Me}\p{Cf}]/u', '', $normalized); 236 | return $sanitized; 237 | } 238 | 239 | /** 240 | * Sanitizes a slug parameter. 241 | * 242 | * @param string $slug The slug to sanitize. 243 | * @return string The sanitized slug. 244 | */ 245 | private function sanitizeSlug(string $slug): string 246 | { 247 | return preg_replace('/[^a-zA-Z0-9_-]/', '', $slug); 248 | } 249 | 250 | /** 251 | * Handles dynamic routes not predefined in the Route enum. 252 | * Could be Route::Search & others instead maybe. 253 | * 254 | * @param string $route the route string from the 'go' parameter 255 | */ 256 | private function handleDynamicRoute(string $route): void 257 | { 258 | if ($post = $this->postController->getRequestedPost($route)) { 259 | $this->postController->renderFullPost($post); 260 | 261 | return; 262 | } 263 | 264 | if ($category = StringUtils::extractCategoryFromRSS($route)) { 265 | $this->feedController->renderRSSByCategory($category); 266 | 267 | return; 268 | } 269 | 270 | if (Validator::isDateRoute($route)) { 271 | $this->postController->renderPostsByDate($route); 272 | 273 | return; 274 | } 275 | 276 | if (Validator::isValidCategoryPath($route)) { 277 | $this->postController->renderPostsByCategory($route); 278 | 279 | return; 280 | } 281 | 282 | if (Validator::isDraftsRoute($route)) { 283 | try { 284 | $slug = $this->sanitizeSlug(substr($route, 7)); 285 | $this->postController->renderDraft($slug); 286 | } catch (\Weblog\Exception\NotFoundException $e) { 287 | $this->postController->handleNotFound(); 288 | } 289 | return; 290 | } 291 | 292 | if (Validator::isSearchRoute($route)) { 293 | $matches = []; 294 | preg_match('#^search/(.+)$#', $route, $matches); 295 | $this->postController->renderSearchResults(urldecode($matches[1])); 296 | 297 | return; 298 | } 299 | 300 | if (Validator::isSelectedRoute($route)) { 301 | $this->postController->renderSelectedPosts(); 302 | 303 | return; 304 | } 305 | 306 | $this->postController->handleNotFound(); 307 | } 308 | } 309 | -------------------------------------------------------------------------------- /src/Exception/NotFoundException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Exception; 25 | 26 | final class NotFoundException extends \Exception {} 27 | -------------------------------------------------------------------------------- /src/Model/Entity/Author.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Model\Entity; 25 | 26 | use Weblog\Config; 27 | 28 | final class Author 29 | { 30 | /** 31 | * Initializes a new Author with specified details. 32 | * 33 | * @param string $name the name of the author 34 | * @param string $email the email address of the author 35 | * @param string $location the city or country of the author 36 | * @param string $aboutText a brief description or bio of the author 37 | */ 38 | public function __construct( 39 | private readonly string $name = 'Unknown', 40 | private readonly string $email = 'no-reply@example.com', 41 | private readonly string $location = '', 42 | private string $aboutText = '', 43 | ) {} 44 | 45 | public function getName(): string 46 | { 47 | return $this->name; 48 | } 49 | 50 | public function getEmail(): string 51 | { 52 | return $this->email; 53 | } 54 | 55 | public function getAbout(): string 56 | { 57 | return $this->aboutText; 58 | } 59 | 60 | public function getInformation(): string 61 | { 62 | return $this->email ?? $this->name; 63 | } 64 | 65 | public function getLocation(): string 66 | { 67 | return $this->location; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Model/Entity/Post.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Model\Entity; 25 | 26 | use Weblog\Config; 27 | use Weblog\Utils\StringUtils; 28 | use Weblog\Model\Enum\Beautify; 29 | 30 | final class Post 31 | { 32 | /** 33 | * Constructs a new Post instance with specified properties. 34 | * 35 | * @param string $title the title of the Post 36 | * @param string $path the file path of the Post 37 | * @param \DateTimeImmutable $date the last modified date the Post 38 | * @param bool $isDraft indicates if the Post is a draft 39 | * @param bool $isHidden indicates if the Post is hidden 40 | */ 41 | public function __construct( 42 | private readonly string $title, 43 | private readonly string $path, 44 | private readonly \DateTimeImmutable $date, 45 | private bool $isDraft = false, 46 | private bool $isHidden = false 47 | ) { 48 | $this->isDraft = $isDraft; 49 | $this->isHidden = $isHidden; 50 | } 51 | 52 | /** 53 | * Checks if the post is a draft. 54 | * 55 | * @return bool True if the post is a draft, false otherwise. 56 | */ 57 | public function isDraft(): bool 58 | { 59 | return $this->isDraft; 60 | } 61 | 62 | /** 63 | * Checks if the post is hidden. 64 | * 65 | * @param bool $checkPath Whether to check the path for hidden directories. 66 | * 67 | * @return bool True if the post is hidden, false otherwise. 68 | */ 69 | public function isHidden(bool $checkPath = true): bool 70 | { 71 | return $this->isHidden || ($checkPath && $this->isHiddenPath($this->path)); 72 | } 73 | 74 | /** 75 | * Determines if any component in the path is hidden. 76 | * 77 | * @param string $path The path to check. 78 | * @return bool Returns true if the path contains hidden components, false otherwise. 79 | */ 80 | private function isHiddenPath(string $path): bool 81 | { 82 | $pathParts = explode(DIRECTORY_SEPARATOR, $path); 83 | foreach ($pathParts as $part) { 84 | if (str_starts_with($part, '.')) { 85 | return true; 86 | } 87 | } 88 | return false; 89 | } 90 | 91 | /** 92 | * Creates an instance from a file. 93 | * 94 | * This method extracts the title from the file's name, uses the full path as the path, 95 | * and sets the date based on the file's last modification time. 96 | * 97 | * @param \SplFileInfo $file the file from which to create the instance 98 | * @param bool $isDraft indicates if the file represents a draft 99 | * 100 | * @return self returns a Post instance populated with data from the file 101 | */ 102 | public static function createFromFile(\SplFileInfo $file, bool $isDraft = false): self { 103 | $date = new \DateTimeImmutable('@'.$file->getMTime()); 104 | $date = $date->setTimezone(new \DateTimeZone(date_default_timezone_get())); 105 | $isDraft = strpos($file->getPathname(), '/drafts/') !== false; 106 | $isHidden = str_starts_with(basename($file->getFilename(), '.'), '.'); 107 | 108 | return new self( 109 | title: basename($file->getFilename(), '.txt'), 110 | path: $file->getPathname(), 111 | date: $date, 112 | isDraft: $isDraft, 113 | isHidden: $isHidden 114 | ); 115 | } 116 | 117 | public function isSelected(): bool 118 | { 119 | return str_starts_with($this->title, '*'); 120 | } 121 | 122 | public function getTitle(): string 123 | { 124 | $title = ltrim($this->title, '*.'); 125 | 126 | $hideSelected = Config::get()->hideSelected; 127 | 128 | if ($this->isSelected() && !$hideSelected) { 129 | $title .= Config::get()->beautify === Beautify::OFF ? ' *' : ' ★'; 130 | } 131 | 132 | return $title; 133 | } 134 | 135 | public function getSlug(): string 136 | { 137 | return StringUtils::slugify($this->getTitle()); 138 | } 139 | 140 | public function getPath(): string 141 | { 142 | return $this->path; 143 | } 144 | 145 | public function getDate(): \DateTimeImmutable 146 | { 147 | return $this->date; 148 | } 149 | 150 | public function getDatetimestamp(): int 151 | { 152 | return $this->date->getTimestamp(); 153 | } 154 | 155 | public function getFormattedDate(string $format = 'Y-m-d'): string 156 | { 157 | return $this->date->format($format); 158 | } 159 | 160 | public function getCategory(): string 161 | { 162 | $relativePath = str_replace(Config::get()->weblogDir, '', $this->getPath()); 163 | $pathParts = explode('/', trim($relativePath, '/')); 164 | 165 | $category = (\count($pathParts) > 1) ? ucfirst(ltrim($pathParts[0], '.')) : 'Misc'; 166 | 167 | return $category; 168 | } 169 | 170 | public function getContent(): string 171 | { 172 | $content = file_get_contents($this->path); 173 | 174 | return false === $content ? '' : $content; 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/Model/Enum/Beautify.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Model\Enum; 25 | 26 | /** 27 | * Defines possible values of beautify config. 28 | */ 29 | enum Beautify: string 30 | { 31 | case OFF = 'Off'; 32 | case ALL = 'All'; 33 | case CONTENT = 'Content'; 34 | case RSS = 'RSS'; 35 | } 36 | -------------------------------------------------------------------------------- /src/Model/Enum/ContentType.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Model\Enum; 25 | 26 | /** 27 | * Defines content types used within the weblog system. 28 | */ 29 | enum ContentType: string 30 | { 31 | case TEXT = 'text/plain'; 32 | case XML = 'application/xml'; 33 | } 34 | -------------------------------------------------------------------------------- /src/Model/Enum/ShowUrls.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Model\Enum; 25 | 26 | /** 27 | * Defines possible values of show_urls config. 28 | */ 29 | enum ShowUrls: string 30 | { 31 | case OFF = 'Off'; 32 | case FULL = 'Full'; 33 | case SHORT = 'Short'; 34 | } 35 | -------------------------------------------------------------------------------- /src/Model/PostCollection.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Model; 25 | 26 | use Weblog\Model\Entity\Post; 27 | use Weblog\Utils\Validator; 28 | 29 | final class PostCollection implements \IteratorAggregate, \Countable 30 | { 31 | /** 32 | * Constructs a collection of posts. 33 | * 34 | * @param Post[] $posts array of Post objects 35 | */ 36 | public function __construct(private array $posts = []) {} 37 | 38 | /** 39 | * Adds a Post object to the collection. 40 | * 41 | * @param Post $post the post to add to the collection 42 | */ 43 | public function add(Post $post): void 44 | { 45 | $this->posts[] = $post; 46 | } 47 | 48 | /** 49 | * Sorts the posts in the collection by date, from newest to oldest. 50 | */ 51 | public function sort(): void 52 | { 53 | usort($this->posts, static fn ($a, $b) => $b->getDateTimestamp() - $a->getDateTimestamp()); 54 | } 55 | 56 | /** 57 | * Checks if the post collection is empty. 58 | * 59 | * @return bool returns true if the collection is empty, false otherwise 60 | */ 61 | public function isEmpty(): bool 62 | { 63 | return [] === $this->posts; 64 | } 65 | 66 | /** 67 | * Retrieves the most recent date from the posts in the collection. 68 | * 69 | * @return null|\DateTimeImmutable returns the date of the most recent post in the collection, or null if the collection is empty 70 | */ 71 | public function getMostRecentDate(): ?\DateTimeImmutable 72 | { 73 | if ($this->isEmpty()) { 74 | return null; 75 | } 76 | 77 | $this->sort(); 78 | 79 | return $this->posts[0]->getDate(); 80 | } 81 | 82 | /** 83 | * Filters posts by date, comparing only the date part. 84 | * 85 | * @param \DateTimeImmutable $date the date to match posts against 86 | * 87 | * @return PostCollection returns a new PostCollection containing only posts that match the given date 88 | */ 89 | public function filterByDate(\DateTimeImmutable $date): self 90 | { 91 | $filteredPosts = []; 92 | foreach ($this->posts as $post) { 93 | if (Validator::dateMatches($date, $post)) { 94 | $filteredPosts[] = $post; 95 | } 96 | } 97 | 98 | return new self($filteredPosts); 99 | } 100 | 101 | /** 102 | * Generates a string of the range of years for the posts. 103 | * 104 | * @return string a formatted string, empty if no posts are present in the 105 | */ 106 | public function getYearRange(): string 107 | { 108 | if (empty($this->posts)) { 109 | return ''; 110 | } 111 | 112 | $dates = array_map(static fn (Post $post) => $post->getDate(), $this->posts); 113 | 114 | $minYear = min($dates)->format('Y'); 115 | $maxYear = max($dates)->format('Y'); 116 | 117 | return $minYear === $maxYear ? $minYear : "{$minYear}-{$maxYear}"; 118 | } 119 | 120 | /** 121 | * Selects a random post from the collection. 122 | * 123 | * @return Post the selected random post 124 | */ 125 | public function getRandomPost(): Post 126 | { 127 | $randomIndex = array_rand($this->posts); 128 | 129 | return $this->posts[$randomIndex]; 130 | } 131 | 132 | /** 133 | * Returns the first post in the collection. 134 | * 135 | * @return Post|null returns the first Post object in the collection, or null if the collection is empty 136 | */ 137 | public function getFirstPost(): ?Post 138 | { 139 | return $this->isEmpty() ? null : $this->posts[0]; 140 | } 141 | 142 | /** 143 | * Returns the 1-based index of the provided post in the collection or null if not found. 144 | * 145 | * @param Post $post the post to find the index of 146 | * 147 | * @return string the index of the post as a string 148 | */ 149 | public function getPostIndex(Post $post): string 150 | { 151 | $reversedPosts = array_reverse($this->posts); 152 | $index = array_search($post, $reversedPosts, true); 153 | 154 | if (false === $index) { 155 | throw new \InvalidArgumentException('Post not found in collection.'); 156 | } 157 | 158 | $index = (int) $index; 159 | 160 | return (string) ($index + 1); 161 | } 162 | 163 | public function getIterator(): \ArrayIterator 164 | { 165 | return new \ArrayIterator($this->posts); 166 | } 167 | 168 | public function count(): int 169 | { 170 | return \count($this->posts); 171 | } 172 | 173 | /** 174 | * @param callable $callback a callback function that returns true if the post should be included 175 | * 176 | * @return PostCollection a new collection with the filtered posts 177 | */ 178 | public function filter(callable $callback): self 179 | { 180 | $filteredPosts = array_filter($this->posts, $callback); 181 | 182 | return new self($filteredPosts); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/Model/PostRepository.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Model; 25 | 26 | use Weblog\Model\Entity\Post; 27 | use Weblog\Utils\Validator; 28 | use Weblog\Utils\StringUtils; 29 | 30 | final class PostRepository 31 | { 32 | private \Iterator $iterator; 33 | 34 | /** 35 | * Constructor for PostRepository. 36 | * Initializes the iterator based on the given directory or the default directory specified in configuration. 37 | * 38 | * @param string $directory The directory path to initialize the iterator 39 | */ 40 | public function __construct(private string $directory) 41 | { 42 | $this->loadIterator(); 43 | } 44 | 45 | /** 46 | * Fetches all posts from the weblog directory, sorted from newest to oldest. 47 | * 48 | * @return PostCollection an array of Posts objects inside a PostCollection 49 | */ 50 | public function fetchAllPosts(): PostCollection 51 | { 52 | $posts = new PostCollection(); 53 | foreach ($this->iterator as $file) { 54 | if ($file instanceof \SplFileInfo) { 55 | $post = Post::createFromFile($file); 56 | if ($post->isHidden() || $post->isDraft()) { 57 | continue; 58 | } 59 | $posts->add($post); 60 | } 61 | } 62 | $posts->sort(); 63 | return $posts; 64 | } 65 | 66 | /** 67 | * Retrieves the specific post based on the requested slug. 68 | * 69 | * @param string $slug the slug of the post to find 70 | * @param null|string $directory the path of the directory to search 71 | * 72 | * @return null|Post the file info of the requested post or null if not found 73 | */ 74 | public function fetchPostInDirectory(string $slug, ?string $directory = null): ?Post 75 | { 76 | $this->setDirectory($directory ?? $this->directory); 77 | foreach ($this->iterator as $file) { 78 | if ($file instanceof \SplFileInfo) { 79 | $post = Post::createFromFile($file); 80 | $postSlug = ltrim($post->getSlug(), '.'); 81 | 82 | if ($slug === $postSlug && !$post->isDraft()) { 83 | return $post; 84 | } 85 | } 86 | } 87 | 88 | return null; 89 | } 90 | 91 | /** 92 | * Fetches posts filtered by a specific date. If no date is provided, the current date is used. 93 | * 94 | * @param \DateTimeImmutable $date The date to filter the posts by. Defaults to the current date if null. 95 | * 96 | * @return PostCollection returns a collection of posts that match the given date 97 | */ 98 | public function fetchPostsByDate(\DateTimeImmutable $date, string $precision): PostCollection 99 | { 100 | $posts = $this->fetchAllPosts(); 101 | 102 | return $posts->filter(static function (Post $post) use ($date, $precision) { 103 | switch ($precision) { 104 | case 'year': 105 | return $date->format('Y') === $post->getDate()->format('Y'); 106 | case 'month': 107 | return $date->format('Y-m') === $post->getDate()->format('Y-m'); 108 | case 'day': 109 | return $date->format('Y-m-d') === $post->getDate()->format('Y-m-d'); 110 | default: 111 | return false; 112 | } 113 | }); 114 | } 115 | 116 | /** 117 | * Fetches all posts from a specified category. 118 | * 119 | * This method filters the posts based on the given category. It includes posts 120 | * that are within the specified hidden category but excludes posts that start with a dot. 121 | * 122 | * @param string $category The category to filter posts by. 'misc' will also fetch posts that do not belong to any category. 123 | * 124 | * @return PostCollection returns a collection of posts filtered by the specified category. 125 | */ 126 | public function fetchPostsByCategory(string $category): PostCollection 127 | { 128 | $posts = new PostCollection(); 129 | foreach ($this->iterator as $file) { 130 | if ($file instanceof \SplFileInfo) { 131 | $post = Post::createFromFile($file); 132 | $categorySlug = StringUtils::slugify($category); 133 | 134 | if ($post->isHidden($checkPath = false)) { 135 | continue; 136 | } 137 | 138 | $directoryName = ltrim($file->getPathInfo()->getFilename(), '.'); 139 | if (($categorySlug === StringUtils::slugify($directoryName)) || Validator::isValidCategoryPost($file, $category, $this->directory)) { 140 | if (!$post->isDraft()) { 141 | $posts->add($post); 142 | } 143 | } 144 | } 145 | } 146 | 147 | $posts->sort(); 148 | 149 | return $posts; 150 | } 151 | 152 | /** 153 | * Retrieves the range of years (earliest and latest) from all posts. 154 | * 155 | * @return string range of years for all posts 156 | */ 157 | public function getPostYearsRange(): string 158 | { 159 | $posts = $this->fetchAllPosts(); 160 | 161 | return $posts->getYearRange(); 162 | } 163 | 164 | /** 165 | * Fetches posts from a specific date. 166 | * 167 | * @param \DateTimeImmutable $date The date to start from 168 | * 169 | * @return PostCollection returns a collection of posts from the specified date 170 | */ 171 | public function fetchPostsFromDate(\DateTimeImmutable $date): PostCollection 172 | { 173 | $posts = new PostCollection(); 174 | foreach ($this->iterator as $file) { 175 | if ($file instanceof \SplFileInfo) { 176 | $post = Post::createFromFile($file); 177 | if ($post->getDate() >= $date) { 178 | $posts->add($post); 179 | } 180 | } 181 | } 182 | $posts->sort(); 183 | 184 | return $posts; 185 | } 186 | 187 | /** 188 | * Fetches posts from a specific date range. 189 | * 190 | * @param \DateTimeImmutable $startDate The start date of the range 191 | * @param \DateTimeImmutable $endDate The end date of the range 192 | * 193 | * @return PostCollection returns a collection of posts from the specified date range 194 | */ 195 | public function fetchPostsFromDateRange(\DateTimeImmutable $startDate, \DateTimeImmutable $endDate): PostCollection 196 | { 197 | $posts = new PostCollection(); 198 | foreach ($this->iterator as $file) { 199 | if ($file instanceof \SplFileInfo) { 200 | $post = Post::createFromFile($file); 201 | if ($post->getDate() >= $startDate && $post->getDate() <= $endDate && !$post->isDraft()) { 202 | $posts->add($post); 203 | } 204 | } 205 | } 206 | $posts->sort(); 207 | 208 | return $posts; 209 | } 210 | 211 | /** 212 | * Fetches selected posts from the weblog directory. 213 | * 214 | * A selected post is identified by an asterisk (*) at the beginning of its title. 215 | * The method collects all such posts and returns them sorted from newest to oldest. 216 | * 217 | * @return PostCollection an array of Post objects inside a PostCollection 218 | */ 219 | public function fetchSelectedPosts(): PostCollection 220 | { 221 | $posts = new PostCollection(); 222 | foreach ($this->iterator as $file) { 223 | if ($file instanceof \SplFileInfo) { 224 | $post = Post::createFromFile($file); 225 | if ($post->isSelected() && !$post->isDraft()) { 226 | $posts->add($post); 227 | } 228 | } 229 | } 230 | $posts->sort(); 231 | 232 | return $posts; 233 | } 234 | 235 | /** 236 | * Searches posts by query. 237 | * 238 | * @param string $query the search query 239 | * 240 | * @return PostCollection returns a collection of posts matching the query 241 | */ 242 | public function searchPosts(string $query): PostCollection 243 | { 244 | $posts = new PostCollection(); 245 | foreach ($this->iterator as $file) { 246 | if ($file instanceof \SplFileInfo) { 247 | $post = Post::createFromFile($file); 248 | if (!$post->isDraft() && 249 | (StringUtils::containsIgnoreCaseAndDiacritics($post->getTitle(), $query) || 250 | StringUtils::containsIgnoreCaseAndDiacritics($post->getContent(), $query)) 251 | ) { 252 | $posts->add($post); 253 | } 254 | } 255 | } 256 | $posts->sort(); 257 | 258 | return $posts; 259 | } 260 | 261 | /** 262 | * Fetches a draft by its slug. 263 | * 264 | * @param string $slug The slug of the draft to find. 265 | * @return null|Post The draft post or null if not found. 266 | */ 267 | public function fetchDraftBySlug(string $slug): ?Post 268 | { 269 | $draftsDir = $this->directory . '/drafts'; 270 | if (!is_dir($draftsDir)) { 271 | throw new \RuntimeException("Drafts directory not found: {$draftsDir}"); 272 | } 273 | $iterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($draftsDir, \RecursiveDirectoryIterator::SKIP_DOTS)); 274 | foreach ($iterator as $file) { 275 | if ($file instanceof \SplFileInfo) { 276 | $post = Post::createFromFile($file, true); 277 | if ($slug === $post->getSlug()) { 278 | return $post; 279 | } 280 | } 281 | } 282 | return null; 283 | } 284 | 285 | /** 286 | * Sets the directory for the iterator and resets the iterator to reflect the new directory. 287 | * This is necessary to ensure the iterator points to the correct directory. 288 | * 289 | * @param string $newDirectory the new directory path to set 290 | */ 291 | public function setDirectory(string $newDirectory): void 292 | { 293 | if (!is_dir($newDirectory)) { 294 | throw new \InvalidArgumentException("The specified directory does not exist or is not a directory: {$newDirectory}"); 295 | } 296 | 297 | $this->directory = $newDirectory; 298 | } 299 | 300 | private function loadIterator(): void 301 | { 302 | $directoryIterator = new \RecursiveDirectoryIterator($this->directory, \RecursiveDirectoryIterator::SKIP_DOTS); 303 | $recursiveIterator = new \RecursiveIteratorIterator($directoryIterator); 304 | 305 | $this->iterator = new \CallbackFilterIterator($recursiveIterator, function ($file) { 306 | return $file->isFile() && $file->getExtension() === 'txt'; 307 | }); 308 | } 309 | } 310 | -------------------------------------------------------------------------------- /src/Model/Route.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Model; 25 | 26 | enum Route: string 27 | { 28 | case SITEMAP = 'sitemap.xml'; 29 | case RSS = 'rss'; 30 | case RANDOM = 'random'; 31 | case LATEST = 'latest'; 32 | case LATEST_YEAR = 'latest/year'; 33 | case LATEST_MONTH = 'latest/month'; 34 | case LATEST_WEEK = 'latest/week'; 35 | case LATEST_DAY = 'latest/day'; 36 | } 37 | -------------------------------------------------------------------------------- /src/Utils/ContentFormatter.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Utils; 25 | 26 | use Weblog\Config; 27 | use Weblog\Model\Enum\Beautify; 28 | 29 | final class ContentFormatter 30 | { 31 | /** 32 | * Formats the content of a post into paragraphs. 33 | * 34 | * @param string $content the raw content of the post 35 | * 36 | * @return string the formatted content 37 | */ 38 | public static function formatPostContent(string $content): string 39 | { 40 | $paragraphs = preg_split('/\n\s*\n/', trim($content)); 41 | $formattedContent = ''; 42 | 43 | foreach ($paragraphs as $paragraph) { 44 | $paragraph = preg_replace('/`([^`]*)`/', '$1', $paragraph); 45 | 46 | if (!Validator::isMobileDevice()) { 47 | $trimmedParagraph = preg_replace('/([.!?]|\.{3})(["\'])?(\s)/', '$1$2 $3', trim($paragraph)); 48 | } else { 49 | $trimmedParagraph = trim($paragraph); 50 | } 51 | 52 | if (preg_match('/^(#+)\s*(.*)$/', $trimmedParagraph, $matches)) { 53 | $text = $matches[2]; 54 | if (!Validator::isMobileDevice()) { 55 | $formattedContent .= "\n" . TextUtils::centerText($text) . "\n\n\n"; 56 | } else { 57 | $formattedContent .= "\n" . " " . TextUtils::centerText($text) . "\n\n\n"; 58 | } 59 | continue; 60 | } 61 | 62 | if (str_starts_with($trimmedParagraph, '>')) { 63 | $formattedContent .= TextUtils::formatQuote($paragraph) . "\n\n"; 64 | } elseif (preg_match('/^(\d+)\.\s/', $trimmedParagraph, $matches) || preg_match('/^\* /', $trimmedParagraph)) { 65 | $formattedContent .= TextUtils::formatList($paragraph) . "\n\n"; 66 | } else { 67 | $lines = explode("\n", $trimmedParagraph); 68 | foreach ($lines as $line) { 69 | $formattedContent .= TextUtils::formatParagraph(trim($line)) . "\n"; 70 | } 71 | $formattedContent .= "\n"; 72 | } 73 | } 74 | 75 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::CONTENT])) { 76 | $formattedContent = StringUtils::beautifyText($formattedContent); 77 | } 78 | 79 | return rtrim($formattedContent) . "\n\n"; 80 | } 81 | 82 | /** 83 | * Formats the header of a post, including category, title, and publication date. 84 | * Adjusts dynamically based on device type and enabled settings. 85 | * 86 | * @param string $title the title of the post 87 | * @param string $category the category of the post (optional) 88 | * @param string $date the publication date of the post (optional) 89 | * 90 | * @return string the formatted header 91 | */ 92 | public static function formatPostHeader(string $title = '', string $category = '', string $date = ''): string 93 | { 94 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::CONTENT])) { 95 | $title = StringUtils::beautifyText($title); 96 | } 97 | 98 | $lineWidth = Config::get()->lineWidth; 99 | $includeCategory = Config::get()->showCategory && !empty($category); 100 | $includeDate = Config::get()->showDate && !empty($date); 101 | 102 | $availableWidth = $lineWidth; 103 | $categoryWidth = $includeCategory ? 20 : 0; 104 | $dateWidth = $includeDate ? 20 : 0; 105 | $titleWidth = $availableWidth - $categoryWidth - $dateWidth; 106 | 107 | if (mb_strlen($title) > 32) { 108 | $titleLines = wordwrap($title, 32, "\n", true); 109 | $titleParts = explode("\n", $titleLines); 110 | } else { 111 | $titleParts = [$title]; 112 | } 113 | 114 | $header = ''; 115 | 116 | foreach ($titleParts as $index => $titleLine) { 117 | $titlePaddingLeft = (int)(($titleWidth - mb_strlen($titleLine)) / 2); 118 | $titlePaddingRight = $titleWidth - mb_strlen($titleLine) - $titlePaddingLeft; 119 | 120 | if (Validator::isMobileDevice()) { 121 | $titlePaddingLeft += 1; 122 | } 123 | 124 | if ($index > 0) { 125 | $header .= "\n" . str_repeat(' ', $titlePaddingLeft + $categoryWidth) . $titleLine; 126 | } else { 127 | if ($includeCategory) { 128 | $header .= str_pad($category, $categoryWidth); 129 | } 130 | $header .= str_repeat(' ', $titlePaddingLeft) . $titleLine . str_repeat(' ', $titlePaddingRight); 131 | if ($includeDate) { 132 | if (Config::get()->shortenDate) { 133 | $date = (new \DateTime($date))->format('j M \'y'); 134 | } 135 | $header .= str_pad($date, $dateWidth, ' ', STR_PAD_LEFT); 136 | } 137 | } 138 | } 139 | 140 | return StringUtils::capitalizeText($header); 141 | } 142 | 143 | /** 144 | * Formats the given content into RSS-compatible HTML. 145 | * 146 | * @param string $content the raw content to be formatted for RSS 147 | * 148 | * @return string the formatted content as HTML paragraphs 149 | */ 150 | public static function formatRssContent(string $content): string 151 | { 152 | $paragraphs = explode("\n", trim($content)); 153 | $formattedContent = ''; 154 | $insideBlockquote = false; 155 | $blockquoteContent = ''; 156 | $insideList = false; 157 | $listContent = ''; 158 | $listType = ''; 159 | $listItems = []; 160 | 161 | foreach ($paragraphs as $paragraph) { 162 | $trimmedParagraph = trim($paragraph); 163 | 164 | if (preg_match('/^(#+)\s*(.*)$/', $trimmedParagraph, $matches)) { 165 | $level = strlen($matches[1]); 166 | $heading = htmlspecialchars($matches[2]); 167 | $tag = 'h' . min($level, 6); 168 | 169 | $formattedContent .= "<{$tag}>" . $heading . "\n"; 170 | continue; 171 | } 172 | 173 | if (str_starts_with($trimmedParagraph, '>')) { 174 | $quoteText = substr($trimmedParagraph, 1); 175 | if (!$insideBlockquote) { 176 | $insideBlockquote = true; 177 | $blockquoteContent .= '
'; 178 | } 179 | $blockquoteContent .= ltrim(htmlspecialchars($quoteText)) . '
'; 180 | } elseif (preg_match('/^(\d+)\.\s/', $trimmedParagraph, $matches) || preg_match('/^\* /', $trimmedParagraph)) { 181 | $listType = isset($matches[1]) ? 'ol' : 'ul'; 182 | 183 | if (!$insideList) { 184 | $insideList = true; 185 | $listItems = []; 186 | } 187 | 188 | $itemText = isset($matches[1]) ? trim(substr($trimmedParagraph, strlen($matches[0]))) : trim(substr($trimmedParagraph, 2)); 189 | $listItems[] = htmlspecialchars($itemText); 190 | } else { 191 | if ($insideBlockquote) { 192 | $insideBlockquote = false; 193 | $blockquoteContent .= '
'; 194 | $formattedContent .= $blockquoteContent; 195 | $blockquoteContent = ''; 196 | } 197 | 198 | if ($insideList) { 199 | $insideList = false; 200 | if (count($listItems) == 1 && $listType === 'ol') { 201 | $formattedContent .= '

' . $listItems[0] . '

'; 202 | } else { 203 | $listContent = $listType === 'ol' ? '
    ' : '
      '; 204 | foreach ($listItems as $item) { 205 | $listContent .= '
    • ' . $item . '
    • '; 206 | } 207 | $listContent .= $listType === 'ol' ? '
' : ''; 208 | $formattedContent .= $listContent; 209 | } 210 | $listItems = []; 211 | } 212 | 213 | if (!empty($trimmedParagraph)) { 214 | $formattedContent .= '

' . htmlspecialchars($trimmedParagraph) . '

'; 215 | } 216 | } 217 | } 218 | 219 | if ($insideBlockquote) { 220 | $blockquoteContent .= ''; 221 | $formattedContent .= $blockquoteContent; 222 | } 223 | 224 | if ($insideList && count($listItems) == 1 && $listType === 'ol') { 225 | $formattedContent .= '

' . $listItems[0] . '

'; 226 | } elseif ($insideList) { 227 | $listContent = $listType === 'ol' ? '
    ' : '
      '; 228 | foreach ($listItems as $item) { 229 | $listContent .= '
    • ' . $item . '
    • '; 230 | } 231 | $listContent .= $listType === 'ol' ? '
' : ''; 232 | $formattedContent .= $listContent; 233 | } 234 | 235 | $formattedContent = str_replace('
', '', $formattedContent); 236 | 237 | return $formattedContent; 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/Utils/Factory.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Utils; 25 | 26 | use Weblog\Config; 27 | use Weblog\Controller\FeedController; 28 | use Weblog\Controller\PostController; 29 | use Weblog\Controller\Router; 30 | use Weblog\Model\PostRepository; 31 | 32 | final class Factory 33 | { 34 | /** 35 | * Create new Router instance. 36 | */ 37 | public static function createRouter(): Router 38 | { 39 | return new Router(self::createPostController(), self::createFeedController()); 40 | } 41 | 42 | private static function createPostController(): PostController 43 | { 44 | return new PostController(self::createPostRepostory()); 45 | } 46 | 47 | private static function createFeedController(): FeedController 48 | { 49 | return new FeedController(self::createPostRepostory()); 50 | } 51 | 52 | private static function createPostRepostory(): PostRepository 53 | { 54 | return new PostRepository(Config::get()->weblogDir); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/Utils/FeedGenerator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Utils; 25 | 26 | use Weblog\Config; 27 | use Weblog\Model\Entity\Post; 28 | use Weblog\Model\PostCollection; 29 | use Weblog\Model\Enum\Beautify; 30 | 31 | final class FeedGenerator 32 | { 33 | /** 34 | * Creates an XML sitemap from a list of posts. 35 | * 36 | * This method takes an array of Post objects and a domain string to construct 37 | * an XML sitemap compliant with the sitemap protocol. The sitemap lists all posts, 38 | * sorting them from the most recent based on their dates, and includes the main page. 39 | * 40 | * @param PostCollection $posts a collection of Post objects to be included in the sitemap 41 | * 42 | * @return \SimpleXMLElement the XML element of the generated sitemap 43 | */ 44 | public static function generateSiteMap(PostCollection $posts): \SimpleXMLElement 45 | { 46 | $sitemap = new \SimpleXMLElement(''); 47 | $lastmodDate = $posts->getMostRecentDate() ?? new \DateTimeImmutable(); 48 | self::appendXmlElement($sitemap, 'url', null, [], [ 49 | 'loc' => Config::get()->url.'/', 50 | 'lastmod' => $lastmodDate->format('Y-m-d'), 51 | 'priority' => '1.0', 52 | 'changefreq' => 'daily', 53 | ]); 54 | 55 | foreach ($posts as $post) { 56 | if (!$post instanceof Post) { 57 | continue; 58 | } 59 | self::appendXmlElement($sitemap, 'url', null, [], [ 60 | 'loc' => Config::get()->url.'/'.StringUtils::slugify($post->getTitle()).'/', 61 | 'lastmod' => $post->getFormattedDate(), 62 | 'priority' => '1.0', 63 | 'changefreq' => 'weekly', 64 | ]); 65 | } 66 | 67 | return $sitemap; 68 | } 69 | 70 | /** 71 | * Generates an RSS feed for a collection of posts. 72 | * 73 | * @param PostCollection $posts the collection of posts to be included in the feed 74 | * @param string $category the of posts 75 | * 76 | * @return \SimpleXMLElement the RSS feed 77 | */ 78 | public static function generateRSS(PostCollection $posts, string $category = ''): \SimpleXMLElement 79 | { 80 | $rss = new \SimpleXMLElement(''); 81 | $channel = $rss->addChild('channel'); 82 | $lastmodDate = $posts->getMostRecentDate() ?? new \DateTimeImmutable(); 83 | $titleSuffix = '' !== $category ? ' — '.ucfirst($category) : $category; 84 | 85 | $href = Config::get()->url . '/rss/'; 86 | if ($category !== '') { 87 | $href .= StringUtils::slugify($category) . '/'; 88 | } 89 | 90 | self::appendXmlElement($channel, 'title', Config::get()->author->getName().$titleSuffix); 91 | self::appendXmlElement($channel, 'link', Config::get()->url.'/'); 92 | self::appendAtomLink($channel, $href); 93 | self::appendXmlElement($channel, 'description', preg_split('/\n{3,}/', Config::get()->author->getAbout())[0] ?? ''); 94 | self::appendXmlElement($channel, 'language', 'en'); 95 | self::appendXmlElement($channel, 'generator', 'Weblog v'.Config::get()->version); 96 | self::appendXmlElement($channel, 'lastBuildDate', $lastmodDate->format(DATE_RSS)); 97 | 98 | self::appendPostItems($posts, $channel); 99 | 100 | return $rss; 101 | } 102 | 103 | /** 104 | * Appends an XML element with attributes to a parent XML element. 105 | * 106 | * @param \SimpleXMLElement $parent the parent XML element 107 | * @param string $name the tag name of the child element 108 | * @param null|string $value the value of the child element 109 | * @param array $attributes an associative array of attributes for the child element 110 | * @param array $subelements an associative array of subelements 111 | */ 112 | private static function appendXmlElement( 113 | \SimpleXMLElement $parent, 114 | string $name, 115 | ?string $value = null, 116 | array $attributes = [], 117 | array $subelements = [], 118 | ): void { 119 | if (null !== $value || !empty($subelements)) { 120 | $element = $parent->addChild($name, $value); 121 | foreach ($attributes as $key => $val) { 122 | $element->addAttribute($key, $val); 123 | } 124 | foreach ($subelements as $subName => $subValue) { 125 | if (!empty($subValue)) { 126 | $element->addChild($subName, $subValue); 127 | } 128 | } 129 | } 130 | } 131 | 132 | /** 133 | * Appends an Atom link to the channel element. 134 | * 135 | * @param \SimpleXMLElement $channel the parent channel XML element 136 | * @param string $href the href attribute for the Atom link 137 | */ 138 | private static function appendAtomLink(\SimpleXMLElement $channel, string $href): void 139 | { 140 | $atomLink = $channel->addChild('link', null, 'http://www.w3.org/2005/Atom'); 141 | $atomLink->addAttribute('href', $href); 142 | $atomLink->addAttribute('rel', 'self'); 143 | $atomLink->addAttribute('type', 'application/rss+xml'); 144 | } 145 | 146 | /** 147 | * Adds post items to the RSS channel. 148 | * 149 | * @param PostCollection $posts collection of posts to be included 150 | * @param \SimpleXMLElement $channel the channel XML element 151 | */ 152 | private static function appendPostItems(PostCollection $posts, \SimpleXMLElement $channel): void 153 | { 154 | foreach ($posts as $post) { 155 | if (!$post instanceof Post) { 156 | continue; 157 | } 158 | 159 | $item = $channel->addChild('item'); 160 | $title = htmlspecialchars($post->getTitle(), ENT_XML1, 'UTF-8'); 161 | 162 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::RSS])) { 163 | $title = StringUtils::beautifyText($title); 164 | } 165 | 166 | self::appendXmlElement($item, 'title', $title); 167 | foreach (['guid', 'link'] as $tag) { 168 | self::appendXmlElement($item, $tag, Config::get()->url.'/'.$post->getSlug().'/'); 169 | } 170 | self::appendXmlElement($item, 'pubDate', $post->getFormattedDate(DATE_RSS)); 171 | self::appendXmlElement($item, 'category', $post->getCategory()); 172 | 173 | $description = $post->getContent(); 174 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::RSS])) { 175 | $description = StringUtils::beautifyText($description); 176 | } 177 | $description = self::formatHyperlinks($description); 178 | $description = self::formatCode($description); 179 | self::appendXmlElement($item, 'description', ContentFormatter::formatRssContent($description)); 180 | } 181 | } 182 | 183 | /** 184 | * Converts plain text URLs to hyperlinks. 185 | * 186 | * @param string $text The input text containing URLs. 187 | * 188 | * @return string The text with URLs converted to hyperlinks. 189 | */ 190 | private static function formatHyperlinks(string $text): string 191 | { 192 | $protocol = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) 193 | ? 'https://' 194 | : 'http://'; 195 | 196 | $text = preg_replace_callback('/^> ([^\s]+\.[^\s]+)/m', function ($matches) { 197 | return $matches[1]; 198 | }, $text); 199 | 200 | return preg_replace_callback( 201 | '/(?' . $match . ''; 207 | } 208 | 209 | return '' . $match . ''; 210 | }, 211 | $text 212 | ); 213 | } 214 | 215 | /** 216 | * Formats backticked text as code in RSS. 217 | * 218 | * @param string $text the raw text potentially containing backticked code 219 | * 220 | * @return string the formatted text for RSS 221 | */ 222 | private static function formatCode(string $text): string 223 | { 224 | $pattern = '/`([^`]*)`/'; 225 | $replacePairs = [ 226 | '“' => '"', 227 | '”' => '"', 228 | '‘' => "'", 229 | '’' => "'", 230 | '—' => '-', 231 | ]; 232 | 233 | $callback = function ($matches) use ($replacePairs) { 234 | return '' . strtr($matches[1], $replacePairs) . ''; 235 | }; 236 | 237 | return preg_replace_callback($pattern, $callback, $text); 238 | } 239 | } 240 | -------------------------------------------------------------------------------- /src/Utils/HttpUtils.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Utils; 25 | 26 | /** 27 | * Utility class for common HTTP operations. 28 | */ 29 | final class HttpUtils 30 | { 31 | /** 32 | * Redirects to a specified URL with a given status code. 33 | * 34 | * ..."Have you mooed today?"... 35 | * 36 | * @param string $url The URL to redirect to. 37 | * @param int $statusCode The HTTP status code for the redirection. Default is 301 (Moved Permanently). 38 | * @throws \InvalidArgumentException if the provided status code is not 301 or 302. 39 | */ 40 | public static function redirect(string $url, int $statusCode = 301): void 41 | { 42 | if (!in_array($statusCode, [301, 302], true)) { 43 | throw new \InvalidArgumentException("Invalid HTTP status code: $statusCode. Only 301 and 302 are supported."); 44 | } 45 | 46 | $messages = [ 47 | 301 => "Moved Permanently", 48 | 302 => "Found" 49 | ]; 50 | 51 | $message = $messages[$statusCode]; 52 | 53 | header("Location: $url", true, $statusCode); 54 | 55 | if ($statusCode === 301 && random_int(1, 10) === 1) { 56 | echo << 59 | --------------------- 60 | \ ^__^ 61 | \ (oo)\_______ 62 | (__)\ )\/\ 63 | ||----w | 64 | || || 65 | 66 | $url 67 | EOT; 68 | 69 | } else { 70 | echo "$statusCode $message\n\n$url"; 71 | } 72 | 73 | exit; 74 | } 75 | 76 | /** 77 | * Retrieves the current scheme (http or https) of the request. 78 | * 79 | * @return string The current scheme. 80 | */ 81 | public static function getScheme(): string 82 | { 83 | return (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off' || $_SERVER['SERVER_PORT'] == 443) ? "https" : "http"; 84 | } 85 | 86 | /** 87 | * Retrieves the current host of the request. 88 | * 89 | * @return string The host. 90 | */ 91 | public static function getHost(): string 92 | { 93 | return $_SERVER['HTTP_HOST'] ?? 'localhost'; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/Utils/Logger.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Utils; 25 | 26 | use Weblog\Config; 27 | 28 | final class Logger 29 | { 30 | private static ?self $instance = null; 31 | private string $logFilePath; 32 | 33 | /** 34 | * Private constructor to prevent direct instantiation. 35 | */ 36 | private function __construct(string $logFilePath) 37 | { 38 | $this->logFilePath = $logFilePath; 39 | } 40 | 41 | /** 42 | * Get the singleton instance of Logger. 43 | */ 44 | public static function getInstance(string $logFilePath): self 45 | { 46 | if (null === self::$instance) { 47 | self::$instance = new self($logFilePath); 48 | } 49 | return self::$instance; 50 | } 51 | 52 | /** 53 | * Log the access information in Nginx format. 54 | */ 55 | public function log(): void 56 | { 57 | if (!isset($_SERVER['REMOTE_ADDR']) || !isset($_SERVER['REQUEST_METHOD']) || !isset($_SERVER['REQUEST_URI']) || !isset($_SERVER['SERVER_PROTOCOL'])) { 58 | return; 59 | } 60 | 61 | $ip = $_SERVER['REMOTE_ADDR']; 62 | $method = $_SERVER['REQUEST_METHOD']; 63 | $uri = $_SERVER['REQUEST_URI']; 64 | $protocol = $_SERVER['SERVER_PROTOCOL']; 65 | $status = http_response_code(); 66 | $size = ob_get_length(); 67 | $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? '-'; 68 | $referer = $_SERVER['HTTP_REFERER'] ?? '-'; 69 | 70 | $filterWords = Config::get()->logFilterWords; 71 | if (!empty($filterWords)) { 72 | foreach ($filterWords as $word) { 73 | if (stripos($uri, $word) !== false) { 74 | return; 75 | } 76 | } 77 | } 78 | 79 | $filterAgents = Config::get()->logFilterAgents; 80 | if (!empty($filterAgents)) { 81 | foreach ($filterAgents as $agent) { 82 | if (stripos($userAgent, $agent) !== false) { 83 | return; 84 | } 85 | } 86 | } 87 | 88 | $logEntry = sprintf( 89 | "%s - - [%s] \"%s %s %s\" %d %d \"%s\" \"%s\"", 90 | $ip, 91 | date('d/M/Y:H:i:s O'), 92 | $method, 93 | $uri, 94 | $protocol, 95 | $status, 96 | $size, 97 | $referer, 98 | $userAgent 99 | ); 100 | 101 | file_put_contents($this->logFilePath, $logEntry.PHP_EOL, FILE_APPEND | LOCK_EX); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/Utils/StringUtils.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Utils; 25 | 26 | use Weblog\Config; 27 | use Weblog\Model\Enum\ShowUrls; 28 | 29 | final class StringUtils 30 | { 31 | /** 32 | * Converts a string to a URL-friendly slug, ensuring non-ASCII characters are appropriately replaced. 33 | * 34 | * @param string $title the string to slugify 35 | * 36 | * @return string the slugified string 37 | */ 38 | public static function slugify($title): string 39 | { 40 | $title = ltrim($title, '.'); 41 | $title = mb_strtolower($title, 'UTF-8'); 42 | $replacements = [ 43 | '/а/u' => 'a', '/б/u' => 'b', '/в/u' => 'v', '/г/u' => 'g', '/д/u' => 'd', 44 | '/е/u' => 'e', '/ё/u' => 'yo', '/ж/u' => 'zh', '/з/u' => 'z', '/и/u' => 'i', 45 | '/й/u' => 'y', '/к/u' => 'k', '/л/u' => 'l', '/м/u' => 'm', '/н/u' => 'n', 46 | '/о/u' => 'o', '/п/u' => 'p', '/р/u' => 'r', '/с/u' => 's', '/т/u' => 't', 47 | '/у/u' => 'u', '/ф/u' => 'f', '/х/u' => 'h', '/ц/u' => 'ts', '/ч/u' => 'ch', 48 | '/ш/u' => 'sh', '/щ/u' => 'sch', '/ъ/u' => '', '/ы/u' => 'y', '/ь/u' => '', 49 | '/э/u' => 'e', '/ю/u' => 'yu', '/я/u' => 'ya', 50 | ]; 51 | $title = preg_replace(array_keys($replacements), array_values($replacements), $title); 52 | 53 | if (null === $title) { 54 | throw new \RuntimeException('Failed to slugify title.'); 55 | } 56 | 57 | $title = preg_replace('/[\'"‘’“”«»]/u', '', $title); 58 | 59 | $title = preg_replace_callback('/(?<=[a-z])\'(?=[a-z])/i', function() { 60 | return '-'; 61 | }, $title); 62 | 63 | $title = iconv('UTF-8', 'ASCII//TRANSLIT', $title) ?: ''; 64 | $title = preg_replace('/[^a-z0-9\s-]/', '-', $title) ?: ''; 65 | $title = preg_replace('/\s+/', '-', $title ?: ''); 66 | $title = preg_replace('/-+/', '-', $title); 67 | 68 | if (null === $title) { 69 | throw new \RuntimeException('Failed to slugify title.'); 70 | } 71 | 72 | $title = trim($title, '-'); 73 | 74 | if ('' === $title) { 75 | throw new \RuntimeException("Failed to generate a valid slug from title: {$title}"); 76 | } 77 | 78 | return $title; 79 | } 80 | 81 | /** 82 | * Removes diacritics from the given string. 83 | * 84 | * This function replaces diacritic characters with their closest ASCII equivalents. 85 | * 86 | * @param string $text The text from which diacritics should be removed. 87 | * @return string The text with diacritics removed. 88 | */ 89 | public static function removeDiacritics(string $text): string 90 | { 91 | $normalizeChars = array( 92 | 'Š'=>'S', 'š'=>'s', 'Ž'=>'Z', 'ž'=>'z', 'À'=>'A', 'Á'=>'A', 'Â'=>'A', 'Ã'=>'A', 'Ä'=>'A', 'Å'=>'A', 'Æ'=>'A', 'Ç'=>'C', 'È'=>'E', 'É'=>'E', 93 | 'Ê'=>'E', 'Ë'=>'E', 'Ì'=>'I', 'Í'=>'I', 'Î'=>'I', 'Ï'=>'I', 'Ñ'=>'N', 'Ò'=>'O', 'Ó'=>'O', 'Ô'=>'O', 'Õ'=>'O', 'Ö'=>'O', 'Ø'=>'O', 'Ù'=>'U', 94 | 'Ú'=>'U', 'Û'=>'U', 'Ü'=>'U', 'Ý'=>'Y', 'Þ'=>'B', 'ß'=>'Ss', 'à'=>'a', 'á'=>'a', 'â'=>'a', 'ã'=>'a', 'ä'=>'a', 'å'=>'a', 'æ'=>'a', 'ç'=>'c', 95 | 'è'=>'e', 'é'=>'e', 'ê'=>'e', 'ë'=>'e', 'ì'=>'i', 'í'=>'i', 'î'=>'i', 'ï'=>'i', 'ð'=>'o', 'ñ'=>'n', 'ò'=>'o', 'ó'=>'o', 'ô'=>'o', 'õ'=>'o', 96 | 'ö'=>'o', 'ø'=>'o', 'ù'=>'u', 'ú'=>'u', 'û'=>'u', 'ü'=>'u', 'ý'=>'y', 'þ'=>'b', 'ÿ'=>'y', 'Ŕ'=>'R', 'ŕ'=>'r', 'ŕ'=>'r' 97 | ); 98 | 99 | return strtr($text, $normalizeChars); 100 | } 101 | 102 | /** 103 | * Checks if a string contains another string, ignoring case and diacritics. 104 | * 105 | * @param string $haystack The string to search in. 106 | * @param string $needle The string to search for. 107 | * @return bool Returns true if the needle is found in the haystack, false otherwise. 108 | */ 109 | public static function containsIgnoreCaseAndDiacritics(string $haystack, string $needle): bool 110 | { 111 | $haystack = self::removeDiacritics(mb_strtolower($haystack, 'UTF-8')); 112 | $needle = self::removeDiacritics(mb_strtolower($needle, 'UTF-8')); 113 | 114 | return mb_strpos($haystack, $needle) !== false; 115 | } 116 | 117 | /** 118 | * Cleans a slug from extensions. 119 | * 120 | * @param string $slug the string to sanitize 121 | * 122 | * @return string the sanitized string 123 | */ 124 | public static function sanitize(string $slug): string 125 | { 126 | $slug = preg_replace('/\.txt$/', '', $slug); 127 | 128 | if (null === $slug) { 129 | throw new \RuntimeException('Failed to sanitize slug.'); 130 | } 131 | 132 | return rtrim($slug, '/'); 133 | } 134 | 135 | /** 136 | * Converts escaped newline characters to actual newlines in the provided text. 137 | * 138 | * @param string $text the text to process 139 | * 140 | * @return string the text with escaped newlines converted to actual newlines 141 | */ 142 | public static function sanitizeText(string $text): string 143 | { 144 | return str_replace('\\n', "\n", $text); 145 | } 146 | 147 | /** 148 | * Formats a URL based on the given slug and configuration settings. 149 | * 150 | * If the configuration 'show_urls' is set to 'Full', it returns the URL including the domain. 151 | * Otherwise, it returns a relative URL. 152 | * 153 | * @param string $slug the slug part of the URL to format 154 | * 155 | * @return string the formatted URL 156 | */ 157 | public static function formatUrl(string $slug): string 158 | { 159 | return ShowUrls::FULL === Config::get()->showUrls ? Config::get()->url.'/'.$slug.'/' : '/'.$slug; 160 | } 161 | 162 | /** 163 | * Extracts the category name from a provided string if it matches the RSS format. 164 | * 165 | * @param string $route the input string, typically part of a URL 166 | * 167 | * @return null|string returns the category name if the pattern matches, or null if it does not 168 | */ 169 | public static function extractCategoryFromRSS(string $route): ?string 170 | { 171 | if (preg_match('#^rss/([\w-]+)$#', $route, $matches)) { 172 | return $matches[1]; 173 | } 174 | 175 | return null; 176 | } 177 | 178 | /** 179 | * Extracts and validates the date from a path. 180 | * 181 | * This method processes a date path from URL and returns a DateTimeImmutable object 182 | * if the format is valid and the date is logically correct. Supports formats: yyyy/mm/dd, yyyy/mm, or yyyy. 183 | * 184 | * @param string $datePath the date path from the URL 185 | * 186 | * @return array with the date and precision 187 | */ 188 | public static function extractDateFromPath(string $datePath): array 189 | { 190 | $datePath = trim($datePath, '/'); 191 | $parts = explode('/', $datePath); 192 | $format = ''; 193 | $precision = ''; 194 | 195 | switch (\count($parts)) { 196 | case 1: 197 | if (!is_numeric($parts[0]) || strlen($parts[0]) !== 4 || $parts[0] < 1900 || $parts[0] > 2100) { 198 | return [null, '']; 199 | } 200 | $format = 'Y'; 201 | $precision = 'year'; 202 | break; 203 | 204 | case 2: 205 | if (!is_numeric($parts[0]) || strlen($parts[0]) !== 4 || $parts[0] < 1900 || $parts[0] > 2100) { 206 | return [null, '']; 207 | } 208 | if (!is_numeric($parts[1]) || $parts[1] < 1 || $parts[1] > 12) { 209 | return [null, '']; 210 | } 211 | $format = 'Y/m'; 212 | $precision = 'month'; 213 | break; 214 | 215 | case 3: 216 | if (!is_numeric($parts[0]) || strlen($parts[0]) !== 4 || $parts[0] < 1900 || $parts[0] > 2100) { 217 | return [null, '']; 218 | } 219 | 220 | $year = (int)$parts[0]; 221 | $month = (int)$parts[1]; 222 | 223 | if ($month < 1 || $month > 12) { 224 | return [null, '']; 225 | } 226 | 227 | $day = (int)$parts[2]; 228 | 229 | if (function_exists('cal_days_in_month')) { 230 | $daysInMonth = cal_days_in_month(CAL_GREGORIAN, $month, $year); 231 | } else { 232 | $daysInMonth = date('t', mktime(0, 0, 0, $month, 1, $year)); 233 | } 234 | 235 | if ($day < 1 || $day > $daysInMonth) { 236 | return [null, '']; 237 | } 238 | 239 | $format = 'Y/m/d'; 240 | $precision = 'day'; 241 | break; 242 | 243 | default: 244 | return [null, '']; 245 | } 246 | 247 | $date = \DateTimeImmutable::createFromFormat($format, $datePath); 248 | 249 | if ($date === false) { 250 | return [null, '']; 251 | } 252 | 253 | $formattedDate = $date->format($format); 254 | if ($formattedDate !== $datePath) { 255 | return [null, '']; 256 | } 257 | 258 | return [$date, $precision]; 259 | } 260 | 261 | /** 262 | * Capitalizes the provided text. 263 | * 264 | * @param string $text the text to possibly capitalize 265 | * 266 | * @return string the processed text, capitalized if the setting is enabled 267 | */ 268 | public static function capitalizeText(string $text): string 269 | { 270 | if (Config::get()->capitalizeTitles) { 271 | return mb_strtoupper($text, 'UTF-8'); 272 | } 273 | 274 | return $text; 275 | } 276 | 277 | /** 278 | * Beautifies the provided text by applying several transformations. 279 | * 280 | * @param string $text the text to beautify 281 | * 282 | * @return string the beautified text 283 | */ 284 | public static function beautifyText(string $text): string 285 | { 286 | $prefixLength = Config::get()->prefixLength + 2; 287 | $prefixPattern = str_repeat(' ', $prefixLength); 288 | 289 | $text = preg_replace('/"([^"]*)"/', '“$1”', $text); 290 | $text = str_replace(' - ', ' — ', $text); 291 | $text = str_replace(' -', ' —', $text); 292 | $text = str_replace("'", "’", $text); 293 | $text = str_replace(['***', '* * *'], '⁂', $text); 294 | 295 | $lines = explode("\n", $text); 296 | foreach ($lines as &$line) { 297 | if (strpos($line, '-') === 0) { 298 | $line = '—' . substr($line, 1); 299 | } 300 | } 301 | $text = implode("\n", $lines); 302 | 303 | return $text; 304 | } 305 | } 306 | -------------------------------------------------------------------------------- /src/Utils/TextUtils.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Utils; 25 | 26 | use Weblog\Config; 27 | use Weblog\Model\Enum\Beautify; 28 | 29 | final class TextUtils 30 | { 31 | /** 32 | * Centers text within the configured line width. 33 | * 34 | * @param string $text the text to be centered 35 | * 36 | * @return string the centered text 37 | */ 38 | public static function centerText(string $text): string 39 | { 40 | $lineWidth = Config::get()->lineWidth; 41 | $leftPadding = ($lineWidth - mb_strlen($text)) / 2; 42 | 43 | if ($leftPadding < 0) { 44 | return $text; 45 | } 46 | 47 | return str_repeat(' ', (int) floor($leftPadding)).$text; 48 | } 49 | 50 | /** 51 | * Formats a quote block. 52 | * 53 | * @param string $text The text to be formatted as a quote. 54 | * 55 | * @return string The formatted quote. 56 | */ 57 | public static function formatQuote(string $text): string 58 | { 59 | $text = preg_replace('/([.!?]|\.{3})(["\'])?(\s)/', '$1$2 $3', $text); 60 | 61 | $lines = explode("\n", $text); 62 | $formattedText = ''; 63 | $insideQuote = false; 64 | $quoteContent = ''; 65 | $maxWidth = Validator::isMobileDevice() ? 30 : 56; 66 | $isSingleQuote = false; 67 | 68 | foreach ($lines as $line) { 69 | $trimmedLine = ltrim($line); 70 | 71 | if (str_starts_with($trimmedLine, '>')) { 72 | if (!$insideQuote) { 73 | $insideQuote = true; 74 | $quoteContent .= ltrim(substr($trimmedLine, 1)); 75 | } else { 76 | $quoteContent .= "\n" . ltrim(substr($trimmedLine, 1)); 77 | } 78 | } else { 79 | if ($insideQuote) { 80 | $insideQuote = false; 81 | 82 | $quoteLines = explode("\n", trim($quoteContent)); 83 | if (count($quoteLines) === 1) { 84 | $singleQuote = trim($quoteLines[0]); 85 | $isSingleQuote = true; 86 | if (mb_strlen($singleQuote) > $maxWidth) { 87 | $wrappedLines = explode("\n", wordwrap($singleQuote, $maxWidth)); 88 | $centeredQuote = ""; 89 | foreach ($wrappedLines as $wrappedLine) { 90 | if (!Validator::isMobileDevice()) { 91 | $centeredQuote .= TextUtils::centerText($wrappedLine) . "\n"; 92 | } else { 93 | $centeredQuote .= " " . TextUtils::centerText($wrappedLine) . "\n"; 94 | } 95 | } 96 | $quoteContent = $centeredQuote; 97 | } else { 98 | $quoteContent = TextUtils::centerText($singleQuote); 99 | } 100 | } else { 101 | if (!Validator::isMobileDevice()) { 102 | $quoteContent = self::formatQuoteText($quoteContent); 103 | } else { 104 | $quoteContent = " " . self::formatQuoteText($quoteContent); 105 | } 106 | } 107 | 108 | $formattedText .= "\n" . $quoteContent . "\n"; 109 | $quoteContent = ''; 110 | } 111 | $formattedText .= self::formatParagraph($line) . "\n"; 112 | } 113 | } 114 | 115 | if ($insideQuote) { 116 | $quoteLines = explode("\n", trim($quoteContent)); 117 | if (count($quoteLines) === 1) { 118 | $singleQuote = trim($quoteLines[0]); 119 | $isSingleQuote = true; 120 | if (mb_strlen($singleQuote) > $maxWidth) { 121 | $wrappedLines = explode("\n", wordwrap($singleQuote, $maxWidth)); 122 | $centeredQuote = ""; 123 | foreach ($wrappedLines as $wrappedLine) { 124 | if (!Validator::isMobileDevice()) { 125 | $centeredQuote .= TextUtils::centerText($wrappedLine) . "\n"; 126 | } else { 127 | $centeredQuote .= " " . TextUtils::centerText($wrappedLine) . "\n"; 128 | } 129 | } 130 | $quoteContent = $centeredQuote; 131 | } else { 132 | if (!Validator::isMobileDevice()) { 133 | $quoteContent = TextUtils::centerText($singleQuote); 134 | } else { 135 | $quoteContent = " " . TextUtils::centerText($singleQuote); 136 | } 137 | } 138 | } else { 139 | $quoteContent = self::formatQuoteText($quoteContent); 140 | } 141 | 142 | $formattedText .= $quoteContent; 143 | } 144 | 145 | $formattedText = preg_replace( 146 | '/[\x{202F}\x{00A0}]/u', 147 | ' ', 148 | $formattedText 149 | ); 150 | 151 | 152 | if ($isSingleQuote) { 153 | return "\n" . rtrim($formattedText) . "\n"; 154 | } else { 155 | return rtrim($formattedText); 156 | } 157 | } 158 | 159 | /** 160 | * Formats the text of a quote block. 161 | * 162 | * @param string $text The raw text of the quote. 163 | * 164 | * @return string The formatted quote text. 165 | */ 166 | public static function formatQuoteText(string $text): string 167 | { 168 | $lineWidth = Config::get()->lineWidth; 169 | $prefix = str_repeat(' ', Config::get()->prefixLength) . '| '; 170 | $lines = explode("\n", wordwrap(trim($text), $lineWidth - Config::get()->prefixLength - 4)); 171 | 172 | $formattedText = ''; 173 | foreach ($lines as $line) { 174 | $formattedText .= $prefix . $line . "\n"; 175 | } 176 | 177 | $formattedText = preg_replace( 178 | '/[\x{202F}\x{00A0}]/u', 179 | ' ', 180 | $formattedText 181 | ); 182 | 183 | return rtrim($formattedText); 184 | } 185 | 186 | /** 187 | * Formats a list block. 188 | * 189 | * @param string $text The text to be formatted as a list. 190 | * 191 | * @return string The formatted list. 192 | */ 193 | public static function formatList(string $text): string 194 | { 195 | $text = preg_replace('/([.!?]|\.{3})(["\'])?(\s)/', '$1$2 $3', $text); 196 | 197 | $lines = explode("\n", $text); 198 | $formattedText = ''; 199 | $insideList = false; 200 | $listContent = ''; 201 | $listType = ''; 202 | $listItems = []; 203 | $listCount = 0; 204 | 205 | foreach ($lines as $line) { 206 | $trimmedLine = trim($line); 207 | 208 | if (preg_match('/^(\d+)\.\s/', $trimmedLine, $matches)) { 209 | $listCount++; 210 | } elseif (preg_match('/^\* /', $trimmedLine)) { 211 | $listCount++; 212 | } 213 | } 214 | 215 | foreach ($lines as $line) { 216 | $trimmedLine = trim($line); 217 | 218 | if (preg_match('/^(\d+)\.\s/', $trimmedLine, $matches)) { 219 | if ($listType === 'ul') { 220 | $formattedText .= $listContent . "\n"; 221 | $listContent = ''; 222 | $listType = ''; 223 | } 224 | 225 | $listType = 'ol'; 226 | $index = (int)$matches[1]; 227 | if (!$insideList) { 228 | $insideList = true; 229 | $listContent .= self::formatListItem($line, $listType, $index, $listCount); 230 | } else { 231 | $listContent .= ($insideList && !empty($listContent) ? "\n\n" : "") . self::formatListItem($line, $listType, $index, $listCount); 232 | } 233 | } elseif (preg_match('/^\* /', $trimmedLine)) { 234 | if ($listType === 'ol') { 235 | $formattedText .= $listContent . "\n"; 236 | $listContent = ''; 237 | $listType = ''; 238 | } 239 | 240 | $listType = 'ul'; 241 | if (!$insideList) { 242 | $insideList = true; 243 | $listContent .= self::formatListItem($line, $listType, 0, $listCount); 244 | } else { 245 | $listContent .= ($insideList && !empty($listContent) ? "\n\n" : "") . self::formatListItem($line, $listType, 0, $listCount); 246 | } 247 | } else { 248 | if ($insideList) { 249 | $listContent .= "\n" . self::formatListItem($line, $listType, 0, $listCount, true); 250 | } else { 251 | $formattedText .= TextUtils::formatParagraph($trimmedLine) . "\n"; 252 | } 253 | } 254 | } 255 | 256 | if ($insideList) { 257 | $formattedText .= $listContent; 258 | } 259 | 260 | $formattedText = preg_replace( 261 | '/[\x{202F}\x{00A0}]/u', 262 | ' ', 263 | $formattedText 264 | ); 265 | 266 | return rtrim($formattedText); 267 | } 268 | 269 | /** 270 | * Formats a list item. 271 | * 272 | * @param string $item The text of the list item. 273 | * @param string $listType The type of the list ('ol' for ordered, 'ul' for unordered). 274 | * @param int $index The index of the list item (only for ordered lists). 275 | * 276 | * @return string The formatted list item. 277 | */ 278 | public static function formatListItem(string $item, string $listType, int $index = 1, int $totalItems = 10, bool $isContinuation = false): string 279 | { 280 | $lineWidth = Config::get()->lineWidth; 281 | $prefixLength = Config::get()->prefixLength; 282 | $linePrefix = str_repeat(' ', $prefixLength); 283 | 284 | $maxDigits = strlen((string)$totalItems); 285 | $indexDigits = strlen((string)$index); 286 | 287 | if ($listType === 'ol' && !$isContinuation) { 288 | $number = $index . '.'; 289 | $suffix = str_repeat(' ', $maxDigits - $indexDigits + 2); 290 | $linePrefix .= $number . $suffix; 291 | $itemText = trim(substr($item, strlen($number))); 292 | } elseif ($listType === 'ul' && !$isContinuation) { 293 | $isBeautifyEnabled = in_array(Config::get()->beautify, [Beautify::ALL, Beautify::CONTENT]); 294 | $bullet = $item[0] === '*' ? ($isBeautifyEnabled ? '•' : '*') : ($isBeautifyEnabled ? '—' : '-'); 295 | $linePrefix .= $bullet . ' '; 296 | $itemText = trim(substr($item, 2)); 297 | } else { 298 | $linePrefix .= ' '; 299 | $itemText = $item; 300 | } 301 | 302 | $words = explode(' ', $itemText); 303 | $line = $linePrefix; 304 | $result = ''; 305 | 306 | foreach ($words as $word) { 307 | if (mb_strlen($line . $word) > $lineWidth) { 308 | $result .= rtrim($line) . "\n"; 309 | $line = str_repeat(' ', mb_strlen($linePrefix)) . $word . ' '; 310 | } else { 311 | $line .= $word . ' '; 312 | } 313 | } 314 | 315 | $result = preg_replace( 316 | '/[\x{202F}\x{00A0}]/u', 317 | ' ', 318 | $result 319 | ); 320 | 321 | return $result . rtrim($line); 322 | } 323 | 324 | /** 325 | * Formats asterism text. 326 | * 327 | * @param string $text the text to be formatted 328 | * 329 | * @return string the formatted text 330 | */ 331 | public static function formatAsterism(string $text): string 332 | { 333 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::CONTENT])) { 334 | if ($text === '***' || $text === '* * *') { 335 | return "\n" . self::centerText('⁂') . "\n"; 336 | } 337 | } 338 | 339 | if ($text === '***') { 340 | return "\n" . self::centerText('* * *') . "\n"; 341 | } 342 | 343 | if ($text === '* * *') { 344 | return "\n" . self::centerText('* * *') . "\n"; 345 | } 346 | 347 | return self::centerText($text); 348 | } 349 | 350 | /** 351 | * Formats a separator line. 352 | * 353 | * @return string the formatted separator 354 | */ 355 | public static function formatSeparator(): string 356 | { 357 | $lineWidth = Config::get()->lineWidth; 358 | $prefixLength = Config::get()->prefixLength; 359 | $separator = str_repeat('—', 5); 360 | 361 | return "\n" . self::centerText(str_repeat(' ', $prefixLength) . $separator . str_repeat(' ', $prefixLength)) . "\n"; 362 | } 363 | 364 | /** 365 | * Formats a paragraph to fit within the configured line width, using a specified prefix length. 366 | * 367 | * @param string $text the text of the paragraph 368 | * 369 | * @return string the formatted paragraph 370 | */ 371 | public static function formatParagraph(string $text): string 372 | { 373 | $text = preg_replace( 374 | '/\x{202F}/u', 375 | '\x{00A0}', 376 | $text 377 | ); 378 | 379 | if (in_array($text, ['***', '* * *'])) { 380 | return self::formatAsterism($text); 381 | } 382 | 383 | if ($text === '---') { 384 | return self::formatSeparator(); 385 | } 386 | 387 | $lineWidth = Config::get()->lineWidth; 388 | $prefixLength = Config::get()->prefixLength; 389 | $linePrefix = str_repeat(' ', $prefixLength); 390 | 391 | $result = ''; 392 | 393 | $breakingSpaces = '[' . 394 | '\x{0009}-\x{000D}' . 395 | '\x{0020}' . 396 | '\x{1680}' . 397 | '\x{180E}' . 398 | '\x{2000}-\x{200A}' . 399 | '\x{2028}' . 400 | '\x{2029}' . 401 | '\x{205F}' . 402 | '\x{3000}' . 403 | ']+'; 404 | 405 | $tokens = preg_split('/(' . $breakingSpaces . ')/u', $text, -1, PREG_SPLIT_DELIM_CAPTURE); 406 | 407 | $line = $linePrefix; 408 | 409 | foreach ($tokens as $token) { 410 | if ($token === '') { 411 | continue; 412 | } 413 | 414 | $tokenLength = mb_strlen($token); 415 | 416 | if (mb_strlen($line) + $tokenLength > $lineWidth) { 417 | if (strpos($token, '-') !== false && mb_strlen(trim($token)) <= $lineWidth - mb_strlen($linePrefix)) { 418 | $hyphenPos = mb_strpos($token, '-'); 419 | $firstPart = mb_substr($token, 0, $hyphenPos + 1); 420 | $remainingPart = mb_substr($token, $hyphenPos + 1); 421 | 422 | if (mb_strlen($line) + mb_strlen($firstPart) <= $lineWidth) { 423 | $line .= $firstPart; 424 | $result .= rtrim($line) . "\n"; 425 | $line = $linePrefix . $remainingPart; 426 | } else { 427 | $result .= rtrim($line) . "\n"; 428 | $line = $linePrefix . $token; 429 | } 430 | } elseif (mb_strlen(trim($token)) > $lineWidth - mb_strlen($linePrefix)) { 431 | $result .= rtrim($line) . "\n"; 432 | $line = $linePrefix; 433 | 434 | $token = ltrim($token); 435 | while (mb_strlen($token) > 0) { 436 | $spaceLeft = $lineWidth - mb_strlen($line); 437 | $part = mb_substr($token, 0, $spaceLeft); 438 | $token = mb_substr($token, $spaceLeft); 439 | 440 | $line .= $part; 441 | 442 | if (mb_strlen($token) > 0) { 443 | $result .= rtrim($line) . "\n"; 444 | $line = $linePrefix; 445 | } 446 | } 447 | } else { 448 | $result .= rtrim($line) . "\n"; 449 | $line = $linePrefix . ltrim($token); 450 | } 451 | } else { 452 | $line .= $token; 453 | } 454 | } 455 | 456 | $result .= rtrim($line); 457 | 458 | $result = preg_replace( 459 | '/[\x{202F}\x{00A0}]/u', 460 | ' ', 461 | $result 462 | ); 463 | 464 | return $result; 465 | } 466 | 467 | /** 468 | * Formats a string with legal information. 469 | * 470 | * @return string the formatted paragraph 471 | */ 472 | public static function formatCopyrightText(string $dateRange): string 473 | { 474 | $authorInfo = Config::get()->author->getInformation(); 475 | 476 | return "Copyright (c) {$dateRange} {$authorInfo}"; 477 | } 478 | 479 | /** 480 | * Formats the About section header with "About" on the left and the author's name centered. 481 | * 482 | * @return string the formatted header string 483 | */ 484 | public static function formatAboutHeader(): string 485 | { 486 | $lineWidth = Config::get()->lineWidth; 487 | 488 | $leftText = Validator::isMobileDevice() ? '' : 'About'; 489 | $centerText = Config::get()->author->getName(); 490 | $rightText = Validator::isMobileDevice() ? '' : Config::get()->author->getLocation(); 491 | 492 | $leftText = StringUtils::capitalizeText($leftText); 493 | $centerText = StringUtils::capitalizeText($centerText); 494 | $rightText = StringUtils::capitalizeText($rightText); 495 | 496 | $leftWidth = mb_strlen($leftText); 497 | $centerWidth = mb_strlen($centerText); 498 | $rightWidth = mb_strlen($rightText); 499 | 500 | $spaceToLeft = (int) (($lineWidth - $centerWidth) / 2); 501 | $spaceToRight = $lineWidth - $spaceToLeft - $centerWidth; 502 | 503 | if (Validator::isMobileDevice()) { 504 | $spaceToLeft += 1; 505 | } 506 | 507 | return "\n\n\n\n".sprintf( 508 | '%s%s%s%s%s', 509 | $leftText, 510 | str_repeat(' ', $spaceToLeft - $leftWidth), 511 | $centerText, 512 | str_repeat(' ', $spaceToRight - $rightWidth), 513 | $rightText 514 | )."\n\n\n"; 515 | } 516 | 517 | /** 518 | * Formats a paragraph from the about text. 519 | * 520 | * @return string the formatted paragraph 521 | */ 522 | public static function formatAboutText(): string 523 | { 524 | $aboutText = Config::get()->author->getAbout(); 525 | 526 | if (in_array(Config::get()->beautify, [Beautify::ALL, Beautify::CONTENT])) { 527 | $aboutText = StringUtils::beautifyText($aboutText); 528 | } 529 | 530 | $paragraphs = explode("\n", $aboutText); 531 | 532 | $formattedAboutText = ''; 533 | 534 | foreach ($paragraphs as $paragraph) { 535 | $formattedParagraph = $paragraph; 536 | if (!Validator::isMobileDevice()) { 537 | $formattedParagraph = preg_replace('/([.!?]|\.{3})(["\'])?(\s)/', '$1$2 $3', rtrim($paragraph)); 538 | } 539 | $formattedAboutText .= self::formatParagraph($formattedParagraph ?? '')."\n"; 540 | } 541 | 542 | if (Config::get()->showSeparator) { 543 | $separator = "\n\n\n".str_repeat( 544 | ' ', 545 | Validator::isMobileDevice() ? Config::get()->prefixLength : 0 546 | ). 547 | str_repeat('—', Config::get()->lineWidth - (Validator::isMobileDevice() ? Config::get()->prefixLength : 0))."\n\n\n\n\n"; 548 | $formattedAboutText .= $separator; 549 | } else { 550 | $formattedAboutText .= "\n\n\n\n\n"; 551 | } 552 | 553 | return $formattedAboutText; 554 | } 555 | } 556 | -------------------------------------------------------------------------------- /src/Utils/Validator.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * This program is free software: you can redistribute it and/or modify 9 | * it under the terms of the GNU Affero General Public License as published by 10 | * the Free Software Foundation, either version 3 of the License, or 11 | * (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU Affero General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU Affero General Public License 19 | * along with this program. If not, see . 20 | */ 21 | 22 | declare(strict_types=1); 23 | 24 | namespace Weblog\Utils; 25 | 26 | use Weblog\Config; 27 | use Weblog\Model\Entity\Post; 28 | 29 | final class Validator 30 | { 31 | /** 32 | * Determines if the date of a post matches a specific date. 33 | * 34 | * @param \DateTimeImmutable $date the date to compare 35 | * @param Post $post the post whose date is being compared 36 | * 37 | * @return bool true if the dates match, false otherwise 38 | */ 39 | public static function dateMatches(\DateTimeImmutable $date, Post $post): bool 40 | { 41 | return $date->format('Y-m-d') === $post->getDate()->format('Y-m-d'); 42 | } 43 | 44 | /** 45 | * Checks if the given route string represents a valid date pattern. 46 | * 47 | * @param string $route the route string to check 48 | * 49 | * @return bool returns true if the route matches a date pattern, false otherwise 50 | */ 51 | public static function isDateRoute(string $route): bool 52 | { 53 | return (bool) preg_match('#^\d{4}(?:/\d{2}(?:/\d{2})?)?/?$#', $route); 54 | } 55 | 56 | /** 57 | * Determines if a file corresponds to a valid post within the specified category. 58 | * 59 | * @param \SplFileInfo $file the file to check 60 | * @param string $category the category to match against 61 | * @param string $directory The directory path 62 | * 63 | * @return bool returns true if the file is a valid post in the specified category 64 | */ 65 | public static function isValidCategoryPost(\SplFileInfo $file, string $category, string $directory): bool 66 | { 67 | $filePath = str_replace('\\', '/', $file->getPathname()); 68 | $directory = rtrim(str_replace('\\', '/', $directory), '/').'/'; 69 | 70 | $relativePath = substr($filePath, \strlen($directory)); 71 | $relativePath = ltrim($relativePath, '/'); 72 | 73 | $firstDir = strstr($relativePath, '/', true) ?: $relativePath; 74 | 75 | if (('misc' === $category && (empty($firstDir) || 'misc' === $firstDir || $relativePath === $firstDir)) || $firstDir === $category) { 76 | return true; 77 | } 78 | 79 | return false; 80 | } 81 | 82 | /** 83 | * Checks if the path is a valid category folder. 84 | * 85 | * @return bool returns true if the directory exists 86 | */ 87 | public static function isValidCategoryPath(string $categoryPath): bool 88 | { 89 | $weblogDir = Config::get()->weblogDir; 90 | $fullPath = $weblogDir . ('misc' !== $categoryPath ? '/'.$categoryPath : ''); 91 | 92 | if (!is_dir($fullPath)) { 93 | if (!is_dir($weblogDir . '/.' . $categoryPath)) { 94 | return false; 95 | } 96 | } 97 | 98 | return true; 99 | } 100 | 101 | /** 102 | * Checks if the route is a drafts route. 103 | * 104 | * @param string $route The route string to check. 105 | * @return bool Returns true if the route is a drafts route. 106 | */ 107 | public static function isDraftsRoute(string $route): bool 108 | { 109 | return preg_match('#^drafts/#', $route) === 1; 110 | } 111 | 112 | /** 113 | * Checks if the route is a search route. 114 | * 115 | * @param string $route The route string to check. 116 | * @return bool Returns true if the route is a search route. 117 | */ 118 | public static function isSearchRoute(string $route): bool 119 | { 120 | return preg_match('#^search/(.+)$#', $route) === 1; 121 | } 122 | 123 | /** 124 | * Checks if the route is a selected posts route. 125 | * 126 | * @param string $route The route string to check. 127 | * @return bool Returns true if the route is a selected posts route. 128 | */ 129 | public static function isSelectedRoute(string $route): bool 130 | { 131 | return preg_match('#^selected$#', $route) === 1; 132 | } 133 | 134 | /** 135 | * Checks if the current user agent is a mobile device. 136 | */ 137 | public static function isMobileDevice(): bool 138 | { 139 | $userAgent = $_SERVER['HTTP_USER_AGENT'] ?? ''; 140 | 141 | if (false === \is_string($userAgent)) { 142 | throw new \InvalidArgumentException('User agent is not a string.'); 143 | } 144 | 145 | $result = preg_match('/Android|iPhone|iPad|iPod|webOS|BlackBerry|IEMobile|Opera Mini/i', $userAgent); 146 | 147 | if (false === $result) { 148 | throw new \RuntimeException('Failed to execute user agent match.'); 149 | } 150 | 151 | return (bool) $result; 152 | } 153 | } 154 | --------------------------------------------------------------------------------