├── .github └── workflows │ └── deploy.yaml ├── .gitignore ├── Dockerfile ├── blogrng.php ├── composer.json ├── composer.lock ├── data └── .keep ├── db ├── 0001.sql ├── 0002.sql ├── 0003.sql ├── 0004.sql ├── 0005.sql └── 0006.sql ├── docker-compose.yaml ├── public ├── .htaccess ├── custom.css ├── dice-192.png ├── dice-512.png ├── dice.svg ├── index.php ├── manifest.json ├── mastodon.png ├── ograph.jpg ├── ograph.svg ├── script.js ├── service-worker.js └── simple.min.css ├── src ├── CLI.php ├── Controller.php ├── CookieManager.php ├── DataBase.php ├── FeedManager.php ├── Mastodon.php └── RSS.php └── templates ├── 401.twig ├── 404.twig ├── admin.twig ├── all.twig ├── faq.twig ├── index.twig ├── inspect.twig ├── partials ├── feedback.twig ├── layout.twig ├── navigation.twig ├── rssitem.twig └── seen.twig ├── rss.twig └── suggest.twig /.github/workflows/deploy.yaml: -------------------------------------------------------------------------------- 1 | name: deployment 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | deploy: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v4 12 | 13 | - name: Setup PHP 14 | uses: shivammathur/setup-php@v2 15 | with: 16 | php-version: 8.2 17 | extensions: mbstring, PDO, pdo_sqlite 18 | 19 | - name: Run Composer 20 | run: composer install 21 | 22 | - name: Log in to GitHub Container Registry 23 | uses: docker/login-action@v2 24 | with: 25 | registry: ghcr.io 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Build Docker image 30 | run: | 31 | docker build -t ghcr.io/${{ github.repository }}:latest . 32 | 33 | - name: Push Docker image 34 | run: | 35 | docker push ghcr.io/${{ github.repository }}:latest 36 | 37 | - name: Trigger Watchtower Refresh 38 | run: | 39 | curl --fail -X POST https://watchtower.splitbrain.net/v1/update \ 40 | -H "Authorization: Bearer ${{ secrets.WATCHTOWER_HTTP_API_TOKEN }}" 41 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | vendor 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.2-apache 2 | 3 | RUN sed -ri -e 's!/var/www/html!/app/public!g' /etc/apache2/sites-available/*.conf 4 | RUN sed -ri -e 's!/var/www/!/app/public!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf 5 | RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" 6 | RUN a2enmod rewrite 7 | 8 | VOLUME /app/data 9 | WORKDIR /app 10 | COPY ./ /app 11 | -------------------------------------------------------------------------------- /blogrng.php: -------------------------------------------------------------------------------- 1 | #!/bin/php 2 | run(); 9 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "splitbrain/blogrng", 3 | "autoload": { 4 | "psr-4": { 5 | "splitbrain\\blogrng\\": "src/" 6 | } 7 | }, 8 | "authors": [ 9 | { 10 | "name": "Andreas Gohr", 11 | "email": "andi@splitbrain.org" 12 | } 13 | ], 14 | "config": { 15 | "platform": { 16 | "php": "8.2" 17 | } 18 | }, 19 | "require": { 20 | "simplepie/simplepie": "^1.8", 21 | "ext-pdo": "*", 22 | "splitbrain/php-cli": "^1.1", 23 | "psr/log": "^1.1", 24 | "ext-json": "*", 25 | "twig/twig": "^3.12", 26 | "openpsa/universalfeedcreator": "^1.9", 27 | "wa72/url": "^0.7.1", 28 | "ext-curl": "*", 29 | "scotteh/php-dom-wrapper": "^2.0", 30 | "scotteh/php-goose": "^1.1", 31 | "ext-simplexml": "*" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "8f7f826653974b0b15f21538a436e6b7", 8 | "packages": [ 9 | { 10 | "name": "guzzlehttp/guzzle", 11 | "version": "7.9.2", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/guzzle/guzzle.git", 15 | "reference": "d281ed313b989f213357e3be1a179f02196ac99b" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b", 20 | "reference": "d281ed313b989f213357e3be1a179f02196ac99b", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "ext-json": "*", 25 | "guzzlehttp/promises": "^1.5.3 || ^2.0.3", 26 | "guzzlehttp/psr7": "^2.7.0", 27 | "php": "^7.2.5 || ^8.0", 28 | "psr/http-client": "^1.0", 29 | "symfony/deprecation-contracts": "^2.2 || ^3.0" 30 | }, 31 | "provide": { 32 | "psr/http-client-implementation": "1.0" 33 | }, 34 | "require-dev": { 35 | "bamarni/composer-bin-plugin": "^1.8.2", 36 | "ext-curl": "*", 37 | "guzzle/client-integration-tests": "3.0.2", 38 | "php-http/message-factory": "^1.1", 39 | "phpunit/phpunit": "^8.5.39 || ^9.6.20", 40 | "psr/log": "^1.1 || ^2.0 || ^3.0" 41 | }, 42 | "suggest": { 43 | "ext-curl": "Required for CURL handler support", 44 | "ext-intl": "Required for Internationalized Domain Name (IDN) support", 45 | "psr/log": "Required for using the Log middleware" 46 | }, 47 | "type": "library", 48 | "extra": { 49 | "bamarni-bin": { 50 | "bin-links": true, 51 | "forward-command": false 52 | } 53 | }, 54 | "autoload": { 55 | "files": [ 56 | "src/functions_include.php" 57 | ], 58 | "psr-4": { 59 | "GuzzleHttp\\": "src/" 60 | } 61 | }, 62 | "notification-url": "https://packagist.org/downloads/", 63 | "license": [ 64 | "MIT" 65 | ], 66 | "authors": [ 67 | { 68 | "name": "Graham Campbell", 69 | "email": "hello@gjcampbell.co.uk", 70 | "homepage": "https://github.com/GrahamCampbell" 71 | }, 72 | { 73 | "name": "Michael Dowling", 74 | "email": "mtdowling@gmail.com", 75 | "homepage": "https://github.com/mtdowling" 76 | }, 77 | { 78 | "name": "Jeremy Lindblom", 79 | "email": "jeremeamia@gmail.com", 80 | "homepage": "https://github.com/jeremeamia" 81 | }, 82 | { 83 | "name": "George Mponos", 84 | "email": "gmponos@gmail.com", 85 | "homepage": "https://github.com/gmponos" 86 | }, 87 | { 88 | "name": "Tobias Nyholm", 89 | "email": "tobias.nyholm@gmail.com", 90 | "homepage": "https://github.com/Nyholm" 91 | }, 92 | { 93 | "name": "Márk Sági-Kazár", 94 | "email": "mark.sagikazar@gmail.com", 95 | "homepage": "https://github.com/sagikazarmark" 96 | }, 97 | { 98 | "name": "Tobias Schultze", 99 | "email": "webmaster@tubo-world.de", 100 | "homepage": "https://github.com/Tobion" 101 | } 102 | ], 103 | "description": "Guzzle is a PHP HTTP client library", 104 | "keywords": [ 105 | "client", 106 | "curl", 107 | "framework", 108 | "http", 109 | "http client", 110 | "psr-18", 111 | "psr-7", 112 | "rest", 113 | "web service" 114 | ], 115 | "support": { 116 | "issues": "https://github.com/guzzle/guzzle/issues", 117 | "source": "https://github.com/guzzle/guzzle/tree/7.9.2" 118 | }, 119 | "funding": [ 120 | { 121 | "url": "https://github.com/GrahamCampbell", 122 | "type": "github" 123 | }, 124 | { 125 | "url": "https://github.com/Nyholm", 126 | "type": "github" 127 | }, 128 | { 129 | "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", 130 | "type": "tidelift" 131 | } 132 | ], 133 | "time": "2024-07-24T11:22:20+00:00" 134 | }, 135 | { 136 | "name": "guzzlehttp/promises", 137 | "version": "2.0.3", 138 | "source": { 139 | "type": "git", 140 | "url": "https://github.com/guzzle/promises.git", 141 | "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8" 142 | }, 143 | "dist": { 144 | "type": "zip", 145 | "url": "https://api.github.com/repos/guzzle/promises/zipball/6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", 146 | "reference": "6ea8dd08867a2a42619d65c3deb2c0fcbf81c8f8", 147 | "shasum": "" 148 | }, 149 | "require": { 150 | "php": "^7.2.5 || ^8.0" 151 | }, 152 | "require-dev": { 153 | "bamarni/composer-bin-plugin": "^1.8.2", 154 | "phpunit/phpunit": "^8.5.39 || ^9.6.20" 155 | }, 156 | "type": "library", 157 | "extra": { 158 | "bamarni-bin": { 159 | "bin-links": true, 160 | "forward-command": false 161 | } 162 | }, 163 | "autoload": { 164 | "psr-4": { 165 | "GuzzleHttp\\Promise\\": "src/" 166 | } 167 | }, 168 | "notification-url": "https://packagist.org/downloads/", 169 | "license": [ 170 | "MIT" 171 | ], 172 | "authors": [ 173 | { 174 | "name": "Graham Campbell", 175 | "email": "hello@gjcampbell.co.uk", 176 | "homepage": "https://github.com/GrahamCampbell" 177 | }, 178 | { 179 | "name": "Michael Dowling", 180 | "email": "mtdowling@gmail.com", 181 | "homepage": "https://github.com/mtdowling" 182 | }, 183 | { 184 | "name": "Tobias Nyholm", 185 | "email": "tobias.nyholm@gmail.com", 186 | "homepage": "https://github.com/Nyholm" 187 | }, 188 | { 189 | "name": "Tobias Schultze", 190 | "email": "webmaster@tubo-world.de", 191 | "homepage": "https://github.com/Tobion" 192 | } 193 | ], 194 | "description": "Guzzle promises library", 195 | "keywords": [ 196 | "promise" 197 | ], 198 | "support": { 199 | "issues": "https://github.com/guzzle/promises/issues", 200 | "source": "https://github.com/guzzle/promises/tree/2.0.3" 201 | }, 202 | "funding": [ 203 | { 204 | "url": "https://github.com/GrahamCampbell", 205 | "type": "github" 206 | }, 207 | { 208 | "url": "https://github.com/Nyholm", 209 | "type": "github" 210 | }, 211 | { 212 | "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", 213 | "type": "tidelift" 214 | } 215 | ], 216 | "time": "2024-07-18T10:29:17+00:00" 217 | }, 218 | { 219 | "name": "guzzlehttp/psr7", 220 | "version": "2.7.0", 221 | "source": { 222 | "type": "git", 223 | "url": "https://github.com/guzzle/psr7.git", 224 | "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201" 225 | }, 226 | "dist": { 227 | "type": "zip", 228 | "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201", 229 | "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201", 230 | "shasum": "" 231 | }, 232 | "require": { 233 | "php": "^7.2.5 || ^8.0", 234 | "psr/http-factory": "^1.0", 235 | "psr/http-message": "^1.1 || ^2.0", 236 | "ralouphie/getallheaders": "^3.0" 237 | }, 238 | "provide": { 239 | "psr/http-factory-implementation": "1.0", 240 | "psr/http-message-implementation": "1.0" 241 | }, 242 | "require-dev": { 243 | "bamarni/composer-bin-plugin": "^1.8.2", 244 | "http-interop/http-factory-tests": "0.9.0", 245 | "phpunit/phpunit": "^8.5.39 || ^9.6.20" 246 | }, 247 | "suggest": { 248 | "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" 249 | }, 250 | "type": "library", 251 | "extra": { 252 | "bamarni-bin": { 253 | "bin-links": true, 254 | "forward-command": false 255 | } 256 | }, 257 | "autoload": { 258 | "psr-4": { 259 | "GuzzleHttp\\Psr7\\": "src/" 260 | } 261 | }, 262 | "notification-url": "https://packagist.org/downloads/", 263 | "license": [ 264 | "MIT" 265 | ], 266 | "authors": [ 267 | { 268 | "name": "Graham Campbell", 269 | "email": "hello@gjcampbell.co.uk", 270 | "homepage": "https://github.com/GrahamCampbell" 271 | }, 272 | { 273 | "name": "Michael Dowling", 274 | "email": "mtdowling@gmail.com", 275 | "homepage": "https://github.com/mtdowling" 276 | }, 277 | { 278 | "name": "George Mponos", 279 | "email": "gmponos@gmail.com", 280 | "homepage": "https://github.com/gmponos" 281 | }, 282 | { 283 | "name": "Tobias Nyholm", 284 | "email": "tobias.nyholm@gmail.com", 285 | "homepage": "https://github.com/Nyholm" 286 | }, 287 | { 288 | "name": "Márk Sági-Kazár", 289 | "email": "mark.sagikazar@gmail.com", 290 | "homepage": "https://github.com/sagikazarmark" 291 | }, 292 | { 293 | "name": "Tobias Schultze", 294 | "email": "webmaster@tubo-world.de", 295 | "homepage": "https://github.com/Tobion" 296 | }, 297 | { 298 | "name": "Márk Sági-Kazár", 299 | "email": "mark.sagikazar@gmail.com", 300 | "homepage": "https://sagikazarmark.hu" 301 | } 302 | ], 303 | "description": "PSR-7 message implementation that also provides common utility methods", 304 | "keywords": [ 305 | "http", 306 | "message", 307 | "psr-7", 308 | "request", 309 | "response", 310 | "stream", 311 | "uri", 312 | "url" 313 | ], 314 | "support": { 315 | "issues": "https://github.com/guzzle/psr7/issues", 316 | "source": "https://github.com/guzzle/psr7/tree/2.7.0" 317 | }, 318 | "funding": [ 319 | { 320 | "url": "https://github.com/GrahamCampbell", 321 | "type": "github" 322 | }, 323 | { 324 | "url": "https://github.com/Nyholm", 325 | "type": "github" 326 | }, 327 | { 328 | "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", 329 | "type": "tidelift" 330 | } 331 | ], 332 | "time": "2024-07-18T11:15:46+00:00" 333 | }, 334 | { 335 | "name": "jakeasmith/http_build_url", 336 | "version": "1.0.1", 337 | "source": { 338 | "type": "git", 339 | "url": "https://github.com/jakeasmith/http_build_url.git", 340 | "reference": "93c273e77cb1edead0cf8bcf8cd2003428e74e37" 341 | }, 342 | "dist": { 343 | "type": "zip", 344 | "url": "https://api.github.com/repos/jakeasmith/http_build_url/zipball/93c273e77cb1edead0cf8bcf8cd2003428e74e37", 345 | "reference": "93c273e77cb1edead0cf8bcf8cd2003428e74e37", 346 | "shasum": "" 347 | }, 348 | "type": "library", 349 | "autoload": { 350 | "files": [ 351 | "src/http_build_url.php" 352 | ] 353 | }, 354 | "notification-url": "https://packagist.org/downloads/", 355 | "license": [ 356 | "MIT" 357 | ], 358 | "authors": [ 359 | { 360 | "name": "Jake A. Smith", 361 | "email": "theman@jakeasmith.com" 362 | } 363 | ], 364 | "description": "Provides functionality for http_build_url() to environments without pecl_http.", 365 | "support": { 366 | "issues": "https://github.com/jakeasmith/http_build_url/issues", 367 | "source": "https://github.com/jakeasmith/http_build_url" 368 | }, 369 | "time": "2017-05-01T15:36:40+00:00" 370 | }, 371 | { 372 | "name": "openpsa/universalfeedcreator", 373 | "version": "v1.9.0", 374 | "source": { 375 | "type": "git", 376 | "url": "https://github.com/flack/UniversalFeedCreator.git", 377 | "reference": "c55f908e867aa6dade0905b8aefb94fd9d8c1f9e" 378 | }, 379 | "dist": { 380 | "type": "zip", 381 | "url": "https://api.github.com/repos/flack/UniversalFeedCreator/zipball/c55f908e867aa6dade0905b8aefb94fd9d8c1f9e", 382 | "reference": "c55f908e867aa6dade0905b8aefb94fd9d8c1f9e", 383 | "shasum": "" 384 | }, 385 | "require": { 386 | "php": ">=5.2" 387 | }, 388 | "require-dev": { 389 | "phpunit/phpunit": "^6.5.14 || ^7.5.20 || ^8.5.32 || ^9.5.10" 390 | }, 391 | "type": "library", 392 | "autoload": { 393 | "files": [ 394 | "lib/constants.php" 395 | ], 396 | "classmap": [ 397 | "lib" 398 | ] 399 | }, 400 | "notification-url": "https://packagist.org/downloads/", 401 | "license": [ 402 | "LGPL-2.1-or-later" 403 | ], 404 | "authors": [ 405 | { 406 | "name": "Andreas Flack", 407 | "email": "flack@contentcontrol-berlin.de", 408 | "homepage": "http://www.contentcontrol-berlin.de/" 409 | } 410 | ], 411 | "description": "RSS and Atom feed generator by Kai Blankenhorn", 412 | "keywords": [ 413 | "atom", 414 | "georss", 415 | "gpx", 416 | "opml", 417 | "pie", 418 | "rss" 419 | ], 420 | "support": { 421 | "issues": "https://github.com/flack/UniversalFeedCreator/issues", 422 | "source": "https://github.com/flack/UniversalFeedCreator/tree/v1.9.0" 423 | }, 424 | "time": "2024-02-22T11:08:24+00:00" 425 | }, 426 | { 427 | "name": "psr/http-client", 428 | "version": "1.0.3", 429 | "source": { 430 | "type": "git", 431 | "url": "https://github.com/php-fig/http-client.git", 432 | "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" 433 | }, 434 | "dist": { 435 | "type": "zip", 436 | "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", 437 | "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", 438 | "shasum": "" 439 | }, 440 | "require": { 441 | "php": "^7.0 || ^8.0", 442 | "psr/http-message": "^1.0 || ^2.0" 443 | }, 444 | "type": "library", 445 | "extra": { 446 | "branch-alias": { 447 | "dev-master": "1.0.x-dev" 448 | } 449 | }, 450 | "autoload": { 451 | "psr-4": { 452 | "Psr\\Http\\Client\\": "src/" 453 | } 454 | }, 455 | "notification-url": "https://packagist.org/downloads/", 456 | "license": [ 457 | "MIT" 458 | ], 459 | "authors": [ 460 | { 461 | "name": "PHP-FIG", 462 | "homepage": "https://www.php-fig.org/" 463 | } 464 | ], 465 | "description": "Common interface for HTTP clients", 466 | "homepage": "https://github.com/php-fig/http-client", 467 | "keywords": [ 468 | "http", 469 | "http-client", 470 | "psr", 471 | "psr-18" 472 | ], 473 | "support": { 474 | "source": "https://github.com/php-fig/http-client" 475 | }, 476 | "time": "2023-09-23T14:17:50+00:00" 477 | }, 478 | { 479 | "name": "psr/http-factory", 480 | "version": "1.1.0", 481 | "source": { 482 | "type": "git", 483 | "url": "https://github.com/php-fig/http-factory.git", 484 | "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" 485 | }, 486 | "dist": { 487 | "type": "zip", 488 | "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", 489 | "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", 490 | "shasum": "" 491 | }, 492 | "require": { 493 | "php": ">=7.1", 494 | "psr/http-message": "^1.0 || ^2.0" 495 | }, 496 | "type": "library", 497 | "extra": { 498 | "branch-alias": { 499 | "dev-master": "1.0.x-dev" 500 | } 501 | }, 502 | "autoload": { 503 | "psr-4": { 504 | "Psr\\Http\\Message\\": "src/" 505 | } 506 | }, 507 | "notification-url": "https://packagist.org/downloads/", 508 | "license": [ 509 | "MIT" 510 | ], 511 | "authors": [ 512 | { 513 | "name": "PHP-FIG", 514 | "homepage": "https://www.php-fig.org/" 515 | } 516 | ], 517 | "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", 518 | "keywords": [ 519 | "factory", 520 | "http", 521 | "message", 522 | "psr", 523 | "psr-17", 524 | "psr-7", 525 | "request", 526 | "response" 527 | ], 528 | "support": { 529 | "source": "https://github.com/php-fig/http-factory" 530 | }, 531 | "time": "2024-04-15T12:06:14+00:00" 532 | }, 533 | { 534 | "name": "psr/http-message", 535 | "version": "2.0", 536 | "source": { 537 | "type": "git", 538 | "url": "https://github.com/php-fig/http-message.git", 539 | "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" 540 | }, 541 | "dist": { 542 | "type": "zip", 543 | "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", 544 | "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", 545 | "shasum": "" 546 | }, 547 | "require": { 548 | "php": "^7.2 || ^8.0" 549 | }, 550 | "type": "library", 551 | "extra": { 552 | "branch-alias": { 553 | "dev-master": "2.0.x-dev" 554 | } 555 | }, 556 | "autoload": { 557 | "psr-4": { 558 | "Psr\\Http\\Message\\": "src/" 559 | } 560 | }, 561 | "notification-url": "https://packagist.org/downloads/", 562 | "license": [ 563 | "MIT" 564 | ], 565 | "authors": [ 566 | { 567 | "name": "PHP-FIG", 568 | "homepage": "https://www.php-fig.org/" 569 | } 570 | ], 571 | "description": "Common interface for HTTP messages", 572 | "homepage": "https://github.com/php-fig/http-message", 573 | "keywords": [ 574 | "http", 575 | "http-message", 576 | "psr", 577 | "psr-7", 578 | "request", 579 | "response" 580 | ], 581 | "support": { 582 | "source": "https://github.com/php-fig/http-message/tree/2.0" 583 | }, 584 | "time": "2023-04-04T09:54:51+00:00" 585 | }, 586 | { 587 | "name": "psr/log", 588 | "version": "1.1.4", 589 | "source": { 590 | "type": "git", 591 | "url": "https://github.com/php-fig/log.git", 592 | "reference": "d49695b909c3b7628b6289db5479a1c204601f11" 593 | }, 594 | "dist": { 595 | "type": "zip", 596 | "url": "https://api.github.com/repos/php-fig/log/zipball/d49695b909c3b7628b6289db5479a1c204601f11", 597 | "reference": "d49695b909c3b7628b6289db5479a1c204601f11", 598 | "shasum": "" 599 | }, 600 | "require": { 601 | "php": ">=5.3.0" 602 | }, 603 | "type": "library", 604 | "extra": { 605 | "branch-alias": { 606 | "dev-master": "1.1.x-dev" 607 | } 608 | }, 609 | "autoload": { 610 | "psr-4": { 611 | "Psr\\Log\\": "Psr/Log/" 612 | } 613 | }, 614 | "notification-url": "https://packagist.org/downloads/", 615 | "license": [ 616 | "MIT" 617 | ], 618 | "authors": [ 619 | { 620 | "name": "PHP-FIG", 621 | "homepage": "https://www.php-fig.org/" 622 | } 623 | ], 624 | "description": "Common interface for logging libraries", 625 | "homepage": "https://github.com/php-fig/log", 626 | "keywords": [ 627 | "log", 628 | "psr", 629 | "psr-3" 630 | ], 631 | "support": { 632 | "source": "https://github.com/php-fig/log/tree/1.1.4" 633 | }, 634 | "time": "2021-05-03T11:20:27+00:00" 635 | }, 636 | { 637 | "name": "ralouphie/getallheaders", 638 | "version": "3.0.3", 639 | "source": { 640 | "type": "git", 641 | "url": "https://github.com/ralouphie/getallheaders.git", 642 | "reference": "120b605dfeb996808c31b6477290a714d356e822" 643 | }, 644 | "dist": { 645 | "type": "zip", 646 | "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", 647 | "reference": "120b605dfeb996808c31b6477290a714d356e822", 648 | "shasum": "" 649 | }, 650 | "require": { 651 | "php": ">=5.6" 652 | }, 653 | "require-dev": { 654 | "php-coveralls/php-coveralls": "^2.1", 655 | "phpunit/phpunit": "^5 || ^6.5" 656 | }, 657 | "type": "library", 658 | "autoload": { 659 | "files": [ 660 | "src/getallheaders.php" 661 | ] 662 | }, 663 | "notification-url": "https://packagist.org/downloads/", 664 | "license": [ 665 | "MIT" 666 | ], 667 | "authors": [ 668 | { 669 | "name": "Ralph Khattar", 670 | "email": "ralph.khattar@gmail.com" 671 | } 672 | ], 673 | "description": "A polyfill for getallheaders.", 674 | "support": { 675 | "issues": "https://github.com/ralouphie/getallheaders/issues", 676 | "source": "https://github.com/ralouphie/getallheaders/tree/develop" 677 | }, 678 | "time": "2019-03-08T08:55:37+00:00" 679 | }, 680 | { 681 | "name": "scotteh/php-dom-wrapper", 682 | "version": "2.0.5", 683 | "source": { 684 | "type": "git", 685 | "url": "https://github.com/scotteh/php-dom-wrapper.git", 686 | "reference": "351e9c635c9aa65c8cedaeefcac3a49581ad2529" 687 | }, 688 | "dist": { 689 | "type": "zip", 690 | "url": "https://api.github.com/repos/scotteh/php-dom-wrapper/zipball/351e9c635c9aa65c8cedaeefcac3a49581ad2529", 691 | "reference": "351e9c635c9aa65c8cedaeefcac3a49581ad2529", 692 | "shasum": "" 693 | }, 694 | "require": { 695 | "ext-libxml": "*", 696 | "ext-mbstring": "*", 697 | "lib-libxml": ">=2.7.7", 698 | "php": ">=7.1.0", 699 | "symfony/css-selector": "^4.0 || ^5.0 || ^6.0" 700 | }, 701 | "require-dev": { 702 | "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" 703 | }, 704 | "type": "library", 705 | "extra": { 706 | "branch-alias": { 707 | "dev-master": "2.0-dev" 708 | } 709 | }, 710 | "autoload": { 711 | "psr-4": { 712 | "DOMWrap\\": "src/" 713 | } 714 | }, 715 | "notification-url": "https://packagist.org/downloads/", 716 | "license": [ 717 | "BSD-3-Clause" 718 | ], 719 | "authors": [ 720 | { 721 | "name": "Andrew Scott", 722 | "email": "andrew@andrewscott.net.au" 723 | } 724 | ], 725 | "description": "Simple DOM wrapper to select nodes using either CSS or XPath expressions and manipulate results quickly and easily.", 726 | "homepage": "https://github.com/scotteh/php-dom-wrapper", 727 | "keywords": [ 728 | "css", 729 | "dom", 730 | "html", 731 | "parser", 732 | "wrapper" 733 | ], 734 | "support": { 735 | "issues": "https://github.com/scotteh/php-dom-wrapper/issues", 736 | "source": "https://github.com/scotteh/php-dom-wrapper/tree/2.0.5" 737 | }, 738 | "time": "2023-09-10T13:50:09+00:00" 739 | }, 740 | { 741 | "name": "scotteh/php-goose", 742 | "version": "1.1.1", 743 | "source": { 744 | "type": "git", 745 | "url": "https://github.com/scotteh/php-goose.git", 746 | "reference": "b188e6a8b71e825a19246cb335dd872be6aa0206" 747 | }, 748 | "dist": { 749 | "type": "zip", 750 | "url": "https://api.github.com/repos/scotteh/php-goose/zipball/b188e6a8b71e825a19246cb335dd872be6aa0206", 751 | "reference": "b188e6a8b71e825a19246cb335dd872be6aa0206", 752 | "shasum": "" 753 | }, 754 | "require": { 755 | "ext-libxml": "*", 756 | "ext-mbstring": "*", 757 | "guzzlehttp/guzzle": "^6.0|^7.0", 758 | "jakeasmith/http_build_url": "1.0.*", 759 | "lib-libxml": ">=2.7.7", 760 | "php": ">=7.1.0", 761 | "scotteh/php-dom-wrapper": "^2.0" 762 | }, 763 | "require-dev": { 764 | "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0" 765 | }, 766 | "type": "library", 767 | "extra": { 768 | "branch-alias": { 769 | "dev-master": "1.0-dev" 770 | } 771 | }, 772 | "autoload": { 773 | "psr-4": { 774 | "Goose\\": "src/" 775 | } 776 | }, 777 | "notification-url": "https://packagist.org/downloads/", 778 | "license": [ 779 | "Apache-2.0" 780 | ], 781 | "authors": [ 782 | { 783 | "name": "Andrew Scott", 784 | "email": "andrew@andrewscott.net.au" 785 | } 786 | ], 787 | "description": "Readability / Html Content / Article Extractor & Web Scrapping library written in PHP", 788 | "homepage": "https://github.com/scotteh/php-goose", 789 | "keywords": [ 790 | "content", 791 | "extractor", 792 | "http", 793 | "readability", 794 | "scraper", 795 | "scraping", 796 | "text", 797 | "website" 798 | ], 799 | "support": { 800 | "issues": "https://github.com/scotteh/php-goose/issues", 801 | "source": "https://github.com/scotteh/php-goose/tree/1.1.1" 802 | }, 803 | "abandoned": true, 804 | "time": "2023-09-05T12:12:16+00:00" 805 | }, 806 | { 807 | "name": "simplepie/simplepie", 808 | "version": "1.8.0", 809 | "source": { 810 | "type": "git", 811 | "url": "https://github.com/simplepie/simplepie.git", 812 | "reference": "65b095d87bc00898d8fa7737bdbcda93a3fbcc55" 813 | }, 814 | "dist": { 815 | "type": "zip", 816 | "url": "https://api.github.com/repos/simplepie/simplepie/zipball/65b095d87bc00898d8fa7737bdbcda93a3fbcc55", 817 | "reference": "65b095d87bc00898d8fa7737bdbcda93a3fbcc55", 818 | "shasum": "" 819 | }, 820 | "require": { 821 | "ext-pcre": "*", 822 | "ext-xml": "*", 823 | "ext-xmlreader": "*", 824 | "php": ">=7.2.0" 825 | }, 826 | "require-dev": { 827 | "friendsofphp/php-cs-fixer": "^2.19 || ^3.8", 828 | "psr/simple-cache": "^1 || ^2 || ^3", 829 | "yoast/phpunit-polyfills": "^1.0.1" 830 | }, 831 | "suggest": { 832 | "ext-curl": "", 833 | "ext-iconv": "", 834 | "ext-intl": "", 835 | "ext-mbstring": "", 836 | "mf2/mf2": "Microformat module that allows for parsing HTML for microformats" 837 | }, 838 | "type": "library", 839 | "autoload": { 840 | "psr-0": { 841 | "SimplePie": "library" 842 | }, 843 | "psr-4": { 844 | "SimplePie\\": "src" 845 | } 846 | }, 847 | "notification-url": "https://packagist.org/downloads/", 848 | "license": [ 849 | "BSD-3-Clause" 850 | ], 851 | "authors": [ 852 | { 853 | "name": "Ryan Parman", 854 | "homepage": "http://ryanparman.com/", 855 | "role": "Creator, alumnus developer" 856 | }, 857 | { 858 | "name": "Sam Sneddon", 859 | "homepage": "https://gsnedders.com/", 860 | "role": "Alumnus developer" 861 | }, 862 | { 863 | "name": "Ryan McCue", 864 | "email": "me@ryanmccue.info", 865 | "homepage": "http://ryanmccue.info/", 866 | "role": "Developer" 867 | } 868 | ], 869 | "description": "A simple Atom/RSS parsing library for PHP", 870 | "homepage": "http://simplepie.org/", 871 | "keywords": [ 872 | "atom", 873 | "feeds", 874 | "rss" 875 | ], 876 | "support": { 877 | "issues": "https://github.com/simplepie/simplepie/issues", 878 | "source": "https://github.com/simplepie/simplepie/tree/1.8.0" 879 | }, 880 | "time": "2023-01-20T08:37:35+00:00" 881 | }, 882 | { 883 | "name": "splitbrain/php-cli", 884 | "version": "1.3.1", 885 | "source": { 886 | "type": "git", 887 | "url": "https://github.com/splitbrain/php-cli.git", 888 | "reference": "844609ef16b8486691b7fd892d54478918f27fe8" 889 | }, 890 | "dist": { 891 | "type": "zip", 892 | "url": "https://api.github.com/repos/splitbrain/php-cli/zipball/844609ef16b8486691b7fd892d54478918f27fe8", 893 | "reference": "844609ef16b8486691b7fd892d54478918f27fe8", 894 | "shasum": "" 895 | }, 896 | "require": { 897 | "php": ">=5.3.0" 898 | }, 899 | "require-dev": { 900 | "phpunit/phpunit": "^8" 901 | }, 902 | "suggest": { 903 | "psr/log": "Allows you to make the CLI available as PSR-3 logger" 904 | }, 905 | "type": "library", 906 | "autoload": { 907 | "psr-4": { 908 | "splitbrain\\phpcli\\": "src" 909 | } 910 | }, 911 | "notification-url": "https://packagist.org/downloads/", 912 | "license": [ 913 | "MIT" 914 | ], 915 | "authors": [ 916 | { 917 | "name": "Andreas Gohr", 918 | "email": "andi@splitbrain.org" 919 | } 920 | ], 921 | "description": "Easy command line scripts for PHP with opt parsing and color output. No dependencies", 922 | "keywords": [ 923 | "argparse", 924 | "cli", 925 | "command line", 926 | "console", 927 | "getopt", 928 | "optparse", 929 | "terminal" 930 | ], 931 | "support": { 932 | "issues": "https://github.com/splitbrain/php-cli/issues", 933 | "source": "https://github.com/splitbrain/php-cli/tree/1.3.1" 934 | }, 935 | "time": "2024-01-17T12:03:34+00:00" 936 | }, 937 | { 938 | "name": "symfony/css-selector", 939 | "version": "v6.4.8", 940 | "source": { 941 | "type": "git", 942 | "url": "https://github.com/symfony/css-selector.git", 943 | "reference": "4b61b02fe15db48e3687ce1c45ea385d1780fe08" 944 | }, 945 | "dist": { 946 | "type": "zip", 947 | "url": "https://api.github.com/repos/symfony/css-selector/zipball/4b61b02fe15db48e3687ce1c45ea385d1780fe08", 948 | "reference": "4b61b02fe15db48e3687ce1c45ea385d1780fe08", 949 | "shasum": "" 950 | }, 951 | "require": { 952 | "php": ">=8.1" 953 | }, 954 | "type": "library", 955 | "autoload": { 956 | "psr-4": { 957 | "Symfony\\Component\\CssSelector\\": "" 958 | }, 959 | "exclude-from-classmap": [ 960 | "/Tests/" 961 | ] 962 | }, 963 | "notification-url": "https://packagist.org/downloads/", 964 | "license": [ 965 | "MIT" 966 | ], 967 | "authors": [ 968 | { 969 | "name": "Fabien Potencier", 970 | "email": "fabien@symfony.com" 971 | }, 972 | { 973 | "name": "Jean-François Simon", 974 | "email": "jeanfrancois.simon@sensiolabs.com" 975 | }, 976 | { 977 | "name": "Symfony Community", 978 | "homepage": "https://symfony.com/contributors" 979 | } 980 | ], 981 | "description": "Converts CSS selectors to XPath expressions", 982 | "homepage": "https://symfony.com", 983 | "support": { 984 | "source": "https://github.com/symfony/css-selector/tree/v6.4.8" 985 | }, 986 | "funding": [ 987 | { 988 | "url": "https://symfony.com/sponsor", 989 | "type": "custom" 990 | }, 991 | { 992 | "url": "https://github.com/fabpot", 993 | "type": "github" 994 | }, 995 | { 996 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 997 | "type": "tidelift" 998 | } 999 | ], 1000 | "time": "2024-05-31T14:49:08+00:00" 1001 | }, 1002 | { 1003 | "name": "symfony/deprecation-contracts", 1004 | "version": "v3.5.0", 1005 | "source": { 1006 | "type": "git", 1007 | "url": "https://github.com/symfony/deprecation-contracts.git", 1008 | "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1" 1009 | }, 1010 | "dist": { 1011 | "type": "zip", 1012 | "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", 1013 | "reference": "0e0d29ce1f20deffb4ab1b016a7257c4f1e789a1", 1014 | "shasum": "" 1015 | }, 1016 | "require": { 1017 | "php": ">=8.1" 1018 | }, 1019 | "type": "library", 1020 | "extra": { 1021 | "branch-alias": { 1022 | "dev-main": "3.5-dev" 1023 | }, 1024 | "thanks": { 1025 | "name": "symfony/contracts", 1026 | "url": "https://github.com/symfony/contracts" 1027 | } 1028 | }, 1029 | "autoload": { 1030 | "files": [ 1031 | "function.php" 1032 | ] 1033 | }, 1034 | "notification-url": "https://packagist.org/downloads/", 1035 | "license": [ 1036 | "MIT" 1037 | ], 1038 | "authors": [ 1039 | { 1040 | "name": "Nicolas Grekas", 1041 | "email": "p@tchwork.com" 1042 | }, 1043 | { 1044 | "name": "Symfony Community", 1045 | "homepage": "https://symfony.com/contributors" 1046 | } 1047 | ], 1048 | "description": "A generic function and convention to trigger deprecation notices", 1049 | "homepage": "https://symfony.com", 1050 | "support": { 1051 | "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.0" 1052 | }, 1053 | "funding": [ 1054 | { 1055 | "url": "https://symfony.com/sponsor", 1056 | "type": "custom" 1057 | }, 1058 | { 1059 | "url": "https://github.com/fabpot", 1060 | "type": "github" 1061 | }, 1062 | { 1063 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 1064 | "type": "tidelift" 1065 | } 1066 | ], 1067 | "time": "2024-04-18T09:32:20+00:00" 1068 | }, 1069 | { 1070 | "name": "symfony/polyfill-ctype", 1071 | "version": "v1.30.0", 1072 | "source": { 1073 | "type": "git", 1074 | "url": "https://github.com/symfony/polyfill-ctype.git", 1075 | "reference": "0424dff1c58f028c451efff2045f5d92410bd540" 1076 | }, 1077 | "dist": { 1078 | "type": "zip", 1079 | "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/0424dff1c58f028c451efff2045f5d92410bd540", 1080 | "reference": "0424dff1c58f028c451efff2045f5d92410bd540", 1081 | "shasum": "" 1082 | }, 1083 | "require": { 1084 | "php": ">=7.1" 1085 | }, 1086 | "provide": { 1087 | "ext-ctype": "*" 1088 | }, 1089 | "suggest": { 1090 | "ext-ctype": "For best performance" 1091 | }, 1092 | "type": "library", 1093 | "extra": { 1094 | "thanks": { 1095 | "name": "symfony/polyfill", 1096 | "url": "https://github.com/symfony/polyfill" 1097 | } 1098 | }, 1099 | "autoload": { 1100 | "files": [ 1101 | "bootstrap.php" 1102 | ], 1103 | "psr-4": { 1104 | "Symfony\\Polyfill\\Ctype\\": "" 1105 | } 1106 | }, 1107 | "notification-url": "https://packagist.org/downloads/", 1108 | "license": [ 1109 | "MIT" 1110 | ], 1111 | "authors": [ 1112 | { 1113 | "name": "Gert de Pagter", 1114 | "email": "BackEndTea@gmail.com" 1115 | }, 1116 | { 1117 | "name": "Symfony Community", 1118 | "homepage": "https://symfony.com/contributors" 1119 | } 1120 | ], 1121 | "description": "Symfony polyfill for ctype functions", 1122 | "homepage": "https://symfony.com", 1123 | "keywords": [ 1124 | "compatibility", 1125 | "ctype", 1126 | "polyfill", 1127 | "portable" 1128 | ], 1129 | "support": { 1130 | "source": "https://github.com/symfony/polyfill-ctype/tree/v1.30.0" 1131 | }, 1132 | "funding": [ 1133 | { 1134 | "url": "https://symfony.com/sponsor", 1135 | "type": "custom" 1136 | }, 1137 | { 1138 | "url": "https://github.com/fabpot", 1139 | "type": "github" 1140 | }, 1141 | { 1142 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 1143 | "type": "tidelift" 1144 | } 1145 | ], 1146 | "time": "2024-05-31T15:07:36+00:00" 1147 | }, 1148 | { 1149 | "name": "symfony/polyfill-mbstring", 1150 | "version": "v1.30.0", 1151 | "source": { 1152 | "type": "git", 1153 | "url": "https://github.com/symfony/polyfill-mbstring.git", 1154 | "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c" 1155 | }, 1156 | "dist": { 1157 | "type": "zip", 1158 | "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/fd22ab50000ef01661e2a31d850ebaa297f8e03c", 1159 | "reference": "fd22ab50000ef01661e2a31d850ebaa297f8e03c", 1160 | "shasum": "" 1161 | }, 1162 | "require": { 1163 | "php": ">=7.1" 1164 | }, 1165 | "provide": { 1166 | "ext-mbstring": "*" 1167 | }, 1168 | "suggest": { 1169 | "ext-mbstring": "For best performance" 1170 | }, 1171 | "type": "library", 1172 | "extra": { 1173 | "thanks": { 1174 | "name": "symfony/polyfill", 1175 | "url": "https://github.com/symfony/polyfill" 1176 | } 1177 | }, 1178 | "autoload": { 1179 | "files": [ 1180 | "bootstrap.php" 1181 | ], 1182 | "psr-4": { 1183 | "Symfony\\Polyfill\\Mbstring\\": "" 1184 | } 1185 | }, 1186 | "notification-url": "https://packagist.org/downloads/", 1187 | "license": [ 1188 | "MIT" 1189 | ], 1190 | "authors": [ 1191 | { 1192 | "name": "Nicolas Grekas", 1193 | "email": "p@tchwork.com" 1194 | }, 1195 | { 1196 | "name": "Symfony Community", 1197 | "homepage": "https://symfony.com/contributors" 1198 | } 1199 | ], 1200 | "description": "Symfony polyfill for the Mbstring extension", 1201 | "homepage": "https://symfony.com", 1202 | "keywords": [ 1203 | "compatibility", 1204 | "mbstring", 1205 | "polyfill", 1206 | "portable", 1207 | "shim" 1208 | ], 1209 | "support": { 1210 | "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.30.0" 1211 | }, 1212 | "funding": [ 1213 | { 1214 | "url": "https://symfony.com/sponsor", 1215 | "type": "custom" 1216 | }, 1217 | { 1218 | "url": "https://github.com/fabpot", 1219 | "type": "github" 1220 | }, 1221 | { 1222 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 1223 | "type": "tidelift" 1224 | } 1225 | ], 1226 | "time": "2024-06-19T12:30:46+00:00" 1227 | }, 1228 | { 1229 | "name": "symfony/polyfill-php81", 1230 | "version": "v1.30.0", 1231 | "source": { 1232 | "type": "git", 1233 | "url": "https://github.com/symfony/polyfill-php81.git", 1234 | "reference": "3fb075789fb91f9ad9af537c4012d523085bd5af" 1235 | }, 1236 | "dist": { 1237 | "type": "zip", 1238 | "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/3fb075789fb91f9ad9af537c4012d523085bd5af", 1239 | "reference": "3fb075789fb91f9ad9af537c4012d523085bd5af", 1240 | "shasum": "" 1241 | }, 1242 | "require": { 1243 | "php": ">=7.1" 1244 | }, 1245 | "type": "library", 1246 | "extra": { 1247 | "thanks": { 1248 | "name": "symfony/polyfill", 1249 | "url": "https://github.com/symfony/polyfill" 1250 | } 1251 | }, 1252 | "autoload": { 1253 | "files": [ 1254 | "bootstrap.php" 1255 | ], 1256 | "psr-4": { 1257 | "Symfony\\Polyfill\\Php81\\": "" 1258 | }, 1259 | "classmap": [ 1260 | "Resources/stubs" 1261 | ] 1262 | }, 1263 | "notification-url": "https://packagist.org/downloads/", 1264 | "license": [ 1265 | "MIT" 1266 | ], 1267 | "authors": [ 1268 | { 1269 | "name": "Nicolas Grekas", 1270 | "email": "p@tchwork.com" 1271 | }, 1272 | { 1273 | "name": "Symfony Community", 1274 | "homepage": "https://symfony.com/contributors" 1275 | } 1276 | ], 1277 | "description": "Symfony polyfill backporting some PHP 8.1+ features to lower PHP versions", 1278 | "homepage": "https://symfony.com", 1279 | "keywords": [ 1280 | "compatibility", 1281 | "polyfill", 1282 | "portable", 1283 | "shim" 1284 | ], 1285 | "support": { 1286 | "source": "https://github.com/symfony/polyfill-php81/tree/v1.30.0" 1287 | }, 1288 | "funding": [ 1289 | { 1290 | "url": "https://symfony.com/sponsor", 1291 | "type": "custom" 1292 | }, 1293 | { 1294 | "url": "https://github.com/fabpot", 1295 | "type": "github" 1296 | }, 1297 | { 1298 | "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", 1299 | "type": "tidelift" 1300 | } 1301 | ], 1302 | "time": "2024-06-19T12:30:46+00:00" 1303 | }, 1304 | { 1305 | "name": "twig/twig", 1306 | "version": "v3.12.0", 1307 | "source": { 1308 | "type": "git", 1309 | "url": "https://github.com/twigphp/Twig.git", 1310 | "reference": "4d19472d4ac1838e0b1f0e029ce1fa4040eb34ea" 1311 | }, 1312 | "dist": { 1313 | "type": "zip", 1314 | "url": "https://api.github.com/repos/twigphp/Twig/zipball/4d19472d4ac1838e0b1f0e029ce1fa4040eb34ea", 1315 | "reference": "4d19472d4ac1838e0b1f0e029ce1fa4040eb34ea", 1316 | "shasum": "" 1317 | }, 1318 | "require": { 1319 | "php": ">=8.0.2", 1320 | "symfony/deprecation-contracts": "^2.5|^3", 1321 | "symfony/polyfill-ctype": "^1.8", 1322 | "symfony/polyfill-mbstring": "^1.3", 1323 | "symfony/polyfill-php81": "^1.29" 1324 | }, 1325 | "require-dev": { 1326 | "psr/container": "^1.0|^2.0", 1327 | "symfony/phpunit-bridge": "^5.4.9|^6.4|^7.0" 1328 | }, 1329 | "type": "library", 1330 | "autoload": { 1331 | "files": [ 1332 | "src/Resources/core.php", 1333 | "src/Resources/debug.php", 1334 | "src/Resources/escaper.php", 1335 | "src/Resources/string_loader.php" 1336 | ], 1337 | "psr-4": { 1338 | "Twig\\": "src/" 1339 | } 1340 | }, 1341 | "notification-url": "https://packagist.org/downloads/", 1342 | "license": [ 1343 | "BSD-3-Clause" 1344 | ], 1345 | "authors": [ 1346 | { 1347 | "name": "Fabien Potencier", 1348 | "email": "fabien@symfony.com", 1349 | "homepage": "http://fabien.potencier.org", 1350 | "role": "Lead Developer" 1351 | }, 1352 | { 1353 | "name": "Twig Team", 1354 | "role": "Contributors" 1355 | }, 1356 | { 1357 | "name": "Armin Ronacher", 1358 | "email": "armin.ronacher@active-4.com", 1359 | "role": "Project Founder" 1360 | } 1361 | ], 1362 | "description": "Twig, the flexible, fast, and secure template language for PHP", 1363 | "homepage": "https://twig.symfony.com", 1364 | "keywords": [ 1365 | "templating" 1366 | ], 1367 | "support": { 1368 | "issues": "https://github.com/twigphp/Twig/issues", 1369 | "source": "https://github.com/twigphp/Twig/tree/v3.12.0" 1370 | }, 1371 | "funding": [ 1372 | { 1373 | "url": "https://github.com/fabpot", 1374 | "type": "github" 1375 | }, 1376 | { 1377 | "url": "https://tidelift.com/funding/github/packagist/twig/twig", 1378 | "type": "tidelift" 1379 | } 1380 | ], 1381 | "time": "2024-08-29T09:51:12+00:00" 1382 | }, 1383 | { 1384 | "name": "wa72/url", 1385 | "version": "v0.7.1", 1386 | "source": { 1387 | "type": "git", 1388 | "url": "https://github.com/wasinger/url.git", 1389 | "reference": "9ae182a0e3408ca8956186eafbbc497144d27d44" 1390 | }, 1391 | "dist": { 1392 | "type": "zip", 1393 | "url": "https://api.github.com/repos/wasinger/url/zipball/9ae182a0e3408ca8956186eafbbc497144d27d44", 1394 | "reference": "9ae182a0e3408ca8956186eafbbc497144d27d44", 1395 | "shasum": "" 1396 | }, 1397 | "require": { 1398 | "php": ">=5.6" 1399 | }, 1400 | "require-dev": { 1401 | "phpunit/phpunit": "^4|^5|^6|^7", 1402 | "psr/http-message": "^1.0" 1403 | }, 1404 | "suggest": { 1405 | "psr/http-message": "For using the Psr7Uri class implementing Psr\\Http\\Message\\UriInterface" 1406 | }, 1407 | "type": "library", 1408 | "autoload": { 1409 | "psr-4": { 1410 | "Wa72\\Url\\": "src/Wa72/Url" 1411 | } 1412 | }, 1413 | "notification-url": "https://packagist.org/downloads/", 1414 | "license": [ 1415 | "MIT" 1416 | ], 1417 | "authors": [ 1418 | { 1419 | "name": "Christoph Singer", 1420 | "email": "singer@webagentur72.de", 1421 | "homepage": "http://www.webagentur72.de" 1422 | } 1423 | ], 1424 | "description": "Class for handling and manipulating URLs", 1425 | "homepage": "http://github.com/wasinger/url", 1426 | "support": { 1427 | "issues": "https://github.com/wasinger/url/issues", 1428 | "source": "https://github.com/wasinger/url/tree/master" 1429 | }, 1430 | "time": "2018-07-25T15:54:06+00:00" 1431 | } 1432 | ], 1433 | "packages-dev": [], 1434 | "aliases": [], 1435 | "minimum-stability": "stable", 1436 | "stability-flags": [], 1437 | "prefer-stable": false, 1438 | "prefer-lowest": false, 1439 | "platform": { 1440 | "ext-pdo": "*", 1441 | "ext-json": "*", 1442 | "ext-curl": "*" 1443 | }, 1444 | "platform-dev": [], 1445 | "platform-overrides": { 1446 | "php": "8.2" 1447 | }, 1448 | "plugin-api-version": "2.6.0" 1449 | } 1450 | -------------------------------------------------------------------------------- /data/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splitbrain/blogrng/073c362f33b9b0e0aa0bcc78f467d7d48de9c8b8/data/.keep -------------------------------------------------------------------------------- /db/0001.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE feeds 2 | ( 3 | feedid TEXT NOT NULL PRIMARY KEY, 4 | feedurl TEXT NOT NULL, 5 | feedtitle TEXT NOT NULL, 6 | homepage TEXT NOT NULL DEFAULT '', 7 | added INTEGER NOT NULL DEFAULT 0, 8 | fetched INTEGER NOT NULL DEFAULT 0, 9 | errors INTEGER NOT NULL DEFAULT 0, 10 | lasterror TEXT NOT NULL DEFAULT '' 11 | ); 12 | 13 | CREATE TABLE items 14 | ( 15 | itemid INTEGER NOT NULL PRIMARY KEY, 16 | feedid TEXT NOT NULL, 17 | itemurl TEXT NOT NULL UNIQUE, 18 | itemtitle TEXT NOT NULL, 19 | published INTEGER NOT NULL, 20 | FOREIGN KEY (feedid) REFERENCES feeds (feedid) ON DELETE CASCADE 21 | ); 22 | 23 | -------------------------------------------------------------------------------- /db/0002.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE suggestions 2 | ( 3 | feedid TEXT NOT NULL PRIMARY KEY, 4 | feedurl TEXT NOT NULL, 5 | feedtitle TEXT NOT NULL, 6 | homepage TEXT NOT NULL DEFAULT '', 7 | added INTEGER NOT NULL DEFAULT 0 8 | ); 9 | -------------------------------------------------------------------------------- /db/0003.sql: -------------------------------------------------------------------------------- 1 | CREATE INDEX idx_published ON items (published); 2 | -------------------------------------------------------------------------------- /db/0004.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE sources 2 | ( 3 | sourceid TEXT NOT NULL PRIMARY KEY, 4 | sourceurl TEXT NOT NULL, 5 | added INTEGER NOT NULL DEFAULT 0 6 | ); 7 | 8 | CREATE TABLE seen 9 | ( 10 | seen TEXT NOT NULL PRIMARY KEY 11 | ); 12 | -------------------------------------------------------------------------------- /db/0005.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `feeds` ADD `mastodon` VARCHAR( 255 ) NOT NULL DEFAULT ''; 2 | -------------------------------------------------------------------------------- /db/0006.sql: -------------------------------------------------------------------------------- 1 | ALTER TABLE `sources` ADD `type` VARCHAR( 50 ) NOT NULL DEFAULT 'feed'; 2 | -------------------------------------------------------------------------------- /docker-compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | web: 3 | build: . 4 | ports: 5 | - "8000:80" 6 | user: "1000:1000" 7 | volumes: 8 | - ./data:/app/data 9 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | RewriteEngine On 2 | RewriteCond %{REQUEST_FILENAME} !-d 3 | RewriteCond %{REQUEST_FILENAME} !-f 4 | RewriteRule ^(.+)$ index.php [QSA,L] 5 | -------------------------------------------------------------------------------- /public/custom.css: -------------------------------------------------------------------------------- 1 | @keyframes spin { 2 | from { 3 | transform: rotate(0deg); 4 | } 5 | 6 | to { 7 | transform: rotate(359deg); 8 | } 9 | } 10 | 11 | #stumble:focus span, 12 | #stumble:hover span { 13 | display: inline-block; 14 | animation: spin 1.5s ease-in-out infinite; 15 | } 16 | 17 | details .postfreq { 18 | display: flex; 19 | align-items: flex-end; 20 | gap: 0.25em; 21 | } 22 | 23 | details .postfreq > div { 24 | background-color: var(--accent); 25 | color: var(--accent-bg); 26 | font-size: x-small; 27 | overflow: hidden; 28 | text-indent: -1000px; 29 | min-width: 1em; 30 | flex-grow: 1; 31 | 32 | } 33 | 34 | .full-width { 35 | width: 100vw; 36 | position: relative; 37 | left: 50%; 38 | right: 50%; 39 | margin-left: -50vw; 40 | margin-right: -50vw; 41 | display: flex; 42 | justify-content: center; 43 | 44 | } 45 | 46 | .full-width > * { 47 | max-width: 1500px; 48 | flex: 100% 1 1; 49 | margin: 1em; 50 | } 51 | -------------------------------------------------------------------------------- /public/dice-192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splitbrain/blogrng/073c362f33b9b0e0aa0bcc78f467d7d48de9c8b8/public/dice-192.png -------------------------------------------------------------------------------- /public/dice-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/splitbrain/blogrng/073c362f33b9b0e0aa0bcc78f467d7d48de9c8b8/public/dice-512.png -------------------------------------------------------------------------------- /public/dice.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | { 2 | /** auto reload the personal history after the stumble button was clicked */ 3 | (function () { 4 | const button = document.getElementById('stumble'); 5 | const output = document.getElementById('seen'); 6 | 7 | async function reloadSeen() { 8 | const data = await fetch('seen'); 9 | output.innerHTML = await data.text(); 10 | } 11 | 12 | if (button) { 13 | button.addEventListener('click', event => { 14 | window.setTimeout(reloadSeen, 1000); 15 | }); 16 | } 17 | })(); 18 | 19 | /** auto open a details block when a hash was passed in the URL */ 20 | (function () { 21 | if (!window.location.hash) return; 22 | const target = document.getElementById(window.location.hash); 23 | if (!target) return; 24 | const details = target.closest('details'); 25 | if (!details) return; 26 | if (!details.open) details.open = true; 27 | })(); 28 | 29 | /** opening/closing a details block adds a hash to the URL */ 30 | (function () { 31 | document.querySelectorAll('summary').forEach(el => el.addEventListener('click', event => { 32 | if (!el.id) return; 33 | const details = el.closest('details'); 34 | 35 | if (details.open) { 36 | history.replaceState({}, '', window.location.pathname) 37 | } else { 38 | history.replaceState({}, '', `#${el.id}`) 39 | } 40 | })); 41 | })(); 42 | 43 | }); 44 | 45 | if ('serviceWorker' in navigator) { 46 | navigator.serviceWorker.register('service-worker.js') 47 | .then(function(reg){ 48 | console.log("Service worker registered."); 49 | }).catch(function(err) { 50 | console.log("Service worker not registered. This happened:", err) 51 | }); 52 | } 53 | -------------------------------------------------------------------------------- /public/service-worker.js: -------------------------------------------------------------------------------- 1 | // dummy worker, just to satisfy the PWA requirements 2 | self.addEventListener('fetch', function (event) { 3 | }); 4 | -------------------------------------------------------------------------------- /public/simple.min.css: -------------------------------------------------------------------------------- 1 | ::backdrop,:root{--sans-font:-apple-system,BlinkMacSystemFont,"Avenir Next",Avenir,"Nimbus Sans L",Roboto,"Noto Sans","Segoe UI",Arial,Helvetica,"Helvetica Neue",sans-serif;--mono-font:Consolas,Menlo,Monaco,"Andale Mono","Ubuntu Mono",monospace;--standard-border-radius:5px;--bg:#fff;--accent-bg:#f5f7ff;--text:#212121;--text-light:#585858;--border:#898EA4;--accent:#0d47a1;--code:#d81b60;--preformatted:#444;--marked:#ffdd33;--disabled:#efefef}@media (prefers-color-scheme:dark){::backdrop,:root{color-scheme:dark;--bg:#212121;--accent-bg:#2b2b2b;--text:#dcdcdc;--text-light:#ababab;--accent:#ffb300;--code:#f06292;--preformatted:#ccc;--disabled:#111}img,video{opacity:.8}}*,::after,::before{box-sizing:border-box}input,progress,select,textarea{appearance:none;-webkit-appearance:none;-moz-appearance:none}html{font-family:var(--sans-font);scroll-behavior:smooth}body{color:var(--text);background-color:var(--bg);font-size:1.15rem;line-height:1.5;display:grid;grid-template-columns:1fr min(45rem,90%) 1fr;margin:0}body>*{grid-column:2}body>header{background-color:var(--accent-bg);border-bottom:1px solid var(--border);text-align:center;padding:0 .5rem 2rem .5rem;grid-column:1/-1}body>header h1{max-width:1200px;margin:1rem auto}body>header p{max-width:40rem;margin:1rem auto}main{padding-top:1.5rem}body>footer{margin-top:4rem;padding:2rem 1rem 1.5rem 1rem;color:var(--text-light);font-size:.9rem;text-align:center;border-top:1px solid var(--border)}h1{font-size:3rem}h2{font-size:2.6rem;margin-top:3rem}h3{font-size:2rem;margin-top:3rem}h4{font-size:1.44rem}h5{font-size:1.15rem}h6{font-size:.96rem}h1,h2,h3,h4,h5,h6,p{overflow-wrap:break-word}h1,h2,h3{line-height:1.1}@media only screen and (max-width:720px){h1{font-size:2.5rem}h2{font-size:2.1rem}h3{font-size:1.75rem}h4{font-size:1.25rem}}a,a:visited{color:var(--accent)}a:hover{text-decoration:none}.button,a.button,button,input[type=button],input[type=reset],input[type=submit],label[type=button]{border:none;border-radius:var(--standard-border-radius);background-color:var(--accent);font-size:1rem;color:var(--bg);padding:.7rem .9rem;margin:.5rem 0;text-decoration:none;font-family:inherit;line-height:normal}.button[aria-disabled=true],button[disabled],input:disabled,select:disabled,textarea:disabled{cursor:not-allowed;background-color:var(--disabled);color:var(--text-light)}input[type=range]{padding:0}abbr[title]{cursor:help;text-decoration-line:underline;text-decoration-style:dotted}.button:not([aria-disabled=true]):hover,button:enabled:hover,input[type=button]:enabled:hover,input[type=reset]:enabled:hover,input[type=submit]:enabled:hover,label[type=button]:hover{filter:brightness(1.4);cursor:pointer}.button:focus-visible,button:focus-visible:where(:enabled),input:enabled:focus-visible:where([type=submit],[type=reset],[type=button]){outline:2px solid var(--accent);outline-offset:1px}header>nav{font-size:1rem;line-height:2;padding:1rem 0 0 0}header>nav ol,header>nav ul{align-content:space-around;align-items:center;display:flex;flex-direction:row;flex-wrap:wrap;justify-content:center;list-style-type:none;margin:0;padding:0}header>nav ol li,header>nav ul li{display:inline-block}header>nav a,header>nav a:visited{margin:0 .5rem 1rem .5rem;border:1px solid var(--border);border-radius:var(--standard-border-radius);color:var(--text);display:inline-block;padding:.1rem 1rem;text-decoration:none}header>nav a.current,header>nav a:hover,header>nav a[aria-current=page]{border-color:var(--accent);color:var(--accent);cursor:pointer}@media only screen and (max-width:720px){header>nav a{border:none;padding:0;text-decoration:underline;line-height:1}}aside,details,pre,progress{background-color:var(--accent-bg);border:1px solid var(--border);border-radius:var(--standard-border-radius);margin-bottom:1rem}aside{font-size:1rem;width:30%;padding:0 15px;margin-inline-start:15px;float:right}[dir=rtl] aside{float:left}@media only screen and (max-width:720px){aside{width:100%;float:none;margin-inline-start:0}}article,dialog,fieldset{border:1px solid var(--border);padding:1rem;border-radius:var(--standard-border-radius);margin-bottom:1rem}article h2:first-child,section h2:first-child{margin-top:1rem}section{border-top:1px solid var(--border);border-bottom:1px solid var(--border);padding:2rem 1rem;margin:3rem 0}section+section,section:first-child{border-top:0;padding-top:0}section:last-child{border-bottom:0;padding-bottom:0}details{padding:.7rem 1rem}summary{cursor:pointer;font-weight:700;padding:.7rem 1rem;margin:-.7rem -1rem;word-break:break-all}details[open]>summary+*{margin-top:0}details[open]>summary{margin-bottom:.5rem}details[open]>:last-child{margin-bottom:0}table{border-collapse:collapse;margin:1.5rem 0}td,th{border:1px solid var(--border);text-align:start;padding:.5rem}th{background-color:var(--accent-bg);font-weight:700}tr:nth-child(even){background-color:var(--accent-bg)}table caption{font-weight:700;margin-bottom:.5rem}input,select,textarea{font-size:inherit;font-family:inherit;padding:.5rem;margin-bottom:.5rem;color:var(--text);background-color:var(--bg);border:1px solid var(--border);border-radius:var(--standard-border-radius);box-shadow:none;max-width:100%;display:inline-block}label{display:block}textarea:not([cols]){width:100%}select:not([multiple]){background-image:linear-gradient(45deg,transparent 49%,var(--text) 51%),linear-gradient(135deg,var(--text) 51%,transparent 49%);background-position:calc(100% - 15px),calc(100% - 10px);background-size:5px 5px,5px 5px;background-repeat:no-repeat;padding-inline-end:25px}[dir=rtl] select:not([multiple]){background-position:10px,15px}input[type=checkbox],input[type=radio]{vertical-align:middle;position:relative;width:min-content}input[type=checkbox]+label,input[type=radio]+label{display:inline-block}input[type=radio]{border-radius:100%}input[type=checkbox]:checked,input[type=radio]:checked{background-color:var(--accent)}input[type=checkbox]:checked::after{content:" ";width:.18em;height:.32em;border-radius:0;position:absolute;top:.05em;left:.17em;background-color:transparent;border-right:solid var(--bg) .08em;border-bottom:solid var(--bg) .08em;font-size:1.8em;transform:rotate(45deg)}input[type=radio]:checked::after{content:" ";width:.25em;height:.25em;border-radius:100%;position:absolute;top:.125em;background-color:var(--bg);left:.125em;font-size:32px}@media only screen and (max-width:720px){input,select,textarea{width:100%}}input[type=color]{height:2.5rem;padding:.2rem}input[type=file]{border:0}hr{border:none;height:1px;background:var(--border);margin:1rem auto}mark{padding:2px 5px;border-radius:var(--standard-border-radius);background-color:var(--marked);color:#000}mark a{color:#0d47a1}img,video{max-width:100%;height:auto;border-radius:var(--standard-border-radius)}figure{margin:0;display:block;overflow-x:auto}figcaption{text-align:center;font-size:.9rem;color:var(--text-light);margin-bottom:1rem}blockquote{margin-inline-start:2rem;margin-inline-end:0;margin-block:2rem;padding:.4rem .8rem;border-inline-start:.35rem solid var(--accent);color:var(--text-light);font-style:italic}cite{font-size:.9rem;color:var(--text-light);font-style:normal}dt{color:var(--text-light)}code,kbd,pre,pre span,samp{font-family:var(--mono-font);color:var(--code)}kbd{color:var(--preformatted);border:1px solid var(--preformatted);border-bottom:3px solid var(--preformatted);border-radius:var(--standard-border-radius);padding:.1rem .4rem}pre{padding:1rem 1.4rem;max-width:100%;overflow:auto;color:var(--preformatted)}pre code{color:var(--preformatted);background:0 0;margin:0;padding:0}progress{width:100%}progress:indeterminate{background-color:var(--accent-bg)}progress::-webkit-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent-bg)}progress::-webkit-progress-value{border-radius:var(--standard-border-radius);background-color:var(--accent)}progress::-moz-progress-bar{border-radius:var(--standard-border-radius);background-color:var(--accent);transition-property:width;transition-duration:.3s}progress:indeterminate::-moz-progress-bar{background-color:var(--accent-bg)}dialog{max-width:40rem;margin:auto}dialog::backdrop{background-color:var(--bg);opacity:.8}@media only screen and (max-width:720px){dialog{max-width:100%;margin:auto 1em}}sub,sup{vertical-align:baseline;position:relative}sup{top:-.4em}sub{top:.3em}.notice{background:var(--accent-bg);border:2px solid var(--border);border-radius:5px;padding:1.5rem;margin:2rem 0} -------------------------------------------------------------------------------- /src/CLI.php: -------------------------------------------------------------------------------- 1 | useCompactHelp(true); 20 | $options->setHelp('Manage the feeds'); 21 | 22 | $options->registerCommand('add', 'Adds a feed'); 23 | $options->registerArgument('feedurl', 'The URL to the RSS/Atom feed', true, 'add'); 24 | 25 | $options->registerCommand('update', 26 | 'Get the newest items for all feeds and update auto suggestions'); 27 | $options->registerOption('skipsources', 'Skip updating auto suggestion sources', 's', false, 'update'); 28 | 29 | $options->registerCommand('inspect', 'Inspect the given feed or item'); 30 | $options->registerArgument('id', 'Feed or item id', true, 'inspect'); 31 | 32 | $options->registerCommand('fetch', 'Fetch the items for a single feed'); 33 | $options->registerArgument('id', 'Feed id', true, 'fetch'); 34 | 35 | $options->registerCommand('delete', 'Delete the given feed'); 36 | $options->registerArgument('id', 'Feed id', true, 'delete'); 37 | 38 | $options->registerCommand('listSources', 'List all auto suggestion sources'); 39 | 40 | $options->registerCommand('addSource', 'Add a feed or plain text list as auto suggestion source'); 41 | $options->registerArgument('sourceurl', 'The URL to the RSS/Atom feed or text list', true, 'addSource'); 42 | 43 | $options->registerCommand('addHN', 'Add links found in a hackernews post'); 44 | $options->registerArgument('id', 'The ID of the post', true, 'addHN'); 45 | 46 | $options->registerCommand('findProfiles', 'Find Mastodon profiles associated with the feeds'); 47 | 48 | $options->registerCommand('postRandom', 'Post a random item to Mastodon'); 49 | 50 | $options->registerCommand('config', 'Set a configuration value'); 51 | $options->registerArgument('key', 'The config key (adminpass|token|instance)', true, 'config'); 52 | $options->registerArgument('value', 'The value to set', true, 'config'); 53 | 54 | $options->registerCommand('rss', 'Generate the RSS feeds'); 55 | $options->registerOption('force', 'Force a refresh of the feed', 'f', false, 'rss'); 56 | } 57 | 58 | /** @inheritdoc */ 59 | protected function main(Options $options) 60 | { 61 | $this->feedManager = new FeedManager(); 62 | 63 | 64 | $args = $options->getArgs(); 65 | $cmd = $options->getCmd(); 66 | switch ($cmd) { 67 | case 'add': 68 | return $this->addFeed($args[0]); 69 | case 'update': 70 | return $this->updateFeeds($options->getOpt('skipsources', false)); 71 | case 'inspect': 72 | return $this->inspect($args[0]); 73 | case 'fetch': 74 | return $this->fetchFeed($args[0]); 75 | case 'delete': 76 | return $this->delete($args[0]); 77 | case 'config': 78 | return $this->config($args[0], $args[1]); 79 | case 'listSources': 80 | return $this->listSources(); 81 | case 'addSource': 82 | return $this->addSource($args[0]); 83 | case 'addHN': 84 | return $this->processHackerNewsItem((int)$args[0]); 85 | case 'findProfiles'; 86 | return $this->updateMastodonProfiles(); 87 | case 'postRandom'; 88 | return $this->postRandom(); 89 | case 'rss'; 90 | return $this->rss($options->getOpt('force', false)); 91 | default: 92 | echo $options->help(); 93 | return 0; 94 | } 95 | } 96 | 97 | /** 98 | * Create all the feeds 99 | * 100 | * @return int 101 | */ 102 | protected function rss($force) 103 | { 104 | $rss = new RSS(); 105 | if ($force) $rss->forceRefresh(); 106 | $rss->createAllFeeds($this); 107 | return 0; 108 | } 109 | 110 | /** 111 | * Add a new feed 112 | * 113 | * @param string $feedurl 114 | * @return int 115 | */ 116 | protected function addFeed($feedurl) 117 | { 118 | try { 119 | $feed = $this->feedManager->addFeed($feedurl); 120 | $this->success('[{feedid}] {feedtitle}', $feed); 121 | return 0; 122 | } catch (Exception $e) { 123 | $this->error($e->getMessage()); 124 | $this->debug($e->getTraceAsString()); 125 | return 1; 126 | } 127 | } 128 | 129 | /** 130 | * Add a new source 131 | * 132 | * @param string $sourceurl 133 | * @return int 134 | */ 135 | protected function addSource($sourceurl) 136 | { 137 | try { 138 | $source = $this->feedManager->addSource($sourceurl); 139 | $this->success('[{sourceid}] {sourceurl}', $source); 140 | return 0; 141 | } catch (Exception $e) { 142 | $this->error($e->getMessage()); 143 | $this->debug($e->getTraceAsString()); 144 | return 1; 145 | } 146 | } 147 | 148 | /** 149 | * List all sources 150 | * 151 | * @return int 152 | */ 153 | protected function listSources() 154 | { 155 | $sources = $this->feedManager->getSources(); 156 | foreach ($sources as $source) { 157 | $this->info('{type}: {sourceurl}', $source); 158 | } 159 | return 0; 160 | } 161 | 162 | /** 163 | * Update a single feed 164 | * 165 | * @param string $feedId 166 | * @return int 167 | */ 168 | protected function fetchFeed($feedId) 169 | { 170 | $feed = $this->feedManager->getFeed($feedId); 171 | if(!$feed) { 172 | $this->error('Feed not found'); 173 | return 1; 174 | } 175 | try { 176 | $count = $this->feedManager->fetchFeedItems($feed); 177 | $this->success('[{feedid}] {count} items found', ['feedid' => $feed['feedid'], 'count' => $count]); 178 | return 0; 179 | } catch (\Throwable $e) { 180 | $this->error('[{feedid}] {msg}', ['feedid' => $feed['feedid'], 'msg' => $e->getMessage()]); 181 | $this->debug($e->getTraceAsString()); 182 | return 1; 183 | } 184 | } 185 | 186 | /** 187 | * Update all the feeds and sources 188 | * 189 | * @return int 190 | */ 191 | protected function updateFeeds($skipSources = false) 192 | { 193 | if (!$skipSources) { 194 | $sources = $this->feedManager->getSources(); 195 | foreach ($sources as $source) { 196 | $this->info('Fetching suggestions from {source}', ['source' => $source['sourceurl']]); 197 | try { 198 | $count = $this->feedManager->fetchSource($source); 199 | $this->success( 200 | 'Found {count} new suggestions at {source}', 201 | ['count' => $count, 'source' => $source['sourceurl']] 202 | ); 203 | } catch (\Throwable $e) { 204 | $this->error( 205 | 'Error fetching suggestions from {source}: {msg}', 206 | ['source' => $source['sourceurl'], 'msg' => $e->getMessage()] 207 | ); 208 | $this->debug($e->getTraceAsString()); 209 | } 210 | } 211 | } 212 | 213 | $feeds = $this->feedManager->getAllUpdatableFeeds(); 214 | foreach ($feeds as $feed) { 215 | try { 216 | $count = $this->feedManager->fetchFeedItems($feed); 217 | $this->success('[{feedid}] {count} items found', ['feedid' => $feed['feedid'], 'count' => $count]); 218 | } catch (\Throwable $e) { 219 | $this->error('[{feedid}] {msg}', ['feedid' => $feed['feedid'], 'msg' => $e->getMessage()]); 220 | $this->debug($e->getTraceAsString()); 221 | } 222 | } 223 | $this->feedManager->db()->exec('VACUUM'); 224 | return 0; 225 | } 226 | 227 | /** 228 | * Inspect the given post or feed 229 | * 230 | * @param string|int $id 231 | * @return int 232 | */ 233 | protected function inspect($id) 234 | { 235 | 236 | if (strlen($id) === 32) { 237 | $data = $this->feedManager->getFeed($id); 238 | } else { 239 | $data = $this->feedManager->getItem($id); 240 | } 241 | 242 | if (!$data) { 243 | $this->error('Could not find any data for given ID'); 244 | return 1; 245 | } 246 | 247 | $td = new TableFormatter($this->colors); 248 | foreach ($data as $key => $val) { 249 | echo $td->format([15, '*'], [$key, $val]); 250 | } 251 | return 0; 252 | } 253 | 254 | /** 255 | * Delete the feed 256 | * 257 | * @param string $id 258 | * @return int 259 | */ 260 | protected function delete($id) 261 | { 262 | 263 | try { 264 | $this->feedManager->deleteFeed($id); 265 | $this->success('Feed deleted'); 266 | return 0; 267 | } catch (Exception $e) { 268 | $this->error($e->getMessage()); 269 | $this->debug($e->getTraceAsString()); 270 | return 1; 271 | } 272 | } 273 | 274 | /** 275 | * Set a config option 276 | * 277 | * @param string $key 278 | * @param string $value 279 | * @return int 280 | */ 281 | public function config($key, $value) 282 | { 283 | $allowed = ['adminpass', 'token', 'instance']; 284 | if (!in_array($key, $allowed)) { 285 | $this->error('Invalid config key'); 286 | return 1; 287 | } 288 | 289 | if ($key === 'adminpass') { 290 | $value = password_hash($value, PASSWORD_DEFAULT); 291 | } 292 | 293 | $this->feedManager->db()->setOpt($key, $value); 294 | return 0; 295 | 296 | } 297 | 298 | /** 299 | * Find Mastodon profiles associated with the feeds 300 | * 301 | * @return int 302 | */ 303 | protected function updateMastodonProfiles() 304 | { 305 | $feeds = $this->feedManager->getAllFeeds(); 306 | 307 | foreach ($feeds as $feed) { 308 | $mastodon = new Mastodon(); 309 | $profile = $mastodon->getProfile($feed['homepage']); 310 | 311 | if ($profile !== $feed['mastodon']) { 312 | $feed['mastodon'] = $profile; 313 | $this->feedManager->db()->saveRecord('feeds', $feed); 314 | } 315 | 316 | if ($profile) { 317 | $this->success('Found profile {profile} for {hp}', ['profile' => $profile, 'hp' => $feed['homepage']]); 318 | } else { 319 | $this->error('Could not find profile for {hp}', ['hp' => $feed['homepage']]); 320 | } 321 | } 322 | return 0; 323 | } 324 | 325 | /** 326 | * Post a random item to Mastodon 327 | * 328 | * @return int 329 | */ 330 | public function postRandom() 331 | { 332 | $token = $this->feedManager->db()->getOpt('token'); 333 | $instance = $this->feedManager->db()->getOpt('instance'); 334 | 335 | if (!$token || !$instance) { 336 | $this->error('No Mastodon token or instance configured'); 337 | return 1; 338 | } 339 | 340 | $post = $this->feedManager->getRandom(); 341 | 342 | $mastodon = new Mastodon(); 343 | $result = $mastodon->postItem($post, $instance, $token); 344 | 345 | if (isset($result['url'])) { 346 | $this->success('Posted {url}', ['url' => $result['url']]); 347 | return 0; 348 | } else { 349 | $this->error('Error posting: {error}', ['error' => $result['error']]); 350 | return 1; 351 | } 352 | } 353 | 354 | /** 355 | * Extracts links from the given HTML and adds them to the database 356 | * 357 | * @param string $html 358 | * @return void 359 | */ 360 | protected function addLinksFromHTML($html) 361 | { 362 | $regex = '/href="(https?:\/\/[^"]+)/'; 363 | preg_match_all($regex, $html, $matches); 364 | 365 | $first = ''; 366 | foreach ($matches[1] as $url) { 367 | if (preg_match('/(ycombinator|hnsearch|hn\.algolia)/', $url)) continue; 368 | if (preg_match('/(blogspot\.com|hnapp\.com|substack\.com|github\.|medium\.com)/', $url)) continue; 369 | if (preg_match('/(tailscale\.dev|youtube\.com|wikipedia\.org|bearblog\.dev)/', $url)) continue; 370 | if (preg_match('/(\.micro\.blog|sr\.ht|tumblr\.com|ng-tech\.icu)/', $url)) continue; 371 | 372 | if (!$first) { 373 | // remember the first link, that's usually the main blog url 374 | $first = $url; 375 | } else { 376 | // if follow up links are just articles on the main site, skip them 377 | if (strpos($url, $first) === 0) { 378 | continue; 379 | } 380 | } 381 | 382 | try { 383 | $feed = $this->feedManager->addFeed($url); 384 | $this->success('{url} {feedid} added', ['url' => $url, 'feedid' => $feed['feedid']]); 385 | } catch (Exception $e) { 386 | $this->error($url.' '.$e->getMessage()); 387 | } 388 | } 389 | } 390 | 391 | /** 392 | * Recusively process the given Hacker News item and extract links from it 393 | * 394 | * @param int $id 395 | * @return int 396 | */ 397 | protected function processHackerNewsItem($id) 398 | { 399 | $this->info('Processing HN item {id}', ['id' => $id]); 400 | $json = file_get_contents("https://hacker-news.firebaseio.com/v0/item/$id.json"); 401 | $item = json_decode($json, true); 402 | 403 | if (isset($item['text'])) { 404 | $this->addLinksFromHTML(html_entity_decode($item['text'])); 405 | } 406 | 407 | if (isset($item['kids'])) foreach ($item['kids'] as $kid) { 408 | $this->processHackerNewsItem($kid); 409 | } 410 | 411 | return 1; 412 | } 413 | 414 | } 415 | -------------------------------------------------------------------------------- /src/Controller.php: -------------------------------------------------------------------------------- 1 | twig = new Environment($loader); 29 | $this->twig->addGlobal('cachebuster', max( 30 | filemtime(__DIR__ . '/../public/custom.css'), 31 | filemtime(__DIR__ . '/../public/script.js') 32 | )); 33 | 34 | $this->feedManager = new FeedManager(); 35 | $this->cookieManager = new CookieManager(); 36 | 37 | } 38 | 39 | /** 40 | * Views are methods in this class 41 | * 42 | * @param string $view 43 | * @return void 44 | */ 45 | public function __invoke($view = '') 46 | { 47 | if ($view === '') $view = 'index'; 48 | 49 | if (is_callable([$this, $view])) { 50 | $this->$view(); 51 | } else { 52 | $this->notFound(); 53 | } 54 | } 55 | 56 | /** 57 | * Ensure only superuser may continue 58 | */ 59 | protected function requireAuth() 60 | { 61 | if ( 62 | !isset($_SERVER['PHP_AUTH_PW']) || 63 | !password_verify($_SERVER['PHP_AUTH_PW'], $this->feedManager->db()->getOpt('adminpass', '')) 64 | ) { 65 | header('WWW-Authenticate: Basic realm="My Realm"'); 66 | header('HTTP/1.0 401 Unauthorized'); 67 | echo $this->twig->render('401.twig'); 68 | exit; 69 | } 70 | } 71 | 72 | public function notFound() 73 | { 74 | http_response_code(404); 75 | echo $this->twig->render('404.twig'); 76 | } 77 | 78 | public function index() 79 | { 80 | $context = [ 81 | 'seen' => $this->feedManager->getLastSeen($this->cookieManager->getSeenPostIDs()), 82 | ]; 83 | 84 | echo $this->twig->render('index.twig', $context); 85 | } 86 | 87 | public function faq() 88 | { 89 | $context = [ 90 | 'stats' => $this->feedManager->getStats() 91 | ]; 92 | 93 | echo $this->twig->render('faq.twig', $context); 94 | } 95 | 96 | public function all() 97 | { 98 | echo $this->twig->render('all.twig'); 99 | } 100 | 101 | public function suggest() 102 | { 103 | $context = []; 104 | 105 | // Android share often posts the URL in the text field for some reason 106 | if (!isset($_POST['suggest']) && isset($_POST['text']) && 107 | preg_match('#\bhttps?://[^,\s()<>]+(?:\([\w\d]+\)|([^,[:punct:]\s]|/))#i', $_POST['text'], $m)) { 108 | $_POST['suggest'] = $m[0]; 109 | } 110 | 111 | if (isset($_POST['suggest']) && 112 | preg_match('/^https?:\/\//', $_POST['suggest']) && 113 | empty($_POST['title'])) { 114 | 115 | try { 116 | $context['feed'] = $this->feedManager->suggestFeed($_POST['suggest']); 117 | } catch (\Exception $e) { 118 | $context['error'] = $e->getMessage(); 119 | } 120 | } 121 | 122 | echo $this->twig->render('suggest.twig', $context); 123 | } 124 | 125 | public function random() 126 | { 127 | $post = $this->feedManager->getRandoms($this->cookieManager->getSeenPostIDs())[0]; 128 | $this->cookieManager->addSeenPostID($post['itemid']); 129 | header('Location: ' . self::campaignURL($post['itemurl'])); 130 | } 131 | 132 | public function seen() 133 | { 134 | $seen = $this->feedManager->getLastSeen($this->cookieManager->getSeenPostIDs()); 135 | echo $this->twig->render('partials/seen.twig', ['seen' => $seen]); 136 | } 137 | 138 | public function export() 139 | { 140 | $stmt = $this->feedManager->getAllFeedsWithDetails(true); 141 | 142 | header('Content-Type: application/json'); 143 | echo "[\n"; 144 | $firstRowDone = false; 145 | while ($row = $stmt->fetch(\PDO::FETCH_ASSOC)) { 146 | if ($firstRowDone) echo ",\n"; 147 | echo json_encode($row, JSON_PRETTY_PRINT); 148 | $firstRowDone = true; 149 | } 150 | echo "]\n"; 151 | $stmt->closeCursor(); 152 | } 153 | 154 | public function admin() 155 | { 156 | $this->requireAuth(); 157 | $context = []; 158 | 159 | if (isset($_REQUEST['add'])) { 160 | try { 161 | $context['feed'] = $this->feedManager->addFeed($_REQUEST['add']); 162 | $this->feedManager->removeSuggestion($context['feed']['feedid']); 163 | } catch (\Exception $e) { 164 | $context['error'] = $e->getMessage(); 165 | } 166 | } 167 | 168 | if (isset($_REQUEST['remove'])) { 169 | $this->feedManager->removeSuggestion($_REQUEST['remove']); 170 | } 171 | 172 | if (isset($_REQUEST['delete'])) { 173 | try { 174 | $this->feedManager->deleteFeed($_REQUEST['delete']); 175 | } catch (\Exception $e) { 176 | $context['error'] = $e->getMessage(); 177 | } 178 | } 179 | 180 | $context['suggestions'] = $this->feedManager->getSuggestions(); 181 | 182 | echo $this->twig->render('admin.twig', $context); 183 | } 184 | 185 | public function inspect() 186 | { 187 | $this->requireAuth(); 188 | $context = []; 189 | 190 | if(isset($_REQUEST['id'])){ 191 | $id = substr($_REQUEST['id'], 0, 32); 192 | 193 | if(!empty($_REQUEST['reset'])) { 194 | try { 195 | $this->feedManager->resetFeedErrors($id); 196 | } catch (\Exception $ignored) { 197 | } 198 | } 199 | 200 | $context['feed'] = $this->feedManager->getFeed($id); 201 | $context['items'] = $this->feedManager->getFeedItems($id); 202 | } 203 | 204 | echo $this->twig->render('inspect.twig', $context); 205 | } 206 | 207 | public function rss() 208 | { 209 | echo $this->twig->render('rss.twig'); 210 | } 211 | 212 | public function dailyfeed() 213 | { 214 | $num = 1; 215 | if (isset($_REQUEST['num'])) $num = (int)$_REQUEST['num']; 216 | $rss = new RSS(); 217 | header('Content-Type: application/rss+xml'); 218 | echo $rss->getFeed(1, $num); 219 | } 220 | 221 | public function weeklyfeed() 222 | { 223 | $num = 5; 224 | if (isset($_REQUEST['num'])) $num = (int)$_REQUEST['num']; 225 | $rss = new RSS(); 226 | header('Content-Type: application/rss+xml'); 227 | echo $rss->getFeed(7, $num); 228 | } 229 | 230 | /** 231 | * Add campaign info 232 | * 233 | * @param string $url 234 | * @param string $type 235 | * @return string 236 | */ 237 | public static function campaignURL($url, $type = 'random') 238 | { 239 | $url = new Url($url); 240 | $url->setQueryParameter('utm_source', 'indieblog.page'); 241 | $url->setQueryParameter('utm_medium', $type); 242 | $url->setQueryParameter('utm_campaign', 'indieblog.page'); 243 | return $url->write(); 244 | } 245 | } 246 | -------------------------------------------------------------------------------- /src/CookieManager.php: -------------------------------------------------------------------------------- 1 | seen = array_map('intval', explode(',', $_COOKIE[self::COOKIENAME])); 17 | } 18 | } 19 | 20 | /** 21 | * Add a new post ID to the seen posts cookie 22 | * 23 | * @param int $id 24 | * @return void 25 | */ 26 | public function addSeenPostID($id) 27 | { 28 | array_unshift($this->seen, $id); 29 | if (count($this->seen) > self::MAX) { 30 | $this->seen = array_slice($this->seen, 0, self::MAX); 31 | } 32 | 33 | $expire = time() + 60 * 60 * 24 * 365; 34 | setcookie(self::COOKIENAME, join(',', $this->seen), $expire); 35 | } 36 | 37 | /** 38 | * Get all the seen posts, newest first 39 | * 40 | * @return array 41 | */ 42 | public function getSeenPostIDs() 43 | { 44 | return $this->seen; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/DataBase.php: -------------------------------------------------------------------------------- 1 | schemadir = $schemadir; 26 | if (is_a($database, \PDO::class)) { 27 | $this->pdo = $database; 28 | } else { 29 | $this->pdo = new \PDO( 30 | 'sqlite:' . $database, 31 | null, 32 | null, 33 | [ 34 | \PDO::ATTR_ERRMODE => \PDO::ERRMODE_EXCEPTION 35 | ] 36 | ); 37 | } 38 | 39 | // apply migrations if needed 40 | $currentVersion = $this->currentDbVersion(); 41 | $migrations = $this->getMigrationsToApply($currentVersion); 42 | if ($migrations) { 43 | foreach ($migrations as $version => $database) { 44 | $this->applyMigration($database, $version); 45 | } 46 | $this->pdo->exec('VACUUM'); 47 | } 48 | } 49 | 50 | // region public API 51 | 52 | /** 53 | * Direct access to the PDO object 54 | * @return \PDO 55 | */ 56 | public function pdo() 57 | { 58 | return $this->pdo; 59 | } 60 | 61 | /** 62 | * Execute a statement 63 | * 64 | * Returns the last insert ID on INSERTs or the number of affected rows 65 | * 66 | * @param string $sql 67 | * @param array $parameters 68 | * @return int 69 | */ 70 | public function exec($sql, $parameters = []) 71 | { 72 | $stmt = $this->pdo->prepare($sql); 73 | $stmt->execute($parameters); 74 | 75 | $count = $stmt->rowCount(); 76 | if ($count && preg_match('/^INSERT /i', $sql)) { 77 | return $this->queryValue('SELECT last_insert_rowid()'); 78 | } 79 | 80 | return $count; 81 | } 82 | 83 | /** 84 | * Simple query abstraction 85 | * 86 | * Returns all data 87 | * 88 | * @param string $sql 89 | * @param array $params 90 | * @return array|false 91 | * @throws \PDOException 92 | */ 93 | public function queryAll($sql, $params = []) 94 | { 95 | $stmt = $this->pdo->prepare($sql); 96 | $stmt->execute($params); 97 | $data = $stmt->fetchAll(\PDO::FETCH_ASSOC); 98 | $stmt->closeCursor(); 99 | return $data; 100 | } 101 | 102 | /** 103 | * Query one single row 104 | * 105 | * @param string $sql 106 | * @param array $parameters 107 | * @return array|null 108 | */ 109 | public function queryRecord($sql, $parameters = []) 110 | { 111 | $stmt = $this->pdo->prepare($sql); 112 | $stmt->execute($parameters); 113 | $row = $stmt->fetch(); 114 | $stmt->closeCursor(); 115 | if (is_array($row) && count($row)) return $row; 116 | return null; 117 | } 118 | 119 | /** 120 | * Insert or replace the given data into the table 121 | * 122 | * @param string $table 123 | * @param array $data 124 | * @param bool $replace Conflict resolution, replace or ignore 125 | * @return void 126 | */ 127 | public function saveRecord($table, $data, $replace = true) 128 | { 129 | $columns = array_map(function ($column) { 130 | return '"' . $column . '"'; 131 | }, array_keys($data)); 132 | $values = array_values($data); 133 | $placeholders = array_pad([], count($columns), '?'); 134 | 135 | if ($replace) { 136 | $command = 'REPLACE'; 137 | } else { 138 | $command = 'INSERT OR IGNORE'; 139 | } 140 | 141 | /** @noinspection SqlResolve */ 142 | $sql = $command . ' INTO "' . $table . '" (' . join(',', $columns) . ') VALUES (' . join(',', $placeholders) . ')'; 143 | $stm = $this->pdo->prepare($sql); 144 | $stm->execute($values); 145 | $stm->closeCursor(); 146 | } 147 | 148 | /** 149 | * Execute a query that returns a single value 150 | * 151 | * @param string $sql 152 | * @param array $params 153 | * @return mixed|null 154 | */ 155 | public function queryValue($sql, $params = []) 156 | { 157 | $result = $this->queryAll($sql, $params); 158 | if (is_array($result) && count($result)) return array_values($result[0])[0]; 159 | return null; 160 | } 161 | 162 | /** 163 | * Get a config value from the opt table 164 | * 165 | * @param string $conf Config name 166 | * @param mixed $default What to return if the value isn't set 167 | * @return mixed 168 | */ 169 | public function getOpt($conf, $default = null) 170 | { 171 | $value = $this->queryValue("SELECT val FROM opt WHERE conf = ?", [$conf]); 172 | if ($value === null) return $default; 173 | return $value; 174 | } 175 | 176 | /** 177 | * Set a config value in the opt table 178 | * 179 | * @param $conf 180 | * @param $value 181 | * @return void 182 | */ 183 | public function setOpt($conf, $value) 184 | { 185 | $this->exec('REPLACE INTO opt (conf,val) VALUES (?,?)', [$conf, $value]); 186 | } 187 | 188 | // endregion 189 | 190 | // region migration handling 191 | 192 | /** 193 | * Read the current version from the opt table 194 | * 195 | * The opt table is created here if not found 196 | * 197 | * @return int 198 | */ 199 | protected function currentDbVersion() 200 | { 201 | $sql = "SELECT val FROM opt WHERE conf = 'dbversion'"; 202 | try { 203 | $version = $this->queryValue($sql); 204 | return (int)$version; 205 | } catch (\PDOException $ignored) { 206 | // add the opt table - if this fails too, let the exception bubble up 207 | $sql = "CREATE TABLE IF NOT EXISTS opt (conf TEXT NOT NULL PRIMARY KEY, val NOT NULL DEFAULT '')"; 208 | $this->pdo->exec($sql); 209 | $sql = "INSERT INTO opt (conf, val) VALUES ('dbversion', 0)"; 210 | $this->pdo->exec($sql); 211 | return 0; 212 | } 213 | } 214 | 215 | /** 216 | * Get all schema files that have not been applied, yet 217 | * 218 | * @param int $current 219 | * @return array 220 | */ 221 | protected function getMigrationsToApply($current) 222 | { 223 | $files = glob($this->schemadir . '/*.sql'); 224 | $upgrades = []; 225 | foreach ($files as $file) { 226 | $file = basename($file); 227 | if (!preg_match('/^(\d+)/', $file, $m)) continue; 228 | if ((int)$m[1] <= $current) continue; 229 | $upgrades[(int)$m[1]] = $file; 230 | } 231 | return $upgrades; 232 | } 233 | 234 | /** 235 | * Apply the migration in the given file, upgrading to the given version 236 | * 237 | * @param string $file 238 | * @param int $version 239 | */ 240 | protected function applyMigration($file, $version) 241 | { 242 | $sql = file_get_contents($this->schemadir . '/' . $file); 243 | 244 | $this->pdo->beginTransaction(); 245 | try { 246 | $this->pdo->exec($sql); 247 | $st = $this->pdo->prepare('REPLACE INTO opt ("conf", "val") VALUES (:conf, :val)'); 248 | $st->execute([':conf' => 'dbversion', ':val' => $version]); 249 | $this->pdo->commit(); 250 | } catch (\PDOException $e) { 251 | $this->pdo->rollBack(); 252 | throw $e; 253 | } 254 | } 255 | 256 | // endregion 257 | } 258 | -------------------------------------------------------------------------------- /src/FeedManager.php: -------------------------------------------------------------------------------- 1 | db = new DataBase( 26 | __DIR__ . '/../data/blogrng.sqlite', 27 | __DIR__ . '/../db/' 28 | ); 29 | } 30 | 31 | /** 32 | * Access to the database 33 | * 34 | * @return DataBase 35 | */ 36 | public function db() 37 | { 38 | return $this->db; 39 | } 40 | 41 | /** 42 | * Get a single random post 43 | * 44 | * This tries to be fair by giving each feed the same chance to be picked, regardless of 45 | * post frequency 46 | * 47 | * @param int $seenPostIDs 48 | * @return array 49 | */ 50 | public function getRandom($seenPostIDs = []) 51 | { 52 | $seenPostIDs = array_map('intval', $seenPostIDs); 53 | $seenPostIDs = join(',', $seenPostIDs); 54 | $mindate = time() - self::MAXAGE; 55 | 56 | // select a single distinct feed that has recent, unseen posts first 57 | $sql = " 58 | SELECT DISTINCT F.feedid 59 | FROM items I, feeds F 60 | WHERE I.feedid = F.feedid 61 | AND I.itemid NOT IN ($seenPostIDs) 62 | AND I.published > $mindate 63 | ORDER BY random() 64 | LIMIT 1 65 | "; 66 | $feedId = $this->db->queryValue($sql); 67 | 68 | $sql = " 69 | SELECT * 70 | FROM items I, feeds F 71 | WHERE I.feedid = F.feedid 72 | AND I.itemid NOT IN ($seenPostIDs) 73 | AND I.published > $mindate 74 | AND F.feedid = :feedid 75 | ORDER BY random() 76 | LIMIT 1 77 | "; 78 | 79 | $result = $this->db->queryRecord($sql, [':feedid' => $feedId]); 80 | // if we did not get results, try again without excluding posts 81 | if (empty($result)) return $this->getRandom([]); 82 | 83 | return $result; 84 | } 85 | 86 | /** 87 | * Get random entries, that is not part of the given exclude list 88 | * 89 | * @param int[] $seenPostIDs 90 | * @param int $limit how many posts 91 | * @return string[][] 92 | */ 93 | public function getRandoms($seenPostIDs = [], $limit = 1) 94 | { 95 | $posts = []; 96 | for ($i = 0; $i < $limit; $i++) { 97 | $post = $this->getRandom($seenPostIDs); 98 | $seenPostIDs[] = $post['itemid']; 99 | $posts[] = $post; 100 | } 101 | 102 | return $posts; 103 | } 104 | 105 | /** 106 | * getInfo about the last seen pages, based on passed IDs 107 | * 108 | * @param int[] $seenPostIDs 109 | * @return array 110 | */ 111 | public function getLastSeen($seenPostIDs) 112 | { 113 | $seenPostIDs = array_map('intval', $seenPostIDs); 114 | $seen = join(',', $seenPostIDs); 115 | 116 | $sql = " 117 | SELECT * 118 | FROM items I, feeds F 119 | WHERE I.feedid = F.feedid 120 | AND I.itemid IN ($seen) 121 | "; 122 | 123 | $result = $this->db->queryAll($sql); 124 | $result = array_column($result, null, 'itemid'); 125 | 126 | // sort by the given order 127 | $data = []; 128 | foreach ($seenPostIDs as $id) { 129 | if (isset($result[$id])) $data[] = $result[$id]; 130 | } 131 | 132 | return $data; 133 | } 134 | 135 | /** 136 | * Get info about the database 137 | * 138 | * @return array 139 | */ 140 | public function getStats() 141 | { 142 | $stats = []; 143 | 144 | $sql = "SELECT COUNT(*) FROM feeds WHERE errors = 0"; 145 | $stats['feeds'] = $this->db->queryValue($sql); 146 | 147 | $sql = "SELECT COUNT(*) FROM feeds WHERE errors = 0 AND mastodon != ''"; 148 | $stats['mastodon'] = $this->db->queryValue($sql); 149 | 150 | 151 | $sql = "SELECT COUNT(*) FROM items A, feeds B 152 | WHERE A.feedid = B.feedid AND B.errors = 0"; 153 | $stats['items'] = $this->db->queryValue($sql); 154 | 155 | $mindate = time() - self::MAXAGE; 156 | $sql = "SELECT COUNT(*) FROM items A, feeds B 157 | WHERE A.feedid = B.feedid AND B.errors = 0 AND A.published > $mindate"; 158 | $stats['recentitems'] = $this->db->queryValue($sql); 159 | 160 | $sql = 'SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()'; 161 | $stats['size'] = $this->db->queryValue($sql); 162 | 163 | $sql = 'SELECT COUNT(*) FROM suggestions'; 164 | $stats['suggestions'] = $this->db->queryValue($sql); 165 | 166 | $sql = "SELECT COUNT(*) as cnt, 167 | STRFTIME('%Y-%W', published, 'unixepoch') as week, 168 | STRFTIME('%W', published, 'unixepoch') as w 169 | FROM items 170 | WHERE published > $mindate 171 | AND DATE(published, 'unixepoch') < DATE('now') 172 | GROUP BY week 173 | ORDER BY week"; 174 | $stats['weeklyposts'] = array_column($this->db->queryAll($sql), 'cnt', 'w'); 175 | 176 | return $stats; 177 | } 178 | 179 | /** 180 | * Get a single feed record 181 | * 182 | * @param string $feedid 183 | * @return array|false 184 | */ 185 | public function getFeed($feedid) 186 | { 187 | $sql = "SELECT * FROM feeds WHERE feedid = ?"; 188 | $result = $this->db->queryAll($sql, [$feedid]); 189 | if ($result) $result = $result[0]; 190 | return $result; 191 | } 192 | 193 | /** 194 | * @param $itemid 195 | * @return array|false 196 | */ 197 | public function getItem($itemid) 198 | { 199 | $sql = "SELECT * FROM items I, feeds F WHERE I.feedid = F.feedid AND I.itemid = ?"; 200 | $result = $this->db->queryAll($sql, [$itemid]); 201 | if ($result) $result = $result[0]; 202 | return $result; 203 | } 204 | 205 | /** 206 | * Get the newest items for a feed 207 | * 208 | * @param string $feedid 209 | * @param int $max 210 | * @return array|false 211 | */ 212 | public function getFeedItems($feedid, $max = 10) 213 | { 214 | $sql = "SELECT * FROM items WHERE feedid = ? ORDER BY published DESC LIMIT ?"; 215 | $result = $this->db->queryAll($sql, [$feedid, $max]); 216 | return $result; 217 | } 218 | 219 | /** 220 | * Suggest a new feed 221 | * 222 | * @param string $url 223 | * @return array 224 | * @throws Exception 225 | */ 226 | public function suggestFeed($url) 227 | { 228 | $feed = $this->getFeedData($url); 229 | $fid = $feed['feedid']; 230 | 231 | $sql = "SELECT * FROM feeds WHERE feedid = ?"; 232 | $result = $this->db->queryRecord($sql, [$fid]); 233 | if ($result) { 234 | throw new Exception("This feed already exists in the database"); 235 | } 236 | 237 | $sql = "SELECT * FROM suggestions WHERE feedid = ?"; 238 | $result = $this->db->queryRecord($sql, [$fid]); 239 | if ($result) { 240 | throw new Exception("This feed has already been suggested"); 241 | } 242 | 243 | $this->db->saveRecord('suggestions', $feed); 244 | return $feed; 245 | } 246 | 247 | /** 248 | * Get all the suggestions 249 | * 250 | * @return array 251 | */ 252 | public function getSuggestions() 253 | { 254 | $sql = "SELECT * FROM suggestions ORDER BY added"; 255 | return $this->db->queryAll($sql); 256 | } 257 | 258 | /** 259 | * Delete a feed from the suggestions 260 | * 261 | * @param string $feedid 262 | * @return void 263 | */ 264 | public function removeSuggestion($feedid) 265 | { 266 | $sql = "DELETE FROM suggestions WHERE feedid = ?"; 267 | $this->db->exec($sql, [$feedid]); 268 | } 269 | 270 | /** 271 | * Try to parse the gien feed or homepage and return the feed details 272 | * 273 | * @param string $url 274 | * @return array 275 | * @throws Exception 276 | */ 277 | protected function getFeedData($url, $mastodon = false) 278 | { 279 | $simplePie = new SimplePie(); 280 | $simplePie->enable_cache(false); 281 | $simplePie->set_feed_url($url); 282 | 283 | try { 284 | $ok = $simplePie->init(); 285 | } catch (\Throwable $e) { 286 | throw new Exception("Sorry I couldn't find a supported feed at that URL. " . $e->getMessage()); 287 | } 288 | 289 | if (!$ok) { 290 | throw new Exception($simplePie->error()); 291 | } 292 | 293 | $url = $simplePie->feed_url; 294 | $fid = $this->feedID($url); 295 | 296 | $homepage = trim($simplePie->get_permalink()); 297 | if (!$homepage) { 298 | $hp = new Url($url); 299 | $hp = $hp->withPath($hp->getDirname()); 300 | $homepage = $hp->__toString(); 301 | } 302 | 303 | $title = trim($simplePie->get_title()); 304 | if (!$title) { 305 | $title = parse_url($homepage, PHP_URL_HOST); 306 | } 307 | 308 | $feed = [ 309 | 'feedid' => $fid, 310 | 'feedurl' => $url, 311 | 'homepage' => $homepage, 312 | 'feedtitle' => $title, 313 | 'added' => time(), 314 | ]; 315 | 316 | if ($mastodon) { 317 | $feed['mastodon'] = (new Mastodon())->getProfile($homepage); 318 | } 319 | 320 | return $feed; 321 | } 322 | 323 | /** 324 | * Adds a new feed 325 | * 326 | * @param string $url Either the feed itself or a website (using autodiscovery) 327 | * @return array The feed information 328 | * @throws Exception|PDOException 329 | */ 330 | public function addFeed($url) 331 | { 332 | $feed = $this->getFeedData($url, true); 333 | $fid = $feed['feedid']; 334 | 335 | $sql = "SELECT * FROM feeds WHERE feedid = ?"; 336 | $result = $this->db->queryRecord($sql, [$fid]); 337 | if ($result) { 338 | throw new Exception("[$fid] Feed already exists"); 339 | } 340 | 341 | $this->db->saveRecord('feeds', $feed); 342 | return $feed; 343 | } 344 | 345 | /** 346 | * Get all feeds that should be updated again 347 | * 348 | * @return array 349 | */ 350 | public function getAllUpdatableFeeds() 351 | { 352 | $day = 60 * 60 * 24; 353 | $limit = time() - $day; 354 | // each error pushes the next retry back by a day 355 | $query = "SELECT * FROM feeds WHERE fetched < ($limit - ( errors * $day )) ORDER BY random()"; 356 | return $this->db->queryAll($query); 357 | } 358 | 359 | /** 360 | * Get all feeds 361 | * 362 | * @return array 363 | */ 364 | public function getAllFeeds() 365 | { 366 | $query = "SELECT * FROM feeds WHERE errors = 0 ORDER BY feedurl"; 367 | return $this->db->queryAll($query); 368 | } 369 | 370 | /** 371 | * Get all feeds with their most recent post 372 | * 373 | * Important! You need to close the cursor when done with the statement 374 | * 375 | * @param bool $witherrors Include feeds that have errors? 376 | * @return \PDOStatement 377 | */ 378 | public function getAllFeedsWithDetails($witherrors = false) 379 | { 380 | $errorlimit = $witherrors ? 100 : 0; 381 | 382 | $query = "SELECT A.*, B.itemid, B.itemurl, B.itemtitle, B.published 383 | FROM feeds A 384 | LEFT JOIN (SELECT feedid, 385 | itemid, 386 | itemurl, 387 | itemtitle, 388 | MAX(published) AS published 389 | FROM items 390 | GROUP BY feedid 391 | ) B 392 | ON A.feedid = B.feedid 393 | WHERE errors <= $errorlimit 394 | ORDER BY A.feedurl"; 395 | 396 | $stmt = $this->db->pdo()->prepare($query); 397 | $stmt->execute(); 398 | return $stmt; 399 | } 400 | 401 | /** 402 | * Fetch items of the given Feed Record 403 | * 404 | * @throws Exception|PDOException 405 | */ 406 | public function fetchFeedItems($feed) 407 | { 408 | $simplePie = new SimplePie(); 409 | $simplePie->enable_cache(false); 410 | $simplePie->set_feed_url($feed['feedurl']); 411 | $simplePie->force_feed(true); // no autodetect here 412 | 413 | $domain = parse_url($feed['feedurl'], PHP_URL_HOST); 414 | 415 | try { 416 | if (!$simplePie->init()) { 417 | throw new Exception($simplePie->error()); 418 | } 419 | 420 | $items = $simplePie->get_items(); 421 | if (!$items) throw new Exception('no items found'); 422 | 423 | $this->db->pdo()->beginTransaction(); 424 | foreach ($items as $item) { 425 | $itemUrl = $item->get_permalink(); 426 | if (!$itemUrl) continue; 427 | 428 | // only keep items from the same domain, ignore external links 429 | if (parse_url($itemUrl, PHP_URL_HOST) != $domain) continue; 430 | 431 | $itemTitle = $item->get_title(); 432 | if (!$itemTitle) continue; 433 | $itemDate = $item->get_gmdate('U'); 434 | if (!$itemDate) $itemDate = time(); 435 | if ($itemDate > time()) $itemDate = time(); 436 | 437 | $record = [ 438 | 'feedid' => $feed['feedid'], 439 | 'itemurl' => $itemUrl, 440 | 'itemtitle' => $itemTitle, 441 | 'published' => $itemDate, 442 | ]; 443 | 444 | $this->db->saveRecord('items', $record, false); 445 | } 446 | $this->db->pdo()->commit(); 447 | 448 | // reset any errors 449 | $feed['errors'] = 0; 450 | $feed['lasterror'] = ''; 451 | $feed['fetched'] = time(); 452 | $this->db->saveRecord('feeds', $feed); 453 | } catch (Exception $e) { 454 | if ($this->db->pdo()->inTransaction()) { 455 | $this->db->pdo()->rollBack(); 456 | } 457 | 458 | // save the error 459 | $feed['errors']++; 460 | $feed['lasterror'] = $e->getMessage(); 461 | $feed['fetched'] = time(); 462 | $this->db->saveRecord('feeds', $feed); 463 | 464 | throw $e; 465 | } 466 | 467 | return count($items); 468 | } 469 | 470 | /** 471 | * Delete a feed and all its items 472 | * 473 | * @param $feedID 474 | * @return void 475 | * @throws Exception 476 | */ 477 | public function deleteFeed($feedID) 478 | { 479 | $feed = $this->getFeed($feedID); 480 | if (!$feed) throw new Exception('Feed does not exist'); 481 | 482 | $this->db->pdo()->exec('PRAGMA foreign_keys = ON'); 483 | $sql = "DELETE FROM feeds WHERE feedid = ?"; 484 | $this->db->queryAll($sql, [$feedID]); 485 | $this->db->pdo()->exec('PRAGMA foreign_keys = OFF'); 486 | } 487 | 488 | /** 489 | * Reset the error counter for the given feed 490 | * 491 | * @param $feedID 492 | * @return void 493 | * @throws Exception 494 | */ 495 | public function resetFeedErrors($feedID) 496 | { 497 | $feed = $this->getFeed($feedID); 498 | if (!$feed) throw new Exception('Feed does not exist'); 499 | 500 | $feed['errors'] = 1; 501 | $feed['lasterror'] = 'to be retried'; 502 | $this->db->saveRecord('feeds', $feed); 503 | } 504 | 505 | /** 506 | * Create a ID for the given feed 507 | * 508 | * @param string $url 509 | * @return string 510 | */ 511 | protected function feedID($url) 512 | { 513 | $url = trim($url); 514 | $url = preg_replace('!^https?://!', '', $url); 515 | $url = strtolower($url); 516 | return md5($url); 517 | } 518 | 519 | /** 520 | * Add a RSS feed source for automatic suggestions 521 | * 522 | * @param string $url 523 | * @return array 524 | * @throws Exception 525 | */ 526 | public function addSource($url) 527 | { 528 | $simplePie = new SimplePie(); 529 | $simplePie->enable_cache(false); 530 | $simplePie->set_feed_url($url); 531 | if (!$simplePie->init()) { 532 | $error = $simplePie->error(); 533 | if ($simplePie->status_code() == 200 && str_contains($error, 'text/plain')) { 534 | $type = 'list'; 535 | } elseif ($simplePie->status_code() == 200 && str_starts_with($simplePie->get_raw_data(), 'feed_url; 542 | $type = 'feed'; 543 | } 544 | 545 | 546 | $sid = $this->feedID($url); 547 | $source = [ 548 | 'sourceid' => $sid, 549 | 'sourceurl' => $url, 550 | 'added' => time(), 551 | 'type' => $type, 552 | ]; 553 | 554 | $sql = "SELECT * FROM sources WHERE sourceid = ?"; 555 | $result = $this->db->queryRecord($sql, [$sid]); 556 | if ($result) { 557 | throw new Exception("[$sid] Source already exists"); 558 | } 559 | 560 | $this->db->saveRecord('sources', $source); 561 | return $source; 562 | } 563 | 564 | /** 565 | * Get all sources 566 | * 567 | * @return array 568 | */ 569 | public function getSources() 570 | { 571 | $sql = "SELECT * FROM sources ORDER BY added DESC"; 572 | return $this->db->queryAll($sql); 573 | } 574 | 575 | /** 576 | * Fetch a single list source and add new suggestions 577 | * 578 | * @param array $source A source record 579 | * @return int number of added suggestions 580 | * @throws Exception 581 | */ 582 | public function fetchSourceList($source) 583 | { 584 | $lines = file_get_contents($source['sourceurl']); 585 | if (!$lines) throw new Exception('Could not fetch source list'); 586 | 587 | $lines = explode("\n", $lines); 588 | $new = 0; 589 | foreach ($lines as $itemUrl) { 590 | $itemUrl = trim($itemUrl); 591 | if (empty($itemUrl)) continue; 592 | 593 | try { 594 | // check if we've seen this item already 595 | $this->rememberSourceSuggestion($itemUrl); 596 | // add the suggestion 597 | $this->suggestFeed($itemUrl); 598 | $new++; 599 | echo '✓'; 600 | } catch (Exception $e) { 601 | // ignore 602 | echo '✗'; 603 | } 604 | } 605 | return $new; 606 | } 607 | 608 | /** 609 | * Fetch a single OPML source and add new suggestions 610 | * 611 | * @param array $source A source record 612 | * @return int number of added suggestions 613 | * @throws Exception 614 | */ 615 | public function fetchSourceOpml($source) 616 | { 617 | $lines = file_get_contents($source['sourceurl']); 618 | if (!$lines) throw new Exception('Could not fetch source list'); 619 | 620 | $xml = simplexml_load_string($lines); 621 | if (!$xml) throw new Exception('Could not parse OPML'); 622 | 623 | $new = 0; 624 | foreach ($xml->body->outline as $item) { 625 | $itemUrl = (string)$item['xmlUrl']; 626 | if (empty($itemUrl)) continue; 627 | 628 | try { 629 | // check if we've seen this item already 630 | $this->rememberSourceSuggestion($itemUrl); 631 | // add the suggestion 632 | $this->suggestFeed($itemUrl); 633 | $new++; 634 | echo '✓'; 635 | } catch (Exception $e) { 636 | // ignore 637 | echo '✗'; 638 | } 639 | } 640 | return $new; 641 | } 642 | 643 | /** 644 | * Fetch a single source and add new suggestions 645 | * 646 | * @param array $source A source record 647 | * @return int number of added suggestions 648 | * @throws Exception 649 | */ 650 | public function fetchSource($source) 651 | { 652 | if ($source['type'] == 'feed') { 653 | return $this->fetchSourceFeed($source); 654 | } elseif ($source['type'] == 'list') { 655 | return $this->fetchSourceList($source); 656 | } elseif ($source['type'] == 'opml') { 657 | return $this->fetchSourceOpml($source); 658 | } else { 659 | throw new Exception('Unknown source type'); 660 | } 661 | } 662 | 663 | /** 664 | * Fetch a single feed source and add new suggestions 665 | * 666 | * @param array $source A source record 667 | * @return int number of added suggestions 668 | * @throws Exception 669 | */ 670 | public function fetchSourceFeed($source) 671 | { 672 | $simplePie = new SimplePie(); 673 | $simplePie->enable_cache(false); 674 | $simplePie->set_feed_url($source['sourceurl']); 675 | $simplePie->force_feed(true); // no autodetect here 676 | 677 | if (!$simplePie->init()) { 678 | throw new Exception($simplePie->error()); 679 | } 680 | 681 | $items = $simplePie->get_items(); 682 | if (!$items) throw new Exception('no items found'); 683 | 684 | $new = 0; 685 | foreach ($items as $item) { 686 | $itemUrl = $item->get_permalink(); 687 | try { 688 | // check if we've seen this item already 689 | $this->rememberSourceSuggestion($itemUrl); 690 | // add the suggestion 691 | $this->suggestFeed($itemUrl); 692 | $new++; 693 | echo '✓'; 694 | } catch (Exception $e) { 695 | // ignore 696 | echo '✗'; 697 | } 698 | } 699 | return $new; 700 | } 701 | 702 | /** 703 | * Remember that this URL has been suggested in the past 704 | * 705 | * @param string $itemUrl 706 | * @throws Exception if the URL has been suggested before 707 | */ 708 | protected function rememberSourceSuggestion($itemUrl) 709 | { 710 | $hash = $this->feedID($itemUrl); 711 | 712 | $sql = "SELECT seen FROM seen WHERE seen = ?"; 713 | $seen = $this->db->queryValue($sql, [$hash]); 714 | if ($seen) throw new Exception('Already suggested'); 715 | 716 | // remember the item to not suggest it again 717 | // we also remember it when it fails to add in the next step to not retry fails 718 | $sql = "INSERT INTO seen (seen) VALUES (?)"; 719 | $this->db->queryValue($sql, [$hash]); 720 | } 721 | } 722 | -------------------------------------------------------------------------------- /src/Mastodon.php: -------------------------------------------------------------------------------- 1 | httpget($homepage); 22 | 23 | // simplify homepage url 24 | $homepage = new Url($homepage); 25 | $homepage = $homepage->getHost() . rtrim($homepage->getPath(), '/'); 26 | 27 | $dom = new Document(); 28 | $dom->html($html); 29 | $links = $dom->find('a[rel=me]'); 30 | 31 | foreach ($links as $link) { 32 | $href = $link->attr('href') . '.json'; 33 | $json = $this->httpget($href); 34 | $data = json_decode($json, true); 35 | 36 | if ($data && isset($data['attachment'])) foreach ($data['attachment'] as $attachment) { 37 | if ($attachment['type'] === 'PropertyValue' && (stripos($attachment['value'], $homepage) !== false)) { 38 | $server = new Url($data['url']); 39 | return trim($server->getPath(),'/') . '@' . $server->getHost(); 40 | } 41 | } 42 | } 43 | 44 | return ''; 45 | } 46 | 47 | /** 48 | * Simple HTTP client 49 | * 50 | * @param string $url 51 | * @param array $headers 52 | * @return string 53 | */ 54 | public function httpget($url, $headers = []) 55 | { 56 | $ch = curl_init(); 57 | curl_setopt($ch, CURLOPT_URL, $url); 58 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); 59 | curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 60 | curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10); 61 | curl_setopt($ch, CURLOPT_TIMEOUT, 25); 62 | $output = curl_exec($ch); 63 | curl_close($ch); 64 | return $output; 65 | } 66 | 67 | /** 68 | * Post to Mastodon 69 | * 70 | * @param string $status Status to post 71 | * @param string $instance Mastodon instance 72 | * @param string $token Mastodon Access Token 73 | * @return mixed 74 | */ 75 | public function postStatus($status, $instance, $token) 76 | { 77 | $headers = [ 78 | "Authorization: Bearer $token" 79 | ]; 80 | 81 | $status_data = [ 82 | "status" => $status, 83 | "language" => "en", 84 | "visibility" => "public" 85 | ]; 86 | 87 | $ch = curl_init(); 88 | curl_setopt($ch, CURLOPT_URL, "$instance/api/v1/statuses"); 89 | curl_setopt($ch, CURLOPT_POST, 1); 90 | curl_setopt($ch, CURLOPT_POSTFIELDS, $status_data); 91 | curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); 92 | curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); 93 | $output = curl_exec($ch); 94 | curl_close ($ch); 95 | 96 | return json_decode($output, true); 97 | } 98 | 99 | /** 100 | * Post a single item to Mastodon 101 | * 102 | * @param string[] $post 103 | * @param string $instance 104 | * @param string $token 105 | * @return mixed 106 | */ 107 | public function postItem($post, $instance, $token) 108 | { 109 | 110 | $text = $post['itemtitle']; 111 | $text .= ' (' . date('Y-m-d', $post['published']) . ')'; 112 | if ($post['mastodon']) { 113 | $text .= ' by ' . $post['mastodon']; 114 | } 115 | $text .= "\n\n" . Controller::campaignURL($post['itemurl'], 'mastodon'); 116 | 117 | $text .= "\n\n🎲 " . $post['feedid'] . '-' . $post['itemid']; 118 | $text .= "\n#blog #blogging #blogpost #random"; 119 | 120 | return $this->postStatus($text, $instance, $token); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/RSS.php: -------------------------------------------------------------------------------- 1 | [1, 3, 5, 10, 20, 25], 25 | 7 => [5, 10, 15, 25], 26 | ]; 27 | 28 | 29 | /** 30 | * Constructor 31 | */ 32 | public function __construct() 33 | { 34 | $loader = new FilesystemLoader(__DIR__ . '/../templates'); 35 | $this->twig = new Environment($loader); 36 | $this->feedManager = new FeedManager(); 37 | } 38 | 39 | /** 40 | * Force a refresh of the feed 41 | */ 42 | public function forceRefresh() 43 | { 44 | $this->force = true; 45 | } 46 | 47 | /** 48 | * Returns the wanted feed content 49 | * 50 | * @param int $freq wanted frequency in days 51 | * @param int $num wanted number of posts 52 | * @return false|string 53 | */ 54 | public function getFeed($freq = 1, $num = 5) 55 | { 56 | // ensure only valid values are used 57 | if ($freq < 1) $freq = 1; 58 | if ($num < 1) $num = 1; 59 | if (!isset($this->feeds[$freq])) $freq = 1; 60 | while (!in_array($num, $this->feeds[$freq])) { 61 | $num--; 62 | } 63 | 64 | $cache = $this->getCacheName($freq, $num); 65 | if (!file_exists($cache)) { 66 | // this should not happen, but if it does, create the feed 67 | $this->createFeed($freq, $num); 68 | } 69 | return file_get_contents($cache); 70 | } 71 | 72 | /** 73 | * Where a feed file is cached 74 | * 75 | * @param int $freq wanted frequency in days 76 | * @param int $num wanted number of posts 77 | * @return string 78 | */ 79 | protected function getCacheName($freq, $num) 80 | { 81 | return __DIR__ . '/../data/rss/' . $freq . '.' . $num . '.xml'; 82 | } 83 | 84 | /** 85 | * Create a feed file 86 | * 87 | * @param int $freq wanted frequency in days 88 | * @param int $num wanted number of posts 89 | * @return int|string Name of the created file or time in seconds until the next update 90 | */ 91 | public function createFeed($freq = 1, $num = 5) 92 | { 93 | $cache = $this->getCacheName($freq, $num); 94 | 95 | $now = time(); 96 | $valid = @filemtime($cache) - ($now - $freq * 60 * 60 * 24); 97 | 98 | 99 | if ($valid < 0 || $this->force) { 100 | $creator = new \UniversalFeedCreator(); 101 | $creator->title = 'indieblog.page daily random posts'; 102 | $creator->description = 'Discover the IndieWeb, one blog post at a time.'; 103 | $creator->link = 'https://indieblog.page'; 104 | 105 | $result = $this->feedManager->getRandoms([], $num + 10); // get more than we need, to compensate for errors 106 | $added = 0; 107 | foreach ($result as $data) { 108 | try { 109 | $data = $this->fetchAdditionalData($data); 110 | } catch (\Exception $e) { 111 | continue; 112 | } 113 | $data['itemurl'] = Controller::campaignURL($data['itemurl'], 'rss'); 114 | $data['feedurl'] = Controller::campaignURL($data['feedurl'], 'rss'); 115 | 116 | $item = new \FeedItem(); 117 | $item->title = '🎲 ' . $data['itemtitle']; 118 | $item->link = $data['itemurl']; 119 | $item->date = $now--; // separate each post by a second, first one being the newest 120 | $item->source = $data['feedurl']; 121 | $item->author = $data['feedtitle']; 122 | $item->description = $this->twig->render('partials/rssitem.twig', ['item' => $data]); 123 | $creator->addItem($item); 124 | 125 | if (++$added >= $num) break; 126 | } 127 | $creator->saveFeed('RSS2.0', $cache, false); 128 | return $cache; 129 | } 130 | return $valid; 131 | } 132 | 133 | /** 134 | * Create all feeds 135 | * 136 | * @param LoggerInterface $logger 137 | */ 138 | public function createAllFeeds(LoggerInterface $logger){ 139 | foreach ($this->feeds as $freq => $nums){ 140 | foreach ($nums as $num){ 141 | $logger->info('Creating feed for '.$freq.' days and '.$num.' posts'); 142 | $ret = $this->createFeed($freq, $num); 143 | if(is_int($ret)){ 144 | $logger->info('Feed still valid for ' . $ret . ' seconds'); 145 | } else { 146 | $logger->success('Feed created: {feed}', ['feed' => $ret]); 147 | } 148 | } 149 | } 150 | } 151 | 152 | /** 153 | * Enhance the given item with data fetched from the web 154 | * 155 | * @param string[] $item 156 | * @return string[] 157 | * @throws \Exception 158 | */ 159 | protected function fetchAdditionalData($item) 160 | { 161 | $goose = new GooseClient(); 162 | $article = $goose->extractContent($item['itemurl']); 163 | 164 | $text = $article->getCleanedArticleText(); 165 | $desc = $article->getMetaDescription(); 166 | if (strlen($text) > strlen($desc)) { 167 | $summary = $text; 168 | } else { 169 | $summary = $desc; 170 | } 171 | if (mb_strlen($summary) > 500) { 172 | $summary = mb_substr($summary, 0, 500) . '…'; 173 | } 174 | $item['summary'] = $summary; 175 | 176 | return $item; 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /templates/401.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | 3 | {% block content %} 4 |

401 - Unauthorized

5 | 6 |

Sorry, you need to be logged in.

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /templates/404.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | 3 | {% block content %} 4 |

404 - Not Found

5 | 6 |

Sorry, whatever you were looking for isn't here.

7 | {% endblock %} 8 | -------------------------------------------------------------------------------- /templates/admin.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | 3 | {% block content %} 4 |

Admin

5 | 6 |
7 |

8 | URL: 9 | 10 |

11 |
12 | 13 | {% include 'partials/feedback.twig' %} 14 | 15 | 16 | {% for feed in suggestions %} 17 | 18 | 21 | 25 | 26 | {% endfor %} 27 |
19 | {{ feed.feedtitle }} 20 | 22 | 23 | 24 |
28 | 29 |
30 |

31 | Remove by ID: 32 | 33 |

34 |
35 | 36 | {% endblock %} 37 | -------------------------------------------------------------------------------- /templates/all.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | 3 | {% block metahead %} 4 | 5 | 6 | 42 | {% endblock %} 43 | 44 | {% block content %} 45 |

List of all Blogs

46 | 47 |

48 | Here's a list of all available blogs with their most recent post. You can sort by clicking on the column 49 | headers. You can also filter by typing in the input fields. 50 |

51 | 52 |
53 |
Sorry, this feature needs JavaScript
54 |
55 | 56 | 203 | 204 | 205 |

All the above data is also available as JSON export.

206 | {% endblock %} 207 | -------------------------------------------------------------------------------- /templates/faq.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | 3 | {% block content %} 4 |

Frequently asked Questions

5 | 6 |

7 | These questions might have been asked frequently if I hadn't answered them here ;-) 8 |

9 | 10 |
11 | Why does this site exist? 12 | 13 |

Because I wanted it.

14 | 15 |

16 | There is a small renaissance of having your own, personal website, independent of the 17 | large corporate entities. A place for your thoughts and ideas that you own and control. It's sometimes 18 | called the IndieWeb or 19 | SmolNet - back in my day it was just having a 20 | homepage. 21 |

22 | 23 |

24 | I love reading text written by real people. Texts that don't want to sell something. 25 | But how can you discover texts you can't search for because you don't know they exist? 26 |

27 | 28 |

29 | That's where this page comes in. Click a button, be surprised and maybe discover your new favorite thing. 30 |

31 |
32 | 33 |
34 | What are the sources? 35 | 36 |

I initially seeded the database with personal websites from the following sources:

37 | 38 | 48 | 49 |

50 | To further grow it, you can suggest your own or a friend's personal site (as long as it has an RSS feed): 51 | Suggest a page. 52 |

53 |
54 | 55 |
56 | How many blogs and posts are in the database? 57 | 58 |

Here are the current statistics:

59 | 60 | 68 | 69 |

70 | Currently only recent posts (published within the last six months) are used when 71 | picking a random post. Below is a visualization of the number of recent posts per week. 72 |

73 | 74 |
75 | {% set maxposts = max(stats.weeklyposts) %} 76 | {% for week, posts in stats.weeklyposts %} 77 |
78 | W{{ week }}: {{ posts }} posts 79 |
80 | {% endfor %} 81 |
82 |
83 | 84 |
85 | Can I have the data? 86 | 87 |

Sure, you can download the list of blog URLs as JSON here:

88 | 89 |

Download JSON

90 |
91 | 92 |
93 | Broken Links, Spam, etc. 94 | 95 |

People abandon or sell their domains. Things break. Sites get hacked.

96 | 97 |

98 | If you were sent to a broken site, please let me know at 99 | andi@splitbrain.org. Be sure to include the ID shown under each 100 | visited link on the front page - it helps me to identify the broken URLs. 101 |

102 | 103 |

104 | Please also let me know if you come across things that don't fit the spirit of personal webpages. 105 | Things like YouTube channels, corporate blogs, etc. should not be in the index but might have slipped 106 | through in the initial setup. 107 |

108 |
109 | 110 |
111 | Are there any alternatives? 112 | 113 |

There are other attempts at making the indieweb discoverable.

114 | 115 | 133 |
134 | 135 |
136 | What tech does this run on? 137 | 138 |

This is a very simple, custom PHP application standing on the shoulders of giants:

139 | 150 |

151 | The rest is just glue code. You can see it all on 152 | Github. 153 |

154 |
155 | 156 | {% endblock %} 157 | -------------------------------------------------------------------------------- /templates/index.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | 3 | {% block content %} 4 |

👋 Hi there!

5 | 6 |

7 | This website lets you randomly explore the IndieWeb. Simply click the button below and you 8 | will be redirected to a random post from a personal blog. 9 |

10 | 11 |

12 | 13 | 14 | 15 |

16 | 17 |
18 | Disclaimer: the content linked to is aggregated automatically. I neither endorse nor necessarily agree with 19 | the linked sites. Use at your own risk. 20 |
21 | 22 |

23 | Hint: you can drag the button to your bookmarks and have it always available when you want to be 24 | inspired. 25 |

26 | 27 |
28 | {% include 'partials/seen.twig' %} 29 |
30 | 31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /templates/inspect.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | 3 | {% block content %} 4 |

Inspect

5 | 6 | 7 | {% if feed %} 8 | 17 | 18 | 23 | 24 | 25 |

26 | Remove 27 | Reset Errors 28 |

29 | {% else %} 30 |

Feed not found

31 | {% endif %} 32 | 33 | 34 | {% endblock %} 35 | -------------------------------------------------------------------------------- /templates/partials/feedback.twig: -------------------------------------------------------------------------------- 1 | {% if error %} 2 |
3 | {{ error }} 4 |
5 | {% endif %} 6 | 7 | {% if feed %} 8 |
9 |

Your feed has been added to the list of suggestions and will be reviewed soon.

10 | 11 | {{ feed.feedtitle }} 12 | 13 | 14 | 16 | 17 | 18 |
19 | {% endif %} 20 | -------------------------------------------------------------------------------- /templates/partials/layout.twig: -------------------------------------------------------------------------------- 1 | {% set title = 'Discover the IndieWeb, one blog post at a time.' %} 2 | {% set description = 'A website to randomly explore the IndieWeb. Simply click a button and you will be redirected to a random post from a personal blog.' %} 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | {{ title }} 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% block metahead %} 23 | {% endblock %} 24 | 25 | 26 |
27 |

indieblog.page

28 |

{{ title }}

29 | 30 | {% include 'partials/navigation.twig' %} 31 |
32 | 33 |
34 | {% block content %} 35 | {% endblock %} 36 |
37 | 38 | 49 | 50 | 51 | 52 | 53 | 54 | -------------------------------------------------------------------------------- /templates/partials/navigation.twig: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /templates/partials/rssitem.twig: -------------------------------------------------------------------------------- 1 |
2 | {{ item.summary|nl2br }} 3 |
4 | 5 |

6 | This random indieblog.page link was picked on {{ "now"|date("l, F jS Y") }}. It was originally published on {{ item.published|date("l, F jS Y") }} at {{ item.feedtitle }}. 7 |

8 |

9 | If you'd like to report any problems with this post or the blog, please include the following ID with your report: {{ item.feedid }}-{{ item.itemid }} 10 |

11 | -------------------------------------------------------------------------------- /templates/partials/seen.twig: -------------------------------------------------------------------------------- 1 | {% if seen %} 2 | 3 |

Your recent visits

4 | 5 |

These are the posts you recently saw - just in case you want to revisit them or subscribe.

6 | 7 | 28 | 29 | {% endif %} 30 | -------------------------------------------------------------------------------- /templates/rss.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | 3 | {% block content %} 4 |

RSS Feeds

5 | 6 |

7 | Use these feeds to get your daily or weekly dose of random blog posts, straight to your RSS reader. 8 |

9 | 10 | 16 | 17 | 23 | 24 |

25 | If you want to stay up-to-date with changes made to indieblog.page itself, you can subscribe to 26 | the Github commits. 27 |

28 | 29 | 32 | 33 |

Mastodon

34 | 35 |

36 | You can follow the indieblog.page Mastodon account for 37 | a random post twice a day. Occasional updates about the service itself will be posted as well. 38 |

39 | 40 | {% endblock %} 41 | -------------------------------------------------------------------------------- /templates/suggest.twig: -------------------------------------------------------------------------------- 1 | {% extends 'partials/layout.twig' %} 2 | 3 | {% block content %} 4 |

Suggest a blog

5 | 6 |

7 | Use this form to add a new source. Enter the URL to an RSS/ATOM feed or a homepage link. 8 |

9 | 10 | 11 |
12 | 13 | {% include 'partials/feedback.twig' %} 14 | 15 |

16 |
17 |
18 | 19 | 20 |

21 |
22 | 23 | 24 |
25 | What sites can be submitted? 26 | 27 |

28 | I haven't decided on any firm rules. If it's a personal site with an RSS feed, it is probably 29 | welcome. 30 |

31 | 32 |

No illegal stuff, no corporate blogs, no nazis.

33 |
34 | 35 |
36 | I tried to submit my site, but got an error!? 37 | 38 |

39 | Make sure your site has a valid RSS or ATOM feed. JSON Feed is not supported. 40 | If you're not sure, you can check with a service like 41 | the W3C Feed Validation Service. Pay especially close 42 | attention that your feed is served with the correct MIME type. 43 |

44 | 45 |

46 | When submitting your site, the feed parser will try to autodetect the feed URL. If it fails, you can 47 | always submit the feed URL manually. 48 |

49 |
50 | 51 |
52 | Using the Android Share Menu 53 | 54 |

55 | When you install indieblog.page as a Progressive Web App (PWA), you can find it in the share menu and 56 | easily suggest new blogs that way. If you missed the prompt earlier, you can still find the option to 57 | install in your browser's menu. 58 |

59 |
60 | 61 | {% endblock %} 62 | --------------------------------------------------------------------------------