├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ ├── devskim.yml │ ├── docker-image.yml │ ├── docker-publish.yml │ └── php.yml ├── .gitignore ├── CODE-OF-CONDUCT.md ├── Dockerfile ├── LICENSE ├── README.md ├── apache.conf ├── composer.json ├── composer.lock ├── conf ├── podsumer.conf ├── test.conf ├── test_bad.conf └── test_empty.conf ├── docker-compose.yml ├── phpunit.xml ├── screenshots ├── episode.png ├── feed.png └── feeds.png ├── sql └── tables.sql ├── src └── Brickner │ └── Podsumer │ ├── Config.php │ ├── FSState.php │ ├── Feed.php │ ├── File.php │ ├── Item.php │ ├── Logs.php │ ├── Main.php │ ├── OPML.php │ ├── Route.php │ ├── State.php │ ├── TStateSchemaMigrations.php │ └── Template.php ├── templates ├── base.html.php ├── feed.html.php ├── home.html.php ├── item.html.php ├── opml.xml.php ├── rss.xml.php └── tests │ ├── base.html.php │ ├── feed.xml.php │ └── test.html.php ├── tests └── Brickner │ └── Podsumer │ ├── ConfigTest.php │ ├── FSStateTest.php │ ├── FeedTest.php │ ├── MainTest.php │ ├── OPMLTest.php │ ├── StateTest.php │ └── TemplateTest.php └── www ├── ai.txt ├── index.php └── robots.txt /.dockerignore: -------------------------------------------------------------------------------- 1 | clover.xml 2 | CODE-OF-CONDUCT.md 3 | coverage.html 4 | coverage.txt 5 | Dockerfile 6 | .git 7 | .github 8 | .gitignore 9 | .phpunit* 10 | phpunit.xml 11 | README.md 12 | state 13 | tests 14 | vendor 15 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [joshwbrick] 4 | -------------------------------------------------------------------------------- /.github/workflows/devskim.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | 6 | name: DevSkim 7 | 8 | on: 9 | push: 10 | branches: [ "development" ] 11 | pull_request: 12 | branches: [ "development" ] 13 | schedule: 14 | - cron: '18 1 * * 4' 15 | 16 | jobs: 17 | lint: 18 | name: DevSkim 19 | runs-on: ubuntu-20.04 20 | permissions: 21 | actions: read 22 | contents: read 23 | security-events: write 24 | steps: 25 | - name: Checkout code 26 | uses: actions/checkout@v3 27 | 28 | - name: Run DevSkim scanner 29 | uses: microsoft/DevSkim-Action@v1 30 | 31 | - name: Upload DevSkim scan results to GitHub Security tab 32 | uses: github/codeql-action/upload-sarif@v2 33 | with: 34 | sarif_file: devskim-results.sarif 35 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: [ "development" ] 6 | pull_request: 7 | branches: [ "development" ] 8 | 9 | jobs: 10 | 11 | build: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | - name: Build the Docker image 18 | run: docker build . --file Dockerfile --tag podsumer:$(date +%s) 19 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker 2 | 3 | # This workflow uses actions that are not certified by GitHub. 4 | # They are provided by a third-party and are governed by 5 | # separate terms of service, privacy policy, and support 6 | # documentation. 7 | 8 | on: 9 | schedule: 10 | - cron: '33 7 * * *' 11 | workflow_dispatch: 12 | branches: [ "development" ] 13 | # Publish semver tags as releases. 14 | tags: [ 'v*.*.*' ] 15 | push: 16 | branches: [ "development" ] 17 | # Publish semver tags as releases. 18 | tags: [ 'v*.*.*' ] 19 | pull_request: 20 | branches: [ "development" ] 21 | 22 | env: 23 | # Use docker.io for Docker Hub if empty 24 | REGISTRY: ghcr.io 25 | # github.repository as / 26 | IMAGE_NAME: ${{ github.repository }} 27 | 28 | 29 | jobs: 30 | build: 31 | 32 | runs-on: ubuntu-latest 33 | permissions: 34 | contents: read 35 | packages: write 36 | # This is used to complete the identity challenge 37 | # with sigstore/fulcio when running outside of PRs. 38 | id-token: write 39 | 40 | steps: 41 | - name: Checkout repository 42 | uses: actions/checkout@v3 43 | 44 | # Install the cosign tool except on PR 45 | # https://github.com/sigstore/cosign-installer 46 | - name: Install cosign 47 | if: github.event_name != 'pull_request' 48 | uses: sigstore/cosign-installer@v3.7.0 49 | with: 50 | cosign-release: 'v2.4.1' 51 | 52 | # Set up BuildKit Docker container builder to be able to build 53 | # multi-platform images and export cache 54 | # https://github.com/docker/setup-buildx-action 55 | - name: Set up Docker Buildx 56 | uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 57 | 58 | # Login against a Docker registry except on PR 59 | # https://github.com/docker/login-action 60 | - name: Log into registry ${{ env.REGISTRY }} 61 | if: github.event_name != 'pull_request' 62 | uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 63 | with: 64 | registry: ${{ env.REGISTRY }} 65 | username: ${{ github.actor }} 66 | password: ${{ secrets.GITHUB_TOKEN }} 67 | 68 | # Extract metadata (tags, labels) for Docker 69 | # https://github.com/docker/metadata-action 70 | - name: Extract Docker metadata 71 | id: meta 72 | uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 73 | with: 74 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 75 | 76 | # Build and push Docker image with Buildx (don't push on PR) 77 | # https://github.com/docker/build-push-action 78 | - name: Build and push Docker im0.4 79 | id: build-and-push 80 | uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 81 | with: 82 | context: . 83 | push: ${{ github.event_name != 'pull_request' }} 84 | tags: ${{ steps.meta.outputs.tags }} 85 | labels: ${{ steps.meta.outputs.labels }} 86 | cache-from: type=gha 87 | cache-to: type=gha,mode=max 88 | 89 | # Sign the resulting Docker image digest except on PRs. 90 | # This will only write to the public Rekor transparency log when the Docker 91 | # repository is public to avoid leaking data. If you would like to publish 92 | # transparency data even for private images, pass --force to cosign below. 93 | # https://github.com/sigstore/cosign 94 | - name: Sign the published Docker image 95 | if: ${{ github.event_name != 'pull_request' }} 96 | env: 97 | # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable 98 | TAGS: ${{ steps.meta.outputs.tags }} 99 | DIGEST: ${{ steps.build-and-push.outputs.digest }} 100 | # This step uses the identity token to provision an ephemeral certificate 101 | # against the sigstore community Fulcio instance. 102 | run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} 103 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: PHPUnit Tests 2 | 3 | on: 4 | push: 5 | branches: [ "development" ] 6 | pull_request: 7 | branches: [ "development" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | 20 | - name: Validate composer.json and composer.lock 21 | run: composer validate --strict 22 | 23 | - name: Cache Composer packages 24 | id: composer-cache 25 | uses: actions/cache@v3 26 | with: 27 | path: vendor 28 | key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }} 29 | restore-keys: | 30 | ${{ runner.os }}-php- 31 | 32 | - name: Install dependencies 33 | run: composer install --prefer-dist --no-progress 34 | 35 | # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" 36 | # Docs: https://getcomposer.org/doc/articles/scripts.md 37 | 38 | - name: Run test suite 39 | run: composer run-script coverage 40 | 41 | - name: Make code coverage badge 42 | uses: timkrase/phpunit-coverage-badge@v1.2.1 43 | with: 44 | coverage_badge_path: output/coverage.svg 45 | push_badge: false 46 | 47 | - name: Git push to image-data branches 48 | uses: peaceiris/actions-gh-pages@v3 49 | with: 50 | publish_dir: ./output 51 | publish_branch: image-data 52 | github_token: ${{ secrets.API_TOKEN }} 53 | user_name: 'github-actions[bot]' 54 | user_email: 'github-actions[bot]@users.noreply.github.com' 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | clover.xml 2 | coverage 3 | coverage.txt 4 | .phpunit* 5 | state 6 | vendor 7 | -------------------------------------------------------------------------------- /CODE-OF-CONDUCT.md: -------------------------------------------------------------------------------- 1 | Be kind. Don't be mean. 2 | 3 | For more information see [Golden Rule](https://en.wikipedia.org/wiki/Golden_Rule): 4 | 5 | - Treat others as you would like others to treat you 6 | - Do not treat others in ways that you would not like to be treated 7 | - What you wish upon others, you wish upon yourself 8 | 9 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.2.12-apache-bookworm 2 | RUN apt update && apt install -y git 3 | RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" && \ 4 | php -r "if (hash_file('sha384', 'composer-setup.php') === 'dac665fdc30fdd8ec78b38b9800061b4150413ff2e3b6f88543c636f7cd84f6db9189d43a81e5503cda447da73c7e5b6') { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" && \ 5 | php composer-setup.php && \ 6 | php -r "unlink('composer-setup.php');" && \ 7 | mv composer.phar /usr/local/bin/composer 8 | RUN apt-get update && apt-get install -y \ 9 | libzip-dev && \ 10 | docker-php-ext-configure zip && \ 11 | docker-php-ext-install -j$(nproc) zip 12 | RUN a2enmod rewrite 13 | RUN pecl install pcov && docker-php-ext-enable pcov 14 | RUN mkdir -p /opt/podsumer/ 15 | RUN mkdir -p /opt/podsumer/conf 16 | COPY ./apache.conf /etc/apache2/sites-available/000-default.conf 17 | COPY ./apache.conf /etc/apache2/sites-enabled/000-default.conf 18 | COPY ./conf/podsumer.conf /opt/podsumer/conf/podsumer.conf 19 | COPY ./sql /opt/podsumer/sql 20 | COPY ./src /opt/podsumer/src 21 | COPY ./templates /opt/podsumer/templates 22 | COPY ./www /opt/podsumer/www 23 | COPY ./composer.json /opt/podsumer/composer.json 24 | COPY ./composer.lock /opt/podsumer/composer.lock 25 | WORKDIR /opt/podsumer 26 | RUN chown -R www-data:www-data /opt/podsumer 27 | RUN chmod -R 755 /opt/podsumer 28 | RUN chown -R www-data:www-data /etc/apache2/sites-available/000-default.conf 29 | RUN chown -R www-data:www-data /etc/apache2/sites-enabled/000-default.conf 30 | RUN composer dump-autoload 31 | EXPOSE 3094 32 | 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2023 Joshua Brickner 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Unit Tests](https://github.com/joshwbrick/podsumer/actions/workflows/php.yml/badge.svg)](https://github.com/joshwbrick/podsumer/actions/workflows/php.yml) 2 | [![Unit Test Coverage](https://raw.githubusercontent.com/joshwbrick/podsumer/image-data/coverage.svg)](https://github.com/joshwbrick/podsumer/actions/workflows/php.yml) 3 | 4 | # Features 5 | - Self hostable 6 | - Single Tenant SQLite backend. 7 | - Run from a single docker image 8 | - Privacy oriented 9 | - No data or usage collection 10 | - Proxy and cache podcast feeds to reduce your traffic to data collecting feed and media servers. 11 | - Per episode artwork is supported 12 | - Supports streaming audio files (e.g. seek to any part of a podcast) 13 | - OPML Import & Export 14 | - Import your current subscriptions from another app 15 | - Export subscriptions to podsumer feeds for use in your mobile or other podcast app. 16 | - Single file library (optional) 17 | - Easily move and migrate your library via a single SQLite DB file. 18 | - Avoid file permissions headches in docker 19 | - Save media to disk (optional) 20 | - If you prefer to store audio and images on disk instead of in the database. 21 | - Faster backups for very large libraries. 22 | - Automatic feed refresh 23 | - Original feeds checked for updates when proxied feeds are queried. 24 | - Codebase contains zero 3rd-party dependencies. 25 | - Content focused UI 26 | 27 | # Roadmap 28 | 29 | For a look at upcoming enhancements checkout the enhancement tag under issues tab. 30 | 31 | # Usage 32 | 33 | This project is useful for self-hosters who listen to podcasts. It allows you to listen to your podcasts via the web on your own infrastructure. You can also use the OPML export to subscribe to Podsumer's mirror of the original feeds. This improves privacy as download and listening metrics will not be tied to your phone or personal computer. Using something like gluetun you can have all the traffic to podcast servers go through a VPN as well, further shrinking your digital footprint. 34 | 35 | ## Installing with Docker Compose 36 | 37 | The docker image is based on the official PHP Debian Bookworm with Apache image. 38 | 39 | ``` 40 | podsumer: 41 | image: ghcr.io/joshwbrick/podsumer:v0.9.4 42 | container_name: podsumer 43 | volumes: 44 | /path/to/dir/for/db:/opt/podsumer/store 45 | /path/to/config/podsumer.conf:/opt/podsumer/conf/podsumer.conf 46 | ports: 47 | - 3094:3094 48 | ``` 49 | 50 | # Requirements 51 | 52 | - PHP 8.2+ w/ extensions: 53 | - simplexml 54 | - curl 55 | - finfo 56 | - PDO 57 | - Composer 58 | - SQLite 3.6.19+ 59 | - Foreign key support is required. 60 | 61 | # Screenshots 62 | 63 | ### Home Page / Feed List 64 | [![Feed](https://raw.githubusercontent.com/joshwbrick/podsumer/development/screenshots/feeds.png)](https://github.com/joshwbrick/podsumer/development/screenshots/feeds.png) 65 | 66 | ### Episode List 67 | [![Episodes](https://raw.githubusercontent.com/joshwbrick/podsumer/development/screenshots/feed.png)](https://raw.githubusercontent.com/joshwbrick/podsumer/development/screenshots/feed.png) 68 | 69 | ### Player Page 70 | [![Player](https://raw.githubusercontent.com/joshwbrick/podsumer/development/screenshots/episode.png)](https://raw.githubusercontent.com/joshwbrick/podsumer/development/screenshots/episode.png) 71 | -------------------------------------------------------------------------------- /apache.conf: -------------------------------------------------------------------------------- 1 | Listen 3094 2 | 3 | 4 | DocumentRoot "/opt/podsumer/www" 5 | 6 | # Suppress server version and OS identity in headers 7 | ServerSignature Off 8 | 9 | # Rewrite engine setup to redirect all traffic to index.php 10 | RewriteEngine on 11 | RewriteCond %{REQUEST_URI} !^/?index.php$ 12 | RewriteRule ^(.*)$ /index.php?url=$1 [L,QSA] 13 | 14 | # Disable access and error logs 15 | CustomLog /dev/stdout combined 16 | ErrorLog /dev/stdout 17 | 18 | # Suppress Apache-generated error pages and do not expose Apache error documents 19 | RedirectMatch 404 ^/.* 20 | 21 | 22 | # Disallow directory listings 23 | Options -Indexes 24 | 25 | # Allow override directives 26 | AllowOverride All 27 | 28 | # Require all granted to serve this site 29 | Require all granted 30 | 31 | # Do not show .htaccess controlled files 32 | 33 | Require all denied 34 | 35 | 36 | 37 | # Deny access to .htaccess files 38 | 39 | Order allow,deny 40 | Deny from all 41 | 42 | 43 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brickner/podsumer", 3 | "description": "An open, self-hosted podcatcher.", 4 | "type": "project", 5 | "license": "MIT", 6 | "autoload": { 7 | "psr-4": { 8 | "Brickner\\Podsumer\\": "src/Brickner/Podsumer" 9 | } 10 | }, 11 | "authors": [ 12 | { 13 | "name": "Josh Brickner" 14 | } 15 | ], 16 | "minimum-stability": "dev", 17 | "require-dev": { 18 | "phpunit/phpunit": "^10.4" 19 | }, 20 | "scripts": { 21 | "test": "phpunit tests", 22 | "coverage": "XDEBUG_MODE=coverage phpunit --coverage-filter src --coverage-clover=clover.xml tests", 23 | "coverage-dev": "XDEBUG_MODE=coverage phpunit --coverage-filter src --coverage-html=coverage tests" 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /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": "8448fb0d3d2148e7770b6bfdc8f9595f", 8 | "packages": [], 9 | "packages-dev": [ 10 | { 11 | "name": "myclabs/deep-copy", 12 | "version": "1.x-dev", 13 | "source": { 14 | "type": "git", 15 | "url": "https://github.com/myclabs/DeepCopy.git", 16 | "reference": "202aaf6b7c2e1e0a622b0298e9f3f537e4d84018" 17 | }, 18 | "dist": { 19 | "type": "zip", 20 | "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/202aaf6b7c2e1e0a622b0298e9f3f537e4d84018", 21 | "reference": "202aaf6b7c2e1e0a622b0298e9f3f537e4d84018", 22 | "shasum": "" 23 | }, 24 | "require": { 25 | "php": "^7.1 || ^8.0" 26 | }, 27 | "conflict": { 28 | "doctrine/collections": "<1.6.8", 29 | "doctrine/common": "<2.13.3 || >=3 <3.2.2" 30 | }, 31 | "require-dev": { 32 | "doctrine/collections": "^1.6.8", 33 | "doctrine/common": "^2.13.3 || ^3.2.2", 34 | "phpspec/prophecy": "^1.10", 35 | "phpunit/phpunit": "^7.5.20 || ^8.5.23 || ^9.5.13" 36 | }, 37 | "default-branch": true, 38 | "type": "library", 39 | "autoload": { 40 | "files": [ 41 | "src/DeepCopy/deep_copy.php" 42 | ], 43 | "psr-4": { 44 | "DeepCopy\\": "src/DeepCopy/" 45 | } 46 | }, 47 | "notification-url": "https://packagist.org/downloads/", 48 | "license": [ 49 | "MIT" 50 | ], 51 | "description": "Create deep copies (clones) of your objects", 52 | "keywords": [ 53 | "clone", 54 | "copy", 55 | "duplicate", 56 | "object", 57 | "object graph" 58 | ], 59 | "support": { 60 | "issues": "https://github.com/myclabs/DeepCopy/issues", 61 | "source": "https://github.com/myclabs/DeepCopy/tree/1.x" 62 | }, 63 | "funding": [ 64 | { 65 | "url": "https://tidelift.com/funding/github/packagist/myclabs/deep-copy", 66 | "type": "tidelift" 67 | } 68 | ], 69 | "time": "2023-11-01T08:01:43+00:00" 70 | }, 71 | { 72 | "name": "nikic/php-parser", 73 | "version": "4.x-dev", 74 | "source": { 75 | "type": "git", 76 | "url": "https://github.com/nikic/PHP-Parser.git", 77 | "reference": "402b6cf3452c21c58aa11d9549cee6205d14e347" 78 | }, 79 | "dist": { 80 | "type": "zip", 81 | "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/402b6cf3452c21c58aa11d9549cee6205d14e347", 82 | "reference": "402b6cf3452c21c58aa11d9549cee6205d14e347", 83 | "shasum": "" 84 | }, 85 | "require": { 86 | "ext-tokenizer": "*", 87 | "php": ">=7.0" 88 | }, 89 | "require-dev": { 90 | "ircmaxell/php-yacc": "^0.0.7", 91 | "phpunit/phpunit": "^6.5 || ^7.0 || ^8.0 || ^9.0" 92 | }, 93 | "bin": [ 94 | "bin/php-parse" 95 | ], 96 | "type": "library", 97 | "extra": { 98 | "branch-alias": { 99 | "dev-master": "4.9-dev" 100 | } 101 | }, 102 | "autoload": { 103 | "psr-4": { 104 | "PhpParser\\": "lib/PhpParser" 105 | } 106 | }, 107 | "notification-url": "https://packagist.org/downloads/", 108 | "license": [ 109 | "BSD-3-Clause" 110 | ], 111 | "authors": [ 112 | { 113 | "name": "Nikita Popov" 114 | } 115 | ], 116 | "description": "A PHP parser written in PHP", 117 | "keywords": [ 118 | "parser", 119 | "php" 120 | ], 121 | "support": { 122 | "issues": "https://github.com/nikic/PHP-Parser/issues", 123 | "source": "https://github.com/nikic/PHP-Parser/tree/4.x" 124 | }, 125 | "time": "2023-11-01T20:31:02+00:00" 126 | }, 127 | { 128 | "name": "phar-io/manifest", 129 | "version": "dev-master", 130 | "source": { 131 | "type": "git", 132 | "url": "https://github.com/phar-io/manifest.git", 133 | "reference": "67729272c564ab9f953c81f48db44e8b1cb1e1c3" 134 | }, 135 | "dist": { 136 | "type": "zip", 137 | "url": "https://api.github.com/repos/phar-io/manifest/zipball/67729272c564ab9f953c81f48db44e8b1cb1e1c3", 138 | "reference": "67729272c564ab9f953c81f48db44e8b1cb1e1c3", 139 | "shasum": "" 140 | }, 141 | "require": { 142 | "ext-dom": "*", 143 | "ext-libxml": "*", 144 | "ext-phar": "*", 145 | "ext-xmlwriter": "*", 146 | "phar-io/version": "^3.0.1", 147 | "php": "^7.3 || ^8.0" 148 | }, 149 | "default-branch": true, 150 | "type": "library", 151 | "extra": { 152 | "branch-alias": { 153 | "dev-master": "2.0.x-dev" 154 | } 155 | }, 156 | "autoload": { 157 | "classmap": [ 158 | "src/" 159 | ] 160 | }, 161 | "notification-url": "https://packagist.org/downloads/", 162 | "license": [ 163 | "BSD-3-Clause" 164 | ], 165 | "authors": [ 166 | { 167 | "name": "Arne Blankerts", 168 | "email": "arne@blankerts.de", 169 | "role": "Developer" 170 | }, 171 | { 172 | "name": "Sebastian Heuer", 173 | "email": "sebastian@phpeople.de", 174 | "role": "Developer" 175 | }, 176 | { 177 | "name": "Sebastian Bergmann", 178 | "email": "sebastian@phpunit.de", 179 | "role": "Developer" 180 | } 181 | ], 182 | "description": "Component for reading phar.io manifest information from a PHP Archive (PHAR)", 183 | "support": { 184 | "issues": "https://github.com/phar-io/manifest/issues", 185 | "source": "https://github.com/phar-io/manifest/tree/master" 186 | }, 187 | "funding": [ 188 | { 189 | "url": "https://github.com/theseer", 190 | "type": "github" 191 | } 192 | ], 193 | "time": "2023-06-01T14:19:47+00:00" 194 | }, 195 | { 196 | "name": "phar-io/version", 197 | "version": "3.2.1", 198 | "source": { 199 | "type": "git", 200 | "url": "https://github.com/phar-io/version.git", 201 | "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74" 202 | }, 203 | "dist": { 204 | "type": "zip", 205 | "url": "https://api.github.com/repos/phar-io/version/zipball/4f7fd7836c6f332bb2933569e566a0d6c4cbed74", 206 | "reference": "4f7fd7836c6f332bb2933569e566a0d6c4cbed74", 207 | "shasum": "" 208 | }, 209 | "require": { 210 | "php": "^7.2 || ^8.0" 211 | }, 212 | "type": "library", 213 | "autoload": { 214 | "classmap": [ 215 | "src/" 216 | ] 217 | }, 218 | "notification-url": "https://packagist.org/downloads/", 219 | "license": [ 220 | "BSD-3-Clause" 221 | ], 222 | "authors": [ 223 | { 224 | "name": "Arne Blankerts", 225 | "email": "arne@blankerts.de", 226 | "role": "Developer" 227 | }, 228 | { 229 | "name": "Sebastian Heuer", 230 | "email": "sebastian@phpeople.de", 231 | "role": "Developer" 232 | }, 233 | { 234 | "name": "Sebastian Bergmann", 235 | "email": "sebastian@phpunit.de", 236 | "role": "Developer" 237 | } 238 | ], 239 | "description": "Library for handling version information and constraints", 240 | "support": { 241 | "issues": "https://github.com/phar-io/version/issues", 242 | "source": "https://github.com/phar-io/version/tree/3.2.1" 243 | }, 244 | "time": "2022-02-21T01:04:05+00:00" 245 | }, 246 | { 247 | "name": "phpunit/php-code-coverage", 248 | "version": "dev-main", 249 | "source": { 250 | "type": "git", 251 | "url": "https://github.com/sebastianbergmann/php-code-coverage.git", 252 | "reference": "8cba024d7a7b66366bb7f7712123896ff5ada4ca" 253 | }, 254 | "dist": { 255 | "type": "zip", 256 | "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/8cba024d7a7b66366bb7f7712123896ff5ada4ca", 257 | "reference": "8cba024d7a7b66366bb7f7712123896ff5ada4ca", 258 | "shasum": "" 259 | }, 260 | "require": { 261 | "ext-dom": "*", 262 | "ext-libxml": "*", 263 | "ext-xmlwriter": "*", 264 | "nikic/php-parser": "^4.15", 265 | "php": ">=8.1", 266 | "phpunit/php-file-iterator": "^4.0", 267 | "phpunit/php-text-template": "^3.0", 268 | "sebastian/code-unit-reverse-lookup": "^3.0", 269 | "sebastian/complexity": "^3.0", 270 | "sebastian/environment": "^6.0", 271 | "sebastian/lines-of-code": "^2.0", 272 | "sebastian/version": "^4.0", 273 | "theseer/tokenizer": "^1.2.0" 274 | }, 275 | "require-dev": { 276 | "phpunit/phpunit": "^10.1" 277 | }, 278 | "suggest": { 279 | "ext-pcov": "PHP extension that provides line coverage", 280 | "ext-xdebug": "PHP extension that provides line coverage as well as branch and path coverage" 281 | }, 282 | "default-branch": true, 283 | "type": "library", 284 | "extra": { 285 | "branch-alias": { 286 | "dev-main": "10.1-dev" 287 | } 288 | }, 289 | "autoload": { 290 | "classmap": [ 291 | "src/" 292 | ] 293 | }, 294 | "notification-url": "https://packagist.org/downloads/", 295 | "license": [ 296 | "BSD-3-Clause" 297 | ], 298 | "authors": [ 299 | { 300 | "name": "Sebastian Bergmann", 301 | "email": "sebastian@phpunit.de", 302 | "role": "lead" 303 | } 304 | ], 305 | "description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.", 306 | "homepage": "https://github.com/sebastianbergmann/php-code-coverage", 307 | "keywords": [ 308 | "coverage", 309 | "testing", 310 | "xunit" 311 | ], 312 | "support": { 313 | "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", 314 | "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", 315 | "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/main" 316 | }, 317 | "funding": [ 318 | { 319 | "url": "https://github.com/sebastianbergmann", 320 | "type": "github" 321 | } 322 | ], 323 | "time": "2023-11-05T08:42:57+00:00" 324 | }, 325 | { 326 | "name": "phpunit/php-file-iterator", 327 | "version": "4.1.0", 328 | "source": { 329 | "type": "git", 330 | "url": "https://github.com/sebastianbergmann/php-file-iterator.git", 331 | "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c" 332 | }, 333 | "dist": { 334 | "type": "zip", 335 | "url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/a95037b6d9e608ba092da1b23931e537cadc3c3c", 336 | "reference": "a95037b6d9e608ba092da1b23931e537cadc3c3c", 337 | "shasum": "" 338 | }, 339 | "require": { 340 | "php": ">=8.1" 341 | }, 342 | "require-dev": { 343 | "phpunit/phpunit": "^10.0" 344 | }, 345 | "type": "library", 346 | "extra": { 347 | "branch-alias": { 348 | "dev-main": "4.0-dev" 349 | } 350 | }, 351 | "autoload": { 352 | "classmap": [ 353 | "src/" 354 | ] 355 | }, 356 | "notification-url": "https://packagist.org/downloads/", 357 | "license": [ 358 | "BSD-3-Clause" 359 | ], 360 | "authors": [ 361 | { 362 | "name": "Sebastian Bergmann", 363 | "email": "sebastian@phpunit.de", 364 | "role": "lead" 365 | } 366 | ], 367 | "description": "FilterIterator implementation that filters files based on a list of suffixes.", 368 | "homepage": "https://github.com/sebastianbergmann/php-file-iterator/", 369 | "keywords": [ 370 | "filesystem", 371 | "iterator" 372 | ], 373 | "support": { 374 | "issues": "https://github.com/sebastianbergmann/php-file-iterator/issues", 375 | "security": "https://github.com/sebastianbergmann/php-file-iterator/security/policy", 376 | "source": "https://github.com/sebastianbergmann/php-file-iterator/tree/4.1.0" 377 | }, 378 | "funding": [ 379 | { 380 | "url": "https://github.com/sebastianbergmann", 381 | "type": "github" 382 | } 383 | ], 384 | "time": "2023-08-31T06:24:48+00:00" 385 | }, 386 | { 387 | "name": "phpunit/php-invoker", 388 | "version": "dev-main", 389 | "source": { 390 | "type": "git", 391 | "url": "https://github.com/sebastianbergmann/php-invoker.git", 392 | "reference": "e3c978565adbdbfee871a91a67fdac13fcaf375f" 393 | }, 394 | "dist": { 395 | "type": "zip", 396 | "url": "https://api.github.com/repos/sebastianbergmann/php-invoker/zipball/e3c978565adbdbfee871a91a67fdac13fcaf375f", 397 | "reference": "e3c978565adbdbfee871a91a67fdac13fcaf375f", 398 | "shasum": "" 399 | }, 400 | "require": { 401 | "php": ">=8.1" 402 | }, 403 | "require-dev": { 404 | "ext-pcntl": "*", 405 | "phpunit/phpunit": "^10.0" 406 | }, 407 | "suggest": { 408 | "ext-pcntl": "*" 409 | }, 410 | "default-branch": true, 411 | "type": "library", 412 | "extra": { 413 | "branch-alias": { 414 | "dev-main": "4.0-dev" 415 | } 416 | }, 417 | "autoload": { 418 | "classmap": [ 419 | "src/" 420 | ] 421 | }, 422 | "notification-url": "https://packagist.org/downloads/", 423 | "license": [ 424 | "BSD-3-Clause" 425 | ], 426 | "authors": [ 427 | { 428 | "name": "Sebastian Bergmann", 429 | "email": "sebastian@phpunit.de", 430 | "role": "lead" 431 | } 432 | ], 433 | "description": "Invoke callables with a timeout", 434 | "homepage": "https://github.com/sebastianbergmann/php-invoker/", 435 | "keywords": [ 436 | "process" 437 | ], 438 | "support": { 439 | "issues": "https://github.com/sebastianbergmann/php-invoker/issues", 440 | "security": "https://github.com/sebastianbergmann/php-invoker/security/policy", 441 | "source": "https://github.com/sebastianbergmann/php-invoker/tree/main" 442 | }, 443 | "funding": [ 444 | { 445 | "url": "https://github.com/sebastianbergmann", 446 | "type": "github" 447 | } 448 | ], 449 | "time": "2023-11-05T08:38:04+00:00" 450 | }, 451 | { 452 | "name": "phpunit/php-text-template", 453 | "version": "dev-main", 454 | "source": { 455 | "type": "git", 456 | "url": "https://github.com/sebastianbergmann/php-text-template.git", 457 | "reference": "58658024d21903540b329eda76f2dcf9268b50c0" 458 | }, 459 | "dist": { 460 | "type": "zip", 461 | "url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/58658024d21903540b329eda76f2dcf9268b50c0", 462 | "reference": "58658024d21903540b329eda76f2dcf9268b50c0", 463 | "shasum": "" 464 | }, 465 | "require": { 466 | "php": ">=8.1" 467 | }, 468 | "require-dev": { 469 | "phpunit/phpunit": "^10.0" 470 | }, 471 | "default-branch": true, 472 | "type": "library", 473 | "extra": { 474 | "branch-alias": { 475 | "dev-main": "3.0-dev" 476 | } 477 | }, 478 | "autoload": { 479 | "classmap": [ 480 | "src/" 481 | ] 482 | }, 483 | "notification-url": "https://packagist.org/downloads/", 484 | "license": [ 485 | "BSD-3-Clause" 486 | ], 487 | "authors": [ 488 | { 489 | "name": "Sebastian Bergmann", 490 | "email": "sebastian@phpunit.de", 491 | "role": "lead" 492 | } 493 | ], 494 | "description": "Simple template engine.", 495 | "homepage": "https://github.com/sebastianbergmann/php-text-template/", 496 | "keywords": [ 497 | "template" 498 | ], 499 | "support": { 500 | "issues": "https://github.com/sebastianbergmann/php-text-template/issues", 501 | "security": "https://github.com/sebastianbergmann/php-text-template/security/policy", 502 | "source": "https://github.com/sebastianbergmann/php-text-template/tree/main" 503 | }, 504 | "funding": [ 505 | { 506 | "url": "https://github.com/sebastianbergmann", 507 | "type": "github" 508 | } 509 | ], 510 | "time": "2023-11-05T08:38:13+00:00" 511 | }, 512 | { 513 | "name": "phpunit/php-timer", 514 | "version": "dev-main", 515 | "source": { 516 | "type": "git", 517 | "url": "https://github.com/sebastianbergmann/php-timer.git", 518 | "reference": "fa22346521f4b2f82c2a13ba1a28c9b757b4bba4" 519 | }, 520 | "dist": { 521 | "type": "zip", 522 | "url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/fa22346521f4b2f82c2a13ba1a28c9b757b4bba4", 523 | "reference": "fa22346521f4b2f82c2a13ba1a28c9b757b4bba4", 524 | "shasum": "" 525 | }, 526 | "require": { 527 | "php": ">=8.1" 528 | }, 529 | "require-dev": { 530 | "phpunit/phpunit": "^10.0" 531 | }, 532 | "default-branch": true, 533 | "type": "library", 534 | "extra": { 535 | "branch-alias": { 536 | "dev-main": "6.0-dev" 537 | } 538 | }, 539 | "autoload": { 540 | "classmap": [ 541 | "src/" 542 | ] 543 | }, 544 | "notification-url": "https://packagist.org/downloads/", 545 | "license": [ 546 | "BSD-3-Clause" 547 | ], 548 | "authors": [ 549 | { 550 | "name": "Sebastian Bergmann", 551 | "email": "sebastian@phpunit.de", 552 | "role": "lead" 553 | } 554 | ], 555 | "description": "Utility class for timing", 556 | "homepage": "https://github.com/sebastianbergmann/php-timer/", 557 | "keywords": [ 558 | "timer" 559 | ], 560 | "support": { 561 | "issues": "https://github.com/sebastianbergmann/php-timer/issues", 562 | "security": "https://github.com/sebastianbergmann/php-timer/security/policy", 563 | "source": "https://github.com/sebastianbergmann/php-timer/tree/main" 564 | }, 565 | "funding": [ 566 | { 567 | "url": "https://github.com/sebastianbergmann", 568 | "type": "github" 569 | } 570 | ], 571 | "time": "2023-11-05T08:38:22+00:00" 572 | }, 573 | { 574 | "name": "phpunit/phpunit", 575 | "version": "10.5.x-dev", 576 | "source": { 577 | "type": "git", 578 | "url": "https://github.com/sebastianbergmann/phpunit.git", 579 | "reference": "c32ea680a29ac6ebcad346702302211353db9371" 580 | }, 581 | "dist": { 582 | "type": "zip", 583 | "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/c32ea680a29ac6ebcad346702302211353db9371", 584 | "reference": "c32ea680a29ac6ebcad346702302211353db9371", 585 | "shasum": "" 586 | }, 587 | "require": { 588 | "ext-dom": "*", 589 | "ext-json": "*", 590 | "ext-libxml": "*", 591 | "ext-mbstring": "*", 592 | "ext-xml": "*", 593 | "ext-xmlwriter": "*", 594 | "myclabs/deep-copy": "^1.10.1", 595 | "phar-io/manifest": "^2.0.3", 596 | "phar-io/version": "^3.0.2", 597 | "php": ">=8.1", 598 | "phpunit/php-code-coverage": "^10.1.5", 599 | "phpunit/php-file-iterator": "^4.0", 600 | "phpunit/php-invoker": "^4.0", 601 | "phpunit/php-text-template": "^3.0", 602 | "phpunit/php-timer": "^6.0", 603 | "sebastian/cli-parser": "^2.0", 604 | "sebastian/code-unit": "^2.0", 605 | "sebastian/comparator": "^5.0", 606 | "sebastian/diff": "^5.0", 607 | "sebastian/environment": "^6.0", 608 | "sebastian/exporter": "^5.1", 609 | "sebastian/global-state": "^6.0.1", 610 | "sebastian/object-enumerator": "^5.0", 611 | "sebastian/recursion-context": "^5.0", 612 | "sebastian/type": "^4.0", 613 | "sebastian/version": "^4.0" 614 | }, 615 | "suggest": { 616 | "ext-soap": "To be able to generate mocks based on WSDL files" 617 | }, 618 | "bin": [ 619 | "phpunit" 620 | ], 621 | "type": "library", 622 | "extra": { 623 | "branch-alias": { 624 | "dev-main": "10.5-dev" 625 | } 626 | }, 627 | "autoload": { 628 | "files": [ 629 | "src/Framework/Assert/Functions.php" 630 | ], 631 | "classmap": [ 632 | "src/" 633 | ] 634 | }, 635 | "notification-url": "https://packagist.org/downloads/", 636 | "license": [ 637 | "BSD-3-Clause" 638 | ], 639 | "authors": [ 640 | { 641 | "name": "Sebastian Bergmann", 642 | "email": "sebastian@phpunit.de", 643 | "role": "lead" 644 | } 645 | ], 646 | "description": "The PHP Unit Testing framework.", 647 | "homepage": "https://phpunit.de/", 648 | "keywords": [ 649 | "phpunit", 650 | "testing", 651 | "xunit" 652 | ], 653 | "support": { 654 | "issues": "https://github.com/sebastianbergmann/phpunit/issues", 655 | "security": "https://github.com/sebastianbergmann/phpunit/security/policy", 656 | "source": "https://github.com/sebastianbergmann/phpunit/tree/10.5" 657 | }, 658 | "funding": [ 659 | { 660 | "url": "https://phpunit.de/sponsors.html", 661 | "type": "custom" 662 | }, 663 | { 664 | "url": "https://github.com/sebastianbergmann", 665 | "type": "github" 666 | }, 667 | { 668 | "url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit", 669 | "type": "tidelift" 670 | } 671 | ], 672 | "time": "2023-11-05T08:41:53+00:00" 673 | }, 674 | { 675 | "name": "sebastian/cli-parser", 676 | "version": "dev-main", 677 | "source": { 678 | "type": "git", 679 | "url": "https://github.com/sebastianbergmann/cli-parser.git", 680 | "reference": "c0dd8d593de76efc5387b6f59fa81d1142ff7a02" 681 | }, 682 | "dist": { 683 | "type": "zip", 684 | "url": "https://api.github.com/repos/sebastianbergmann/cli-parser/zipball/c0dd8d593de76efc5387b6f59fa81d1142ff7a02", 685 | "reference": "c0dd8d593de76efc5387b6f59fa81d1142ff7a02", 686 | "shasum": "" 687 | }, 688 | "require": { 689 | "php": ">=8.1" 690 | }, 691 | "require-dev": { 692 | "phpunit/phpunit": "^10.0" 693 | }, 694 | "default-branch": true, 695 | "type": "library", 696 | "extra": { 697 | "branch-alias": { 698 | "dev-main": "2.0-dev" 699 | } 700 | }, 701 | "autoload": { 702 | "classmap": [ 703 | "src/" 704 | ] 705 | }, 706 | "notification-url": "https://packagist.org/downloads/", 707 | "license": [ 708 | "BSD-3-Clause" 709 | ], 710 | "authors": [ 711 | { 712 | "name": "Sebastian Bergmann", 713 | "email": "sebastian@phpunit.de", 714 | "role": "lead" 715 | } 716 | ], 717 | "description": "Library for parsing CLI options", 718 | "homepage": "https://github.com/sebastianbergmann/cli-parser", 719 | "support": { 720 | "issues": "https://github.com/sebastianbergmann/cli-parser/issues", 721 | "security": "https://github.com/sebastianbergmann/cli-parser/security/policy", 722 | "source": "https://github.com/sebastianbergmann/cli-parser/tree/main" 723 | }, 724 | "funding": [ 725 | { 726 | "url": "https://github.com/sebastianbergmann", 727 | "type": "github" 728 | } 729 | ], 730 | "time": "2023-11-05T08:36:04+00:00" 731 | }, 732 | { 733 | "name": "sebastian/code-unit", 734 | "version": "dev-main", 735 | "source": { 736 | "type": "git", 737 | "url": "https://github.com/sebastianbergmann/code-unit.git", 738 | "reference": "3db0c02903b84269b29fc01d2cdee12b466c0cd2" 739 | }, 740 | "dist": { 741 | "type": "zip", 742 | "url": "https://api.github.com/repos/sebastianbergmann/code-unit/zipball/3db0c02903b84269b29fc01d2cdee12b466c0cd2", 743 | "reference": "3db0c02903b84269b29fc01d2cdee12b466c0cd2", 744 | "shasum": "" 745 | }, 746 | "require": { 747 | "php": ">=8.1" 748 | }, 749 | "require-dev": { 750 | "phpunit/phpunit": "^10.0" 751 | }, 752 | "default-branch": true, 753 | "type": "library", 754 | "extra": { 755 | "branch-alias": { 756 | "dev-main": "2.0-dev" 757 | } 758 | }, 759 | "autoload": { 760 | "classmap": [ 761 | "src/" 762 | ] 763 | }, 764 | "notification-url": "https://packagist.org/downloads/", 765 | "license": [ 766 | "BSD-3-Clause" 767 | ], 768 | "authors": [ 769 | { 770 | "name": "Sebastian Bergmann", 771 | "email": "sebastian@phpunit.de", 772 | "role": "lead" 773 | } 774 | ], 775 | "description": "Collection of value objects that represent the PHP code units", 776 | "homepage": "https://github.com/sebastianbergmann/code-unit", 777 | "support": { 778 | "issues": "https://github.com/sebastianbergmann/code-unit/issues", 779 | "security": "https://github.com/sebastianbergmann/code-unit/security/policy", 780 | "source": "https://github.com/sebastianbergmann/code-unit/tree/main" 781 | }, 782 | "funding": [ 783 | { 784 | "url": "https://github.com/sebastianbergmann", 785 | "type": "github" 786 | } 787 | ], 788 | "time": "2023-11-05T08:36:15+00:00" 789 | }, 790 | { 791 | "name": "sebastian/code-unit-reverse-lookup", 792 | "version": "dev-main", 793 | "source": { 794 | "type": "git", 795 | "url": "https://github.com/sebastianbergmann/code-unit-reverse-lookup.git", 796 | "reference": "976d605bc37509624206e4ca77ffe0657c374259" 797 | }, 798 | "dist": { 799 | "type": "zip", 800 | "url": "https://api.github.com/repos/sebastianbergmann/code-unit-reverse-lookup/zipball/976d605bc37509624206e4ca77ffe0657c374259", 801 | "reference": "976d605bc37509624206e4ca77ffe0657c374259", 802 | "shasum": "" 803 | }, 804 | "require": { 805 | "php": ">=8.1" 806 | }, 807 | "require-dev": { 808 | "phpunit/phpunit": "^10.0" 809 | }, 810 | "default-branch": true, 811 | "type": "library", 812 | "extra": { 813 | "branch-alias": { 814 | "dev-main": "3.0-dev" 815 | } 816 | }, 817 | "autoload": { 818 | "classmap": [ 819 | "src/" 820 | ] 821 | }, 822 | "notification-url": "https://packagist.org/downloads/", 823 | "license": [ 824 | "BSD-3-Clause" 825 | ], 826 | "authors": [ 827 | { 828 | "name": "Sebastian Bergmann", 829 | "email": "sebastian@phpunit.de" 830 | } 831 | ], 832 | "description": "Looks up which function or method a line of code belongs to", 833 | "homepage": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/", 834 | "support": { 835 | "issues": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/issues", 836 | "security": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/security/policy", 837 | "source": "https://github.com/sebastianbergmann/code-unit-reverse-lookup/tree/main" 838 | }, 839 | "funding": [ 840 | { 841 | "url": "https://github.com/sebastianbergmann", 842 | "type": "github" 843 | } 844 | ], 845 | "time": "2023-11-05T08:36:25+00:00" 846 | }, 847 | { 848 | "name": "sebastian/comparator", 849 | "version": "dev-main", 850 | "source": { 851 | "type": "git", 852 | "url": "https://github.com/sebastianbergmann/comparator.git", 853 | "reference": "fd2147c23c3fee83db219d6bf86632aed459cd20" 854 | }, 855 | "dist": { 856 | "type": "zip", 857 | "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fd2147c23c3fee83db219d6bf86632aed459cd20", 858 | "reference": "fd2147c23c3fee83db219d6bf86632aed459cd20", 859 | "shasum": "" 860 | }, 861 | "require": { 862 | "ext-dom": "*", 863 | "ext-mbstring": "*", 864 | "php": ">=8.1", 865 | "sebastian/diff": "^5.0", 866 | "sebastian/exporter": "^5.0" 867 | }, 868 | "require-dev": { 869 | "phpunit/phpunit": "^10.4" 870 | }, 871 | "default-branch": true, 872 | "type": "library", 873 | "extra": { 874 | "branch-alias": { 875 | "dev-main": "5.0-dev" 876 | } 877 | }, 878 | "autoload": { 879 | "classmap": [ 880 | "src/" 881 | ] 882 | }, 883 | "notification-url": "https://packagist.org/downloads/", 884 | "license": [ 885 | "BSD-3-Clause" 886 | ], 887 | "authors": [ 888 | { 889 | "name": "Sebastian Bergmann", 890 | "email": "sebastian@phpunit.de" 891 | }, 892 | { 893 | "name": "Jeff Welch", 894 | "email": "whatthejeff@gmail.com" 895 | }, 896 | { 897 | "name": "Volker Dusch", 898 | "email": "github@wallbash.com" 899 | }, 900 | { 901 | "name": "Bernhard Schussek", 902 | "email": "bschussek@2bepublished.at" 903 | } 904 | ], 905 | "description": "Provides the functionality to compare PHP values for equality", 906 | "homepage": "https://github.com/sebastianbergmann/comparator", 907 | "keywords": [ 908 | "comparator", 909 | "compare", 910 | "equality" 911 | ], 912 | "support": { 913 | "issues": "https://github.com/sebastianbergmann/comparator/issues", 914 | "security": "https://github.com/sebastianbergmann/comparator/security/policy", 915 | "source": "https://github.com/sebastianbergmann/comparator/tree/main" 916 | }, 917 | "funding": [ 918 | { 919 | "url": "https://github.com/sebastianbergmann", 920 | "type": "github" 921 | } 922 | ], 923 | "time": "2023-11-05T08:36:33+00:00" 924 | }, 925 | { 926 | "name": "sebastian/complexity", 927 | "version": "dev-main", 928 | "source": { 929 | "type": "git", 930 | "url": "https://github.com/sebastianbergmann/complexity.git", 931 | "reference": "29d4265739e534dbc79dfeefae1a7534411f5094" 932 | }, 933 | "dist": { 934 | "type": "zip", 935 | "url": "https://api.github.com/repos/sebastianbergmann/complexity/zipball/29d4265739e534dbc79dfeefae1a7534411f5094", 936 | "reference": "29d4265739e534dbc79dfeefae1a7534411f5094", 937 | "shasum": "" 938 | }, 939 | "require": { 940 | "nikic/php-parser": "^4.10", 941 | "php": ">=8.1" 942 | }, 943 | "require-dev": { 944 | "phpunit/phpunit": "^10.0" 945 | }, 946 | "default-branch": true, 947 | "type": "library", 948 | "extra": { 949 | "branch-alias": { 950 | "dev-main": "3.2-dev" 951 | } 952 | }, 953 | "autoload": { 954 | "classmap": [ 955 | "src/" 956 | ] 957 | }, 958 | "notification-url": "https://packagist.org/downloads/", 959 | "license": [ 960 | "BSD-3-Clause" 961 | ], 962 | "authors": [ 963 | { 964 | "name": "Sebastian Bergmann", 965 | "email": "sebastian@phpunit.de", 966 | "role": "lead" 967 | } 968 | ], 969 | "description": "Library for calculating the complexity of PHP code units", 970 | "homepage": "https://github.com/sebastianbergmann/complexity", 971 | "support": { 972 | "issues": "https://github.com/sebastianbergmann/complexity/issues", 973 | "security": "https://github.com/sebastianbergmann/complexity/security/policy", 974 | "source": "https://github.com/sebastianbergmann/complexity/tree/main" 975 | }, 976 | "funding": [ 977 | { 978 | "url": "https://github.com/sebastianbergmann", 979 | "type": "github" 980 | } 981 | ], 982 | "time": "2023-11-05T08:36:43+00:00" 983 | }, 984 | { 985 | "name": "sebastian/diff", 986 | "version": "dev-main", 987 | "source": { 988 | "type": "git", 989 | "url": "https://github.com/sebastianbergmann/diff.git", 990 | "reference": "256b76eaa62da8669c29e3fd56cc7aaef20b3c6c" 991 | }, 992 | "dist": { 993 | "type": "zip", 994 | "url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/256b76eaa62da8669c29e3fd56cc7aaef20b3c6c", 995 | "reference": "256b76eaa62da8669c29e3fd56cc7aaef20b3c6c", 996 | "shasum": "" 997 | }, 998 | "require": { 999 | "php": ">=8.1" 1000 | }, 1001 | "require-dev": { 1002 | "phpunit/phpunit": "^10.0", 1003 | "symfony/process": "^4.2 || ^5" 1004 | }, 1005 | "default-branch": true, 1006 | "type": "library", 1007 | "extra": { 1008 | "branch-alias": { 1009 | "dev-main": "5.1-dev" 1010 | } 1011 | }, 1012 | "autoload": { 1013 | "classmap": [ 1014 | "src/" 1015 | ] 1016 | }, 1017 | "notification-url": "https://packagist.org/downloads/", 1018 | "license": [ 1019 | "BSD-3-Clause" 1020 | ], 1021 | "authors": [ 1022 | { 1023 | "name": "Sebastian Bergmann", 1024 | "email": "sebastian@phpunit.de" 1025 | }, 1026 | { 1027 | "name": "Kore Nordmann", 1028 | "email": "mail@kore-nordmann.de" 1029 | } 1030 | ], 1031 | "description": "Diff implementation", 1032 | "homepage": "https://github.com/sebastianbergmann/diff", 1033 | "keywords": [ 1034 | "diff", 1035 | "udiff", 1036 | "unidiff", 1037 | "unified diff" 1038 | ], 1039 | "support": { 1040 | "issues": "https://github.com/sebastianbergmann/diff/issues", 1041 | "security": "https://github.com/sebastianbergmann/diff/security/policy", 1042 | "source": "https://github.com/sebastianbergmann/diff/tree/main" 1043 | }, 1044 | "funding": [ 1045 | { 1046 | "url": "https://github.com/sebastianbergmann", 1047 | "type": "github" 1048 | } 1049 | ], 1050 | "time": "2023-11-05T08:36:52+00:00" 1051 | }, 1052 | { 1053 | "name": "sebastian/environment", 1054 | "version": "dev-main", 1055 | "source": { 1056 | "type": "git", 1057 | "url": "https://github.com/sebastianbergmann/environment.git", 1058 | "reference": "c44298b68abb6311674c0dc6e720c77022d7d1bf" 1059 | }, 1060 | "dist": { 1061 | "type": "zip", 1062 | "url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/c44298b68abb6311674c0dc6e720c77022d7d1bf", 1063 | "reference": "c44298b68abb6311674c0dc6e720c77022d7d1bf", 1064 | "shasum": "" 1065 | }, 1066 | "require": { 1067 | "php": ">=8.1" 1068 | }, 1069 | "require-dev": { 1070 | "phpunit/phpunit": "^10.0" 1071 | }, 1072 | "suggest": { 1073 | "ext-posix": "*" 1074 | }, 1075 | "default-branch": true, 1076 | "type": "library", 1077 | "extra": { 1078 | "branch-alias": { 1079 | "dev-main": "6.0-dev" 1080 | } 1081 | }, 1082 | "autoload": { 1083 | "classmap": [ 1084 | "src/" 1085 | ] 1086 | }, 1087 | "notification-url": "https://packagist.org/downloads/", 1088 | "license": [ 1089 | "BSD-3-Clause" 1090 | ], 1091 | "authors": [ 1092 | { 1093 | "name": "Sebastian Bergmann", 1094 | "email": "sebastian@phpunit.de" 1095 | } 1096 | ], 1097 | "description": "Provides functionality to handle HHVM/PHP environments", 1098 | "homepage": "https://github.com/sebastianbergmann/environment", 1099 | "keywords": [ 1100 | "Xdebug", 1101 | "environment", 1102 | "hhvm" 1103 | ], 1104 | "support": { 1105 | "issues": "https://github.com/sebastianbergmann/environment/issues", 1106 | "security": "https://github.com/sebastianbergmann/environment/security/policy", 1107 | "source": "https://github.com/sebastianbergmann/environment/tree/main" 1108 | }, 1109 | "funding": [ 1110 | { 1111 | "url": "https://github.com/sebastianbergmann", 1112 | "type": "github" 1113 | } 1114 | ], 1115 | "time": "2023-11-05T08:37:02+00:00" 1116 | }, 1117 | { 1118 | "name": "sebastian/exporter", 1119 | "version": "dev-main", 1120 | "source": { 1121 | "type": "git", 1122 | "url": "https://github.com/sebastianbergmann/exporter.git", 1123 | "reference": "f33ad9247fc3d4d3f03593aaddc8bd1cf3de49f4" 1124 | }, 1125 | "dist": { 1126 | "type": "zip", 1127 | "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/f33ad9247fc3d4d3f03593aaddc8bd1cf3de49f4", 1128 | "reference": "f33ad9247fc3d4d3f03593aaddc8bd1cf3de49f4", 1129 | "shasum": "" 1130 | }, 1131 | "require": { 1132 | "ext-mbstring": "*", 1133 | "php": ">=8.1", 1134 | "sebastian/recursion-context": "^5.0" 1135 | }, 1136 | "require-dev": { 1137 | "phpunit/phpunit": "^10.0" 1138 | }, 1139 | "default-branch": true, 1140 | "type": "library", 1141 | "extra": { 1142 | "branch-alias": { 1143 | "dev-main": "5.1-dev" 1144 | } 1145 | }, 1146 | "autoload": { 1147 | "classmap": [ 1148 | "src/" 1149 | ] 1150 | }, 1151 | "notification-url": "https://packagist.org/downloads/", 1152 | "license": [ 1153 | "BSD-3-Clause" 1154 | ], 1155 | "authors": [ 1156 | { 1157 | "name": "Sebastian Bergmann", 1158 | "email": "sebastian@phpunit.de" 1159 | }, 1160 | { 1161 | "name": "Jeff Welch", 1162 | "email": "whatthejeff@gmail.com" 1163 | }, 1164 | { 1165 | "name": "Volker Dusch", 1166 | "email": "github@wallbash.com" 1167 | }, 1168 | { 1169 | "name": "Adam Harvey", 1170 | "email": "aharvey@php.net" 1171 | }, 1172 | { 1173 | "name": "Bernhard Schussek", 1174 | "email": "bschussek@gmail.com" 1175 | } 1176 | ], 1177 | "description": "Provides the functionality to export PHP variables for visualization", 1178 | "homepage": "https://www.github.com/sebastianbergmann/exporter", 1179 | "keywords": [ 1180 | "export", 1181 | "exporter" 1182 | ], 1183 | "support": { 1184 | "issues": "https://github.com/sebastianbergmann/exporter/issues", 1185 | "security": "https://github.com/sebastianbergmann/exporter/security/policy", 1186 | "source": "https://github.com/sebastianbergmann/exporter/tree/main" 1187 | }, 1188 | "funding": [ 1189 | { 1190 | "url": "https://github.com/sebastianbergmann", 1191 | "type": "github" 1192 | } 1193 | ], 1194 | "time": "2023-11-05T08:37:10+00:00" 1195 | }, 1196 | { 1197 | "name": "sebastian/global-state", 1198 | "version": "dev-main", 1199 | "source": { 1200 | "type": "git", 1201 | "url": "https://github.com/sebastianbergmann/global-state.git", 1202 | "reference": "590126ff4f0145161d22f8ff9c98c05f305b26b0" 1203 | }, 1204 | "dist": { 1205 | "type": "zip", 1206 | "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/590126ff4f0145161d22f8ff9c98c05f305b26b0", 1207 | "reference": "590126ff4f0145161d22f8ff9c98c05f305b26b0", 1208 | "shasum": "" 1209 | }, 1210 | "require": { 1211 | "php": ">=8.1", 1212 | "sebastian/object-reflector": "^3.0", 1213 | "sebastian/recursion-context": "^5.0" 1214 | }, 1215 | "require-dev": { 1216 | "ext-dom": "*", 1217 | "phpunit/phpunit": "^10.0" 1218 | }, 1219 | "default-branch": true, 1220 | "type": "library", 1221 | "extra": { 1222 | "branch-alias": { 1223 | "dev-main": "6.0-dev" 1224 | } 1225 | }, 1226 | "autoload": { 1227 | "classmap": [ 1228 | "src/" 1229 | ] 1230 | }, 1231 | "notification-url": "https://packagist.org/downloads/", 1232 | "license": [ 1233 | "BSD-3-Clause" 1234 | ], 1235 | "authors": [ 1236 | { 1237 | "name": "Sebastian Bergmann", 1238 | "email": "sebastian@phpunit.de" 1239 | } 1240 | ], 1241 | "description": "Snapshotting of global state", 1242 | "homepage": "https://www.github.com/sebastianbergmann/global-state", 1243 | "keywords": [ 1244 | "global state" 1245 | ], 1246 | "support": { 1247 | "issues": "https://github.com/sebastianbergmann/global-state/issues", 1248 | "security": "https://github.com/sebastianbergmann/global-state/security/policy", 1249 | "source": "https://github.com/sebastianbergmann/global-state/tree/main" 1250 | }, 1251 | "funding": [ 1252 | { 1253 | "url": "https://github.com/sebastianbergmann", 1254 | "type": "github" 1255 | } 1256 | ], 1257 | "time": "2023-11-05T08:37:19+00:00" 1258 | }, 1259 | { 1260 | "name": "sebastian/lines-of-code", 1261 | "version": "dev-main", 1262 | "source": { 1263 | "type": "git", 1264 | "url": "https://github.com/sebastianbergmann/lines-of-code.git", 1265 | "reference": "f1f2f9b66dc50533707b4c8462ede7b7f59bfa34" 1266 | }, 1267 | "dist": { 1268 | "type": "zip", 1269 | "url": "https://api.github.com/repos/sebastianbergmann/lines-of-code/zipball/f1f2f9b66dc50533707b4c8462ede7b7f59bfa34", 1270 | "reference": "f1f2f9b66dc50533707b4c8462ede7b7f59bfa34", 1271 | "shasum": "" 1272 | }, 1273 | "require": { 1274 | "nikic/php-parser": "^4.10", 1275 | "php": ">=8.1" 1276 | }, 1277 | "require-dev": { 1278 | "phpunit/phpunit": "^10.0" 1279 | }, 1280 | "default-branch": true, 1281 | "type": "library", 1282 | "extra": { 1283 | "branch-alias": { 1284 | "dev-main": "2.0-dev" 1285 | } 1286 | }, 1287 | "autoload": { 1288 | "classmap": [ 1289 | "src/" 1290 | ] 1291 | }, 1292 | "notification-url": "https://packagist.org/downloads/", 1293 | "license": [ 1294 | "BSD-3-Clause" 1295 | ], 1296 | "authors": [ 1297 | { 1298 | "name": "Sebastian Bergmann", 1299 | "email": "sebastian@phpunit.de", 1300 | "role": "lead" 1301 | } 1302 | ], 1303 | "description": "Library for counting the lines of code in PHP source code", 1304 | "homepage": "https://github.com/sebastianbergmann/lines-of-code", 1305 | "support": { 1306 | "issues": "https://github.com/sebastianbergmann/lines-of-code/issues", 1307 | "security": "https://github.com/sebastianbergmann/lines-of-code/security/policy", 1308 | "source": "https://github.com/sebastianbergmann/lines-of-code/tree/main" 1309 | }, 1310 | "funding": [ 1311 | { 1312 | "url": "https://github.com/sebastianbergmann", 1313 | "type": "github" 1314 | } 1315 | ], 1316 | "time": "2023-11-05T08:37:29+00:00" 1317 | }, 1318 | { 1319 | "name": "sebastian/object-enumerator", 1320 | "version": "dev-main", 1321 | "source": { 1322 | "type": "git", 1323 | "url": "https://github.com/sebastianbergmann/object-enumerator.git", 1324 | "reference": "9df4cc1587cb14fbe0b4ec80d67b959daa00cdb7" 1325 | }, 1326 | "dist": { 1327 | "type": "zip", 1328 | "url": "https://api.github.com/repos/sebastianbergmann/object-enumerator/zipball/9df4cc1587cb14fbe0b4ec80d67b959daa00cdb7", 1329 | "reference": "9df4cc1587cb14fbe0b4ec80d67b959daa00cdb7", 1330 | "shasum": "" 1331 | }, 1332 | "require": { 1333 | "php": ">=8.1", 1334 | "sebastian/object-reflector": "^3.0", 1335 | "sebastian/recursion-context": "^5.0" 1336 | }, 1337 | "require-dev": { 1338 | "phpunit/phpunit": "^10.0" 1339 | }, 1340 | "default-branch": true, 1341 | "type": "library", 1342 | "extra": { 1343 | "branch-alias": { 1344 | "dev-main": "5.0-dev" 1345 | } 1346 | }, 1347 | "autoload": { 1348 | "classmap": [ 1349 | "src/" 1350 | ] 1351 | }, 1352 | "notification-url": "https://packagist.org/downloads/", 1353 | "license": [ 1354 | "BSD-3-Clause" 1355 | ], 1356 | "authors": [ 1357 | { 1358 | "name": "Sebastian Bergmann", 1359 | "email": "sebastian@phpunit.de" 1360 | } 1361 | ], 1362 | "description": "Traverses array structures and object graphs to enumerate all referenced objects", 1363 | "homepage": "https://github.com/sebastianbergmann/object-enumerator/", 1364 | "support": { 1365 | "issues": "https://github.com/sebastianbergmann/object-enumerator/issues", 1366 | "security": "https://github.com/sebastianbergmann/object-enumerator/security/policy", 1367 | "source": "https://github.com/sebastianbergmann/object-enumerator/tree/main" 1368 | }, 1369 | "funding": [ 1370 | { 1371 | "url": "https://github.com/sebastianbergmann", 1372 | "type": "github" 1373 | } 1374 | ], 1375 | "time": "2023-11-05T08:37:38+00:00" 1376 | }, 1377 | { 1378 | "name": "sebastian/object-reflector", 1379 | "version": "dev-main", 1380 | "source": { 1381 | "type": "git", 1382 | "url": "https://github.com/sebastianbergmann/object-reflector.git", 1383 | "reference": "739a9d56bc84a5fd028f566d08bd36bc712d848e" 1384 | }, 1385 | "dist": { 1386 | "type": "zip", 1387 | "url": "https://api.github.com/repos/sebastianbergmann/object-reflector/zipball/739a9d56bc84a5fd028f566d08bd36bc712d848e", 1388 | "reference": "739a9d56bc84a5fd028f566d08bd36bc712d848e", 1389 | "shasum": "" 1390 | }, 1391 | "require": { 1392 | "php": ">=8.1" 1393 | }, 1394 | "require-dev": { 1395 | "phpunit/phpunit": "^10.0" 1396 | }, 1397 | "default-branch": true, 1398 | "type": "library", 1399 | "extra": { 1400 | "branch-alias": { 1401 | "dev-main": "3.0-dev" 1402 | } 1403 | }, 1404 | "autoload": { 1405 | "classmap": [ 1406 | "src/" 1407 | ] 1408 | }, 1409 | "notification-url": "https://packagist.org/downloads/", 1410 | "license": [ 1411 | "BSD-3-Clause" 1412 | ], 1413 | "authors": [ 1414 | { 1415 | "name": "Sebastian Bergmann", 1416 | "email": "sebastian@phpunit.de" 1417 | } 1418 | ], 1419 | "description": "Allows reflection of object attributes, including inherited and non-public ones", 1420 | "homepage": "https://github.com/sebastianbergmann/object-reflector/", 1421 | "support": { 1422 | "issues": "https://github.com/sebastianbergmann/object-reflector/issues", 1423 | "security": "https://github.com/sebastianbergmann/object-reflector/security/policy", 1424 | "source": "https://github.com/sebastianbergmann/object-reflector/tree/main" 1425 | }, 1426 | "funding": [ 1427 | { 1428 | "url": "https://github.com/sebastianbergmann", 1429 | "type": "github" 1430 | } 1431 | ], 1432 | "time": "2023-11-05T08:37:46+00:00" 1433 | }, 1434 | { 1435 | "name": "sebastian/recursion-context", 1436 | "version": "dev-main", 1437 | "source": { 1438 | "type": "git", 1439 | "url": "https://github.com/sebastianbergmann/recursion-context.git", 1440 | "reference": "5831b46c3858c0afcc8b0135821e3a9d2dcb924e" 1441 | }, 1442 | "dist": { 1443 | "type": "zip", 1444 | "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/5831b46c3858c0afcc8b0135821e3a9d2dcb924e", 1445 | "reference": "5831b46c3858c0afcc8b0135821e3a9d2dcb924e", 1446 | "shasum": "" 1447 | }, 1448 | "require": { 1449 | "php": ">=8.1" 1450 | }, 1451 | "require-dev": { 1452 | "phpunit/phpunit": "^10.0" 1453 | }, 1454 | "default-branch": true, 1455 | "type": "library", 1456 | "extra": { 1457 | "branch-alias": { 1458 | "dev-main": "5.0-dev" 1459 | } 1460 | }, 1461 | "autoload": { 1462 | "classmap": [ 1463 | "src/" 1464 | ] 1465 | }, 1466 | "notification-url": "https://packagist.org/downloads/", 1467 | "license": [ 1468 | "BSD-3-Clause" 1469 | ], 1470 | "authors": [ 1471 | { 1472 | "name": "Sebastian Bergmann", 1473 | "email": "sebastian@phpunit.de" 1474 | }, 1475 | { 1476 | "name": "Jeff Welch", 1477 | "email": "whatthejeff@gmail.com" 1478 | }, 1479 | { 1480 | "name": "Adam Harvey", 1481 | "email": "aharvey@php.net" 1482 | } 1483 | ], 1484 | "description": "Provides functionality to recursively process PHP variables", 1485 | "homepage": "https://github.com/sebastianbergmann/recursion-context", 1486 | "support": { 1487 | "issues": "https://github.com/sebastianbergmann/recursion-context/issues", 1488 | "security": "https://github.com/sebastianbergmann/recursion-context/security/policy", 1489 | "source": "https://github.com/sebastianbergmann/recursion-context/tree/main" 1490 | }, 1491 | "funding": [ 1492 | { 1493 | "url": "https://github.com/sebastianbergmann", 1494 | "type": "github" 1495 | } 1496 | ], 1497 | "time": "2023-11-05T08:38:30+00:00" 1498 | }, 1499 | { 1500 | "name": "sebastian/type", 1501 | "version": "dev-main", 1502 | "source": { 1503 | "type": "git", 1504 | "url": "https://github.com/sebastianbergmann/type.git", 1505 | "reference": "791d72a91e5547cba1f8d19cc6594150c11e7d1a" 1506 | }, 1507 | "dist": { 1508 | "type": "zip", 1509 | "url": "https://api.github.com/repos/sebastianbergmann/type/zipball/791d72a91e5547cba1f8d19cc6594150c11e7d1a", 1510 | "reference": "791d72a91e5547cba1f8d19cc6594150c11e7d1a", 1511 | "shasum": "" 1512 | }, 1513 | "require": { 1514 | "php": ">=8.1" 1515 | }, 1516 | "require-dev": { 1517 | "phpunit/phpunit": "^10.0" 1518 | }, 1519 | "default-branch": true, 1520 | "type": "library", 1521 | "extra": { 1522 | "branch-alias": { 1523 | "dev-main": "4.0-dev" 1524 | } 1525 | }, 1526 | "autoload": { 1527 | "classmap": [ 1528 | "src/" 1529 | ] 1530 | }, 1531 | "notification-url": "https://packagist.org/downloads/", 1532 | "license": [ 1533 | "BSD-3-Clause" 1534 | ], 1535 | "authors": [ 1536 | { 1537 | "name": "Sebastian Bergmann", 1538 | "email": "sebastian@phpunit.de", 1539 | "role": "lead" 1540 | } 1541 | ], 1542 | "description": "Collection of value objects that represent the types of the PHP type system", 1543 | "homepage": "https://github.com/sebastianbergmann/type", 1544 | "support": { 1545 | "issues": "https://github.com/sebastianbergmann/type/issues", 1546 | "security": "https://github.com/sebastianbergmann/type/security/policy", 1547 | "source": "https://github.com/sebastianbergmann/type/tree/main" 1548 | }, 1549 | "funding": [ 1550 | { 1551 | "url": "https://github.com/sebastianbergmann", 1552 | "type": "github" 1553 | } 1554 | ], 1555 | "time": "2023-11-05T08:38:39+00:00" 1556 | }, 1557 | { 1558 | "name": "sebastian/version", 1559 | "version": "dev-main", 1560 | "source": { 1561 | "type": "git", 1562 | "url": "https://github.com/sebastianbergmann/version.git", 1563 | "reference": "e24af053f2e9d14ce405de79befb044d23201c5a" 1564 | }, 1565 | "dist": { 1566 | "type": "zip", 1567 | "url": "https://api.github.com/repos/sebastianbergmann/version/zipball/e24af053f2e9d14ce405de79befb044d23201c5a", 1568 | "reference": "e24af053f2e9d14ce405de79befb044d23201c5a", 1569 | "shasum": "" 1570 | }, 1571 | "require": { 1572 | "php": ">=8.1" 1573 | }, 1574 | "default-branch": true, 1575 | "type": "library", 1576 | "extra": { 1577 | "branch-alias": { 1578 | "dev-main": "4.0-dev" 1579 | } 1580 | }, 1581 | "autoload": { 1582 | "classmap": [ 1583 | "src/" 1584 | ] 1585 | }, 1586 | "notification-url": "https://packagist.org/downloads/", 1587 | "license": [ 1588 | "BSD-3-Clause" 1589 | ], 1590 | "authors": [ 1591 | { 1592 | "name": "Sebastian Bergmann", 1593 | "email": "sebastian@phpunit.de", 1594 | "role": "lead" 1595 | } 1596 | ], 1597 | "description": "Library that helps with managing the version number of Git-hosted PHP projects", 1598 | "homepage": "https://github.com/sebastianbergmann/version", 1599 | "support": { 1600 | "issues": "https://github.com/sebastianbergmann/version/issues", 1601 | "security": "https://github.com/sebastianbergmann/version/security/policy", 1602 | "source": "https://github.com/sebastianbergmann/version/tree/main" 1603 | }, 1604 | "funding": [ 1605 | { 1606 | "url": "https://github.com/sebastianbergmann", 1607 | "type": "github" 1608 | } 1609 | ], 1610 | "time": "2023-11-05T08:38:48+00:00" 1611 | }, 1612 | { 1613 | "name": "theseer/tokenizer", 1614 | "version": "1.2.1", 1615 | "source": { 1616 | "type": "git", 1617 | "url": "https://github.com/theseer/tokenizer.git", 1618 | "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e" 1619 | }, 1620 | "dist": { 1621 | "type": "zip", 1622 | "url": "https://api.github.com/repos/theseer/tokenizer/zipball/34a41e998c2183e22995f158c581e7b5e755ab9e", 1623 | "reference": "34a41e998c2183e22995f158c581e7b5e755ab9e", 1624 | "shasum": "" 1625 | }, 1626 | "require": { 1627 | "ext-dom": "*", 1628 | "ext-tokenizer": "*", 1629 | "ext-xmlwriter": "*", 1630 | "php": "^7.2 || ^8.0" 1631 | }, 1632 | "type": "library", 1633 | "autoload": { 1634 | "classmap": [ 1635 | "src/" 1636 | ] 1637 | }, 1638 | "notification-url": "https://packagist.org/downloads/", 1639 | "license": [ 1640 | "BSD-3-Clause" 1641 | ], 1642 | "authors": [ 1643 | { 1644 | "name": "Arne Blankerts", 1645 | "email": "arne@blankerts.de", 1646 | "role": "Developer" 1647 | } 1648 | ], 1649 | "description": "A small library for converting tokenized PHP source code into XML and potentially other formats", 1650 | "support": { 1651 | "issues": "https://github.com/theseer/tokenizer/issues", 1652 | "source": "https://github.com/theseer/tokenizer/tree/1.2.1" 1653 | }, 1654 | "funding": [ 1655 | { 1656 | "url": "https://github.com/theseer", 1657 | "type": "github" 1658 | } 1659 | ], 1660 | "time": "2021-07-28T10:34:58+00:00" 1661 | } 1662 | ], 1663 | "aliases": [], 1664 | "minimum-stability": "dev", 1665 | "stability-flags": [], 1666 | "prefer-stable": false, 1667 | "prefer-lowest": false, 1668 | "platform": [], 1669 | "platform-dev": [], 1670 | "plugin-api-version": "2.6.0" 1671 | } 1672 | -------------------------------------------------------------------------------- /conf/podsumer.conf: -------------------------------------------------------------------------------- 1 | [podsumer] 2 | user = josh 3 | pass = josh 4 | template_dir = templates 5 | default_page_title = podsumer 6 | language = en_us 7 | base_template = base 8 | state_file = state/podsumer.sqlite3 9 | sql_dir = sql 10 | per_item_art_download = 33 11 | 12 | # File Storage Method 13 | # 14 | # By default all data is stored in the database. If you would prefer 15 | # to have your audio files and artwork stored on disk set this to 16 | # `true` and specify a path where you would like podsumer to save 17 | # files. If you have a very large libary this should improve the 18 | # performance and size of backups. 19 | # 20 | # NOTE: If this value is changed after a library is already established, 21 | # only _new_ items and feeds will use the new storage backend. Previously 22 | # downloaded files will remain where they are. 23 | 24 | store_media_on_disk = true 25 | 26 | # Make sure podsumer has permission to read and write in this directory. 27 | # media_dir cannot be changed once library is established. If changed, some media 28 | # files may no longer be servable. 29 | 30 | media_dir = /opt/media 31 | 32 | -------------------------------------------------------------------------------- /conf/test.conf: -------------------------------------------------------------------------------- 1 | [podsumer] 2 | user = user 3 | pass = s3cr3t 4 | template_dir = templates/tests 5 | default_page_title = podsumer 6 | language = en_us 7 | base_template = base 8 | state_file = state/podsumer-test.sqlite3 9 | sql_dir = sql 10 | per_item_art_download = 33 11 | 12 | media_dir = /opt/media 13 | -------------------------------------------------------------------------------- /conf/test_bad.conf: -------------------------------------------------------------------------------- 1 | 'junk"asd'as 2 | -------------------------------------------------------------------------------- /conf/test_empty.conf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leftouterjoins/podsumer/631313aac3740cb66e374b8cf22856b89c0716df/conf/test_empty.conf -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.8" 2 | services: 3 | podsumer: 4 | image: podsumer 5 | container_name: podsumer 6 | build: 7 | context: . 8 | volumes: 9 | - ./:/opt/podsumer 10 | - ./state/media/:/opt/media 11 | ports: 12 | - 3095:3094 13 | 14 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | 17 | tests 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /screenshots/episode.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leftouterjoins/podsumer/631313aac3740cb66e374b8cf22856b89c0716df/screenshots/episode.png -------------------------------------------------------------------------------- /screenshots/feed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leftouterjoins/podsumer/631313aac3740cb66e374b8cf22856b89c0716df/screenshots/feed.png -------------------------------------------------------------------------------- /screenshots/feeds.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/leftouterjoins/podsumer/631313aac3740cb66e374b8cf22856b89c0716df/screenshots/feeds.png -------------------------------------------------------------------------------- /sql/tables.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `file_contents` ( 2 | id INTEGER PRIMARY KEY AUTOINCREMENT, 3 | content_hash TEXT NOT NULL UNIQUE, 4 | data BLOB 5 | ); 6 | 7 | CREATE TABLE IF NOT EXISTS `files` ( 8 | id INTEGER PRIMARY KEY AUTOINCREMENT, 9 | url TEXT NOT NULL, 10 | url_hash TEXT NOT NULL UNIQUE, 11 | filename TEXT NOT NULL, 12 | cached DATETIME NOT NULL, 13 | size INTEGER NOT NULL, 14 | mimetype TEXT NOT NULL, 15 | content_hash TEXT NOT NULL, 16 | content_id INTEGER NOT NULL, 17 | CONSTRAINT one FOREIGN KEY (content_id) REFERENCES file_contents(id) ON DELETE CASCADE 18 | ); 19 | 20 | CREATE TABLE IF NOT EXISTS `feeds` ( 21 | id INTEGER PRIMARY KEY AUTOINCREMENT, 22 | url_hash TEXT NOT NULL UNIQUE, 23 | name TEXT NOT NULL, 24 | last_update DATETIME NOT NULL, 25 | url TEXT NOT NULL, 26 | description TEXT, 27 | image INTEGER 28 | ); 29 | 30 | CREATE TABLE IF NOT EXISTS `items` ( 31 | id INTEGER PRIMARY KEY AUTOINCREMENT, 32 | guid TEXT UNIQUE, 33 | feed_id INTEGER NOT NULL REFERENCES feeds(id) ON DELETE CASCADE, 34 | name TEXT NOT NULL, 35 | published DATETIME NOT NULL, 36 | description TEXT, 37 | size INTEGER NOT NULL, 38 | audio_url TEXT NOT NULL, 39 | audio_file INTEGER NULL, 40 | image INTEGER NULL 41 | ); 42 | 43 | CREATE TABLE IF NOT EXISTS `versions` ( 44 | version INTEGER NULL 45 | ); 46 | 47 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/Config.php: -------------------------------------------------------------------------------- 1 | path = $config_path; 17 | 18 | if (!file_exists($this->path)) { 19 | throw new \Exception('Config file not found at ' . $this->path . '.'); 20 | } 21 | 22 | $parsed = $this->parseConfig($this->path); 23 | if (false === $parsed) { 24 | throw new \Exception('Config file at ' . $this->path . ' is not valid.'); 25 | } 26 | 27 | $this->config = $parsed; 28 | 29 | if (empty($this->config)) { 30 | throw new \Exception('Config file at ' . $this->path . ' is empty.'); 31 | } 32 | } 33 | 34 | public function get(string $key1, ?string $key2 = null): mixed 35 | { 36 | if (null !== $key2) { 37 | return $this->config[$key1][$key2] ?? null; 38 | } else { 39 | return $this->config[$key1] ?? null; 40 | } 41 | } 42 | 43 | public function set(mixed $value, string $key1, ?string $key2 = null): void 44 | { 45 | if (null !== $key2) { 46 | $this->config[$key1][$key2] = $value;; 47 | } else { 48 | $this->config[$key1] = $value; 49 | } 50 | } 51 | 52 | protected function parseConfig($path): mixed 53 | { 54 | error_clear_last(); 55 | 56 | # '@' is used to suppress warnings about parse issues. 57 | # We will throw an exception if the file is not valid. 58 | $result = @parse_ini_file( 59 | $path, 60 | true, 61 | INI_SCANNER_TYPED 62 | ); 63 | 64 | if (false === $result) { 65 | $error = error_get_last(); 66 | throw new Exception('Config file at ' . $path . ' is not valid: ' . ($error['message'] ?? '') . '.'); 67 | } 68 | 69 | return $result; 70 | } 71 | } 72 | 73 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/FSState.php: -------------------------------------------------------------------------------- 1 | main->getConf('podsumer', 'media_dir'); 13 | } 14 | 15 | public function getFeedDir($name): string 16 | { 17 | return $this->getMediaDir() . DIRECTORY_SEPARATOR . $this->escapeFilename($name); 18 | } 19 | 20 | protected function addFileContents(string $content_hash, string $contents, ?string $filename = null, ?array $feed = null): int 21 | { 22 | # Get configured media directory. 23 | $media_dir = $this->getMediaDir(); 24 | 25 | # Check permissions of root media directory. 26 | if (!is_writable($media_dir)) { 27 | 28 | if (!file_exists($media_dir)) { 29 | 30 | error_clear_last(); 31 | 32 | $made_dir = mkdir($media_dir, 0755, true); 33 | 34 | $error = error_get_last(); 35 | if (!empty($error)) { 36 | $message = "Cannot create media directory at: $media_dir"; 37 | throw new Exception($message); 38 | } 39 | 40 | if (!$made_dir) { 41 | $message = "Cannot write to media directory at: $media_dir"; 42 | throw new Exception($message); 43 | } 44 | } 45 | 46 | $modified_perms = chmod($media_dir, 0755); 47 | if (false === $modified_perms) { 48 | $message = "Cannot modify permissions of media directory at: $media_dir"; 49 | throw new Exception($message); 50 | } 51 | } 52 | 53 | # Create dir for feed if needed 54 | $feed_dir = $this->getFeedDir($feed['name']); 55 | if (!file_exists($feed_dir)) { 56 | error_clear_last(); 57 | 58 | @mkdir($feed_dir, 0755, true); 59 | $error = error_get_last(); 60 | if (!empty($error)) { 61 | $message = "Cannot create feed directory at: $feed_dir"; 62 | throw new Exception($message); 63 | } 64 | 65 | } 66 | 67 | # Write file to disk along with image file 68 | $file_path = $feed_dir . DIRECTORY_SEPARATOR . $filename; 69 | 70 | error_clear_last(); 71 | $written = @file_put_contents($file_path, $contents); 72 | 73 | $error = error_get_last(); 74 | 75 | if (!$written || !empty($error)) { 76 | $message = "Cannot write to media to file at: $file_path"; 77 | throw new Exception($message); 78 | } 79 | 80 | return parent::addFileContents($content_hash, $file_path, $filename, $feed); 81 | } 82 | 83 | protected function escapeFilename(string $filename): string 84 | { 85 | if ($filename === '.' || $filename === '..') { 86 | return ''; 87 | } 88 | 89 | $filename = str_replace('./', '', $filename); 90 | $filename = str_replace('../', '', $filename); 91 | $filename = str_replace('/', '', $filename); 92 | 93 | return $filename; 94 | } 95 | 96 | public function deleteFeed(int $feed_id) 97 | { 98 | $feed = $this->getFeed($feed_id); 99 | $file_id = $feed['image']; 100 | 101 | $file = $this->getFileById($file_id); 102 | 103 | if ($file['storage_mode'] == 'DISK' && file_exists($file['filename'])) { 104 | unlink($file['filename']); 105 | } 106 | 107 | $items = $this->getFeedItems($feed_id); 108 | foreach ($items as $item) { 109 | $file_id = $item['image']; 110 | 111 | if (!empty($file_id)) { 112 | 113 | $file = $this->getFileById($file_id); 114 | 115 | if ($file['storage_mode'] == 'DISK' && file_exists($file['filename'])) { 116 | unlink($file['filename']); 117 | } 118 | } 119 | 120 | $file_id = $item['audio_file']; 121 | 122 | if (!empty($file_id)) { # The audio for an item may not be downloaded. 123 | 124 | $file = $this->getFileById($file_id); 125 | 126 | if ($file['storage_mode'] == 'DISK' && file_exists($file['filename'])) { 127 | unlink($file['filename']); 128 | } 129 | } 130 | } 131 | 132 | # Delete feed dir. 133 | $feed_dir = $this->getFeedDir($feed['name']); 134 | if (file_exists($feed_dir)) { 135 | rmdir($feed_dir); 136 | } 137 | 138 | parent::deleteFeed($feed_id); 139 | } 140 | 141 | public function deleteItemMedia(int $item_id) 142 | { 143 | $item = $this->getFeedItem($item_id); 144 | 145 | $file_id = $item['audio_file']; 146 | 147 | if (empty($file_id)) { 148 | return; 149 | } 150 | 151 | $file = $this->getFileById($file_id); 152 | 153 | if ($file['storage_mode'] == 'DISK' && file_exists($file['filename'])) { 154 | unlink($file['filename']); 155 | } 156 | 157 | parent::deleteItemMedia($item_id); 158 | } 159 | } 160 | 161 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/Feed.php: -------------------------------------------------------------------------------- 1 | validateUrl($url); 22 | 23 | if (false === $valid_url) { 24 | throw new Exception("Invalid feed URL: $url"); 25 | } 26 | 27 | $this->url = $url; 28 | 29 | $this->fetchFeed(); 30 | } 31 | 32 | protected function fetchFeed() 33 | { 34 | $feed_contents = File::downloadUrl($this->url); 35 | $this->hash = md5($feed_contents); 36 | $this->parseFeed($feed_contents); 37 | } 38 | 39 | protected function validateUrl(string $url) 40 | { 41 | $scheme = parse_url($url, PHP_URL_SCHEME); 42 | if (false === $scheme || is_null($scheme) || !str_contains($scheme, 'http')) { 43 | return false; 44 | } 45 | 46 | return true; 47 | } 48 | 49 | protected function parseFeed(string $feed_contents): void 50 | { 51 | $parse_result = simplexml_load_string( 52 | $feed_contents, 53 | SimpleXMLElement::class, 54 | LIBXML_NOCDATA | LIBXML_NOERROR | LIBXML_NOWARNING | LIBXML_NONET 55 | ); 56 | 57 | if (false === $parse_result) { 58 | $this->feed = null; 59 | return; 60 | } 61 | 62 | $this->feed = $parse_result; 63 | } 64 | 65 | public function feedLoaded(): bool 66 | { 67 | return !empty($this->feed); 68 | } 69 | 70 | public function getTitle(): string 71 | { 72 | return strval($this->feed->channel->title); 73 | } 74 | 75 | public function getLastUpdated(): DateTime 76 | { 77 | $lastUpdated = strval($this->feed->channel->pubDate); 78 | $lastUpdated = $lastUpdated ?: strval($this->feed->channel->lastBuildDate); 79 | 80 | return new DateTime(trim($lastUpdated)); 81 | } 82 | 83 | public function getDescription(): string 84 | { 85 | return strval($this->feed->channel->description); 86 | } 87 | 88 | public function getImage(): string 89 | { 90 | $image = $this->feed->channel->children('itunes', true)->image; 91 | $href = ''; 92 | if (!empty($image)) { 93 | $href = strval($image->attributes()->href); 94 | return $href; 95 | } 96 | 97 | return strval($this->feed->channel->image->url); 98 | } 99 | 100 | public function getUrl(): string 101 | { 102 | return $this->url; 103 | } 104 | 105 | public function getUrlHash(): string 106 | { 107 | return md5($this->url); 108 | } 109 | 110 | public function getFeedItems(): SimpleXMLElement 111 | { 112 | return $this->feed->channel->item; 113 | } 114 | 115 | public function setFeedId(int $feed_id) 116 | { 117 | $this->feed_id = $feed_id; 118 | } 119 | 120 | public function getFeedId(): int|null 121 | { 122 | return isset($this->feed_id) ? $this->feed_id : null; 123 | } 124 | } 125 | 126 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/File.php: -------------------------------------------------------------------------------- 1 | main = $main; 16 | } 17 | 18 | public function cacheUrl(string $url, array $feed): int|null 19 | { 20 | $url_hash = $this->hashUrl($url); 21 | $cached = $this->cacheForHash($url_hash); 22 | 23 | if (empty($cached)) { 24 | $file_contents = self::downloadUrl($url); 25 | $file_id = $this->main->getState()->addFile($url, $file_contents, $feed); 26 | } else { 27 | $file_id = $cached['id']; 28 | } 29 | 30 | return $file_id; 31 | } 32 | 33 | public function hashUrl(string $url) 34 | { 35 | return md5($url); 36 | } 37 | 38 | public function cacheForId(int $file_id): array 39 | { 40 | return $this->main->getState()->getFileById($file_id) ?? []; 41 | } 42 | 43 | public function cacheForHash(string $url_hash): array 44 | { 45 | return $this->main->getState()->getFileByUrlHash($url_hash) ?? []; 46 | } 47 | 48 | public static function downloadUrl($url, $user = null, $pass = null): string 49 | { 50 | $curl = curl_init(); 51 | 52 | curl_setopt($curl, \CURLOPT_URL, $url); 53 | curl_setopt($curl, \CURLOPT_RETURNTRANSFER, true); 54 | curl_setopt($curl, \CURLOPT_FOLLOWLOCATION, true); 55 | curl_setopt($curl, \CURLOPT_CONNECTTIMEOUT, 300); 56 | curl_setopt($curl, \CURLOPT_MAXREDIRS, 10); 57 | 58 | if (!empty($user) && !empty($pass)) { 59 | curl_setopt($curl,\CURLOPT_USERPWD, "$user:$pass"); 60 | curl_setopt($curl, \CURLOPT_HTTPAUTH, \CURLAUTH_ANY); 61 | } 62 | 63 | $url_contents = curl_exec($curl); 64 | if (false === $url_contents) { 65 | throw new Exception('Cannot download url: ' . curl_error($curl)); 66 | } 67 | 68 | return $url_contents; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/Item.php: -------------------------------------------------------------------------------- 1 | main = $main; 17 | $this->item = $item; 18 | $this->feed = $feed; 19 | } 20 | 21 | public function getFeedId(): int 22 | { 23 | return $this->feed->getFeedId(); 24 | } 25 | 26 | public function getGuid(): string 27 | { 28 | return strval($this->item->guid); 29 | } 30 | 31 | public function getName(): string 32 | { 33 | return strval($this->item->title); 34 | } 35 | 36 | public function getPublished(): DateTime 37 | { 38 | $published = strval($this->item->pubDate); 39 | $published = $published ?: date('c'); 40 | 41 | return new DateTime($published); 42 | } 43 | 44 | public function getDescription(): string 45 | { 46 | return strval($this->item->description); 47 | } 48 | 49 | public function getSize(): int 50 | { 51 | $size = 0; 52 | $enclosure_attrs = $this->item->enclosure->attributes(); 53 | if (!empty($enclosure_attrs)) { 54 | $size = intval($enclosure_attrs->length); 55 | } 56 | 57 | return $size; 58 | } 59 | 60 | public function getAudioFileUrl(): string 61 | { 62 | $media_url = ''; 63 | $enclosure_attrs = $this->item->enclosure->attributes(); 64 | if (!empty($enclosure_attrs)) { 65 | $media_url = strval($enclosure_attrs->url); 66 | } 67 | 68 | return $media_url; 69 | } 70 | 71 | public function getImage(): string|bool 72 | { 73 | $image = $this->item->children('itunes', true)->image; 74 | $href = ''; 75 | if (!empty($image)) { 76 | $href = strval($image->attributes()->href); 77 | return $href; 78 | } 79 | 80 | return false; 81 | } 82 | } 83 | 84 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/Logs.php: -------------------------------------------------------------------------------- 1 | main = $main; 17 | $this->stdout = fopen('php://stdout', 'w'); 18 | $this->stderr = fopen('php://stderr', 'w'); 19 | } 20 | 21 | public function __destruct() 22 | { 23 | fclose($this->stdout); 24 | fclose($this->stderr); 25 | } 26 | 27 | public function log(?string $message): void 28 | { 29 | $log = [ 30 | 'class' => 'info', 31 | 'time' => date('Y-m-d H:i:s'), 32 | 'host' => $this->main->getHost(), 33 | 'method' => $this->main->getMethod(), 34 | 'response_code' => $this->main->getResponseCode(), 35 | 'url' => $this->main->getUrl(), 36 | 'ip' => $this->main->getRemoteAddress(), 37 | 'args' => json_encode($this->main->getArgs()), 38 | 'message' => $message 39 | ]; 40 | 41 | $this->writeLog($log); 42 | } 43 | 44 | public function accessLog(): void 45 | { 46 | $log = [ 47 | 'class' => 'access', 48 | 'time' => date('Y-m-d H:i:s'), 49 | 'host' => $this->main->getHost(), 50 | 'method' => $this->main->getMethod(), 51 | 'response_code' => $this->main->getResponseCode(), 52 | 'url' => $this->main->getUrl(), 53 | 'ip' => $this->main->getRemoteAddress(), 54 | 'args' => json_encode($this->main->getArgs()) 55 | ]; 56 | 57 | $this->writeLog($log); 58 | } 59 | 60 | public function exceptionLog(?\Exception $exception): void 61 | { 62 | $log = [ 63 | 'class' => 'exception', 64 | 'time' => date('Y-m-d H:i:s'), 65 | 'host' => $this->main->getHost(), 66 | 'method' => $this->main->getMethod(), 67 | 'response_code' => $this->main->getResponseCode(), 68 | 'url' => $this->main->getUrl(), 69 | 'ip' => $this->main->getRemoteAddress(), 70 | 'args' => json_encode($this->main->getArgs()), 71 | 'message' => $exception->getMessage(), 72 | 'code' => $exception->getCode(), 73 | 'file' => $exception->getFile(), 74 | 'line' => $exception->getLine(), 75 | 'trace' => str_replace("\n", "\t", $exception->getTraceAsString()) 76 | ]; 77 | 78 | $this->writeError($log); 79 | } 80 | 81 | protected function writeLog(array $message): void 82 | { 83 | $this->write($this->stdout, $message); 84 | } 85 | 86 | protected function writeError(array $message): void 87 | { 88 | $this->write($this->stdout, $message); 89 | } 90 | 91 | protected function write($f, array $message): void 92 | { 93 | if (!$this->main->getTestMode() && !fputcsv($f, $message)) { 94 | throw new \Exception('Failed to write to log.'); 95 | } 96 | } 97 | } 98 | 99 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/Main.php: -------------------------------------------------------------------------------- 1 | test_mode = $test_mode; 30 | $this->env = $env; 31 | $this->args = $request; 32 | $this->uploads = $files; 33 | 34 | $this->path = $path; 35 | $this->config = new Config($this->getConfigPath($test_mode)); 36 | $this->logs = new Logs($this); 37 | 38 | $this->user = $this->config->get('podsumer', 'user'); 39 | $this->pass = $this->config->get('podsumer', 'pass'); 40 | 41 | $this->sent_user = $_SERVER['PHP_AUTH_USER'] ?? null; 42 | $this->sent_pass = $_SERVER['PHP_AUTH_PW'] ?? null; 43 | 44 | if ($this->getConf('podsumer', 'store_media_on_disk')) { 45 | $this->state = new FSState($this); 46 | } else { 47 | $this->state = new State($this); 48 | } 49 | } 50 | 51 | protected function authenticate(): void 52 | { 53 | # If either user or pass is not set disable authentication. 54 | if (empty($this->user) || empty($this->pass)) { 55 | return; 56 | } 57 | 58 | if ( 59 | empty($this->sent_user) 60 | || empty($this->sent_pass) 61 | || $this->user !== $this->sent_user 62 | || $this->pass !== $this->sent_pass 63 | ) { 64 | header('WWW-Authenticate: Basic realm="Protected Area"'); 65 | header('HTTP/1.0 401 Unauthorized'); 66 | exit; 67 | } 68 | 69 | } 70 | 71 | public function run(): void 72 | { 73 | 74 | $route = (new Route( 75 | $this->getRoute(), 76 | $this->getMethod(), 77 | $this->getQueryParams(), 78 | ))->matchedRoute; 79 | 80 | if (empty($route)) { 81 | $this->setResponseCode(404); 82 | $this->logs->accessLog(); 83 | return; 84 | } 85 | 86 | if ($route[2] ?? false) { 87 | $this->authenticate(); 88 | } 89 | 90 | try { 91 | 92 | $args = $this->getArgs(); 93 | 94 | // Sanitize inputs to sidestep XSS. QPs in this app are only alpha-numeric anyway. 95 | $args = filter_var_array($args, \FILTER_SANITIZE_FULL_SPECIAL_CHARS); 96 | 97 | call_user_func($route[0], $args, $this); 98 | 99 | $this->logs->accessLog(); 100 | 101 | } catch (\Exception $e) { 102 | 103 | $this->setResponseCode(500); 104 | 105 | $this->logs->accessLog(); 106 | $this->logs->exceptionLog($e); 107 | 108 | echo $e->getMessage(); 109 | } 110 | } 111 | 112 | public function getUrl(): string 113 | { 114 | return $this->env['REQUEST_SCHEME'] 115 | . '://' 116 | . $this->env['HTTP_HOST'] 117 | . $this->env['REQUEST_URI']; 118 | } 119 | 120 | public function getBaseUrl(): string 121 | { 122 | return $this->env['REQUEST_SCHEME'] 123 | . '://' 124 | . $this->env['HTTP_HOST']; 125 | } 126 | 127 | public function getArg(string $key): mixed 128 | { 129 | return $this->args[$key]; 130 | } 131 | 132 | # public function getAuth(): string 133 | # { 134 | # if (empty($this->user) || empty($this->pass)) { 135 | # return ''; 136 | # } 137 | 138 | # $user = urlencode($this->user); 139 | # $pass = urlencode($this->pass); 140 | 141 | # # return "$user:$pass@"; 142 | # return ''; 143 | # } 144 | 145 | /** 146 | * @codeCoverageIgnore 147 | */ 148 | public function getHeaders(): array 149 | { 150 | return getallheaders(); 151 | } 152 | 153 | public function getUploads(): array 154 | { 155 | return $this->uploads; 156 | } 157 | 158 | public function getArgs(): array 159 | { 160 | return $this->args; 161 | } 162 | 163 | public function parseUrl(string $key): mixed 164 | { 165 | $url = $this->getUrl(); 166 | $parse = parse_url($url); 167 | 168 | return $parse[$key] ?? null; 169 | } 170 | 171 | public function getQueryParams(): array 172 | { 173 | $q = []; 174 | $query = $this->parseUrl('query') ?? ''; 175 | 176 | parse_str($query, $q); 177 | 178 | return $q; 179 | } 180 | 181 | public function getRoute(): string 182 | { 183 | return $this->parseUrl('path'); 184 | } 185 | 186 | public function getMethod(): string 187 | { 188 | return $this->env['REQUEST_METHOD']; 189 | } 190 | 191 | public function setResponseCode(int $code): void 192 | { 193 | http_response_code($code); 194 | } 195 | 196 | public function getResponseCode(): int 197 | { 198 | return http_response_code() ?: 0; 199 | } 200 | 201 | public function getHost(): string 202 | { 203 | return $this->env['HTTP_HOST']; 204 | } 205 | 206 | public function getRemoteAddress(): string 207 | { 208 | return $this->env['REMOTE_ADDR']; 209 | } 210 | 211 | public function getConfigPath($test_mode = false): string 212 | { 213 | return $test_mode 214 | ? $this->path . 'conf/test.conf' 215 | : $this->path . 'conf/podsumer.conf'; 216 | } 217 | 218 | public function getConf(string $key1, ?string $key2 = null): mixed 219 | { 220 | $f = $this->config->get($key1, $key2); 221 | return $f; 222 | } 223 | 224 | public function setConf(mixed $value, string $key1, ?string $key2 = null): void 225 | { 226 | $this->config->set($value, $key1, $key2); 227 | } 228 | 229 | public function log(string $message): void 230 | { 231 | $this->logs->log($message); 232 | } 233 | 234 | public function getState(): State 235 | { 236 | return $this->state; 237 | } 238 | 239 | public function setState(State $state): void 240 | { 241 | $this->state = $state; 242 | } 243 | 244 | public function getStateFilePath(): string 245 | { 246 | return $this->getInstallPath() 247 | . $this->getConf('podsumer', 'state_file'); 248 | } 249 | 250 | public function getInstallPath(): string 251 | { 252 | return $this->path; 253 | } 254 | 255 | public function setInstallPath(string $path): string 256 | { 257 | $this->path = $path; 258 | return $this->path; 259 | } 260 | 261 | /** 262 | * @codeCoverageIgnore 263 | */ 264 | public function getDbSize(): int 265 | { 266 | return filesize($this->getStateFilePath()); 267 | } 268 | 269 | /** 270 | * @codeCoverageIgnore 271 | */ 272 | public function redirect(string $path) 273 | { 274 | header("Location: $path"); 275 | exit(0); 276 | } 277 | 278 | public function getTestMode(): bool 279 | { 280 | return $this->test_mode; 281 | } 282 | } 283 | 284 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/OPML.php: -------------------------------------------------------------------------------- 1 | file = $file; 15 | } 16 | 17 | public static function parse(array $file): array 18 | { 19 | $opml = new self($file); 20 | 21 | return $opml->getFeeds(); 22 | } 23 | 24 | protected function getFeeds(): array 25 | { 26 | $opml = simplexml_load_file($this->file['tmp_name']); 27 | $body = $opml->body->outline; 28 | 29 | if (count($body) < 2) { 30 | $body = $body->outline; 31 | } 32 | 33 | $feeds = []; 34 | foreach ($body as $feed) { 35 | $feeds[] = strval($feed->attributes()->xmlUrl); 36 | } 37 | 38 | return $feeds; 39 | } 40 | } 41 | 42 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/Route.php: -------------------------------------------------------------------------------- 1 | collectDefinedRoutes(); 16 | 17 | $this->matchedRoute = $this->matchRoute($route, $method); 18 | } 19 | 20 | protected function collectDefinedRoutes() 21 | { 22 | foreach (get_defined_functions()['user'] as $fnName) { 23 | $ref = new \ReflectionFunction($fnName); 24 | foreach ($ref->getAttributes() as $attr) { 25 | $name = $attr->getName(); 26 | if ($name === 'Route') { 27 | $args = $attr->getArguments(); 28 | $this->routes[$args[0]] = [ 29 | $fnName, 30 | $args[1], 31 | $args[2] ?? false 32 | ]; 33 | } 34 | } 35 | } 36 | } 37 | 38 | protected function matchRoute(string $route, string $method): array 39 | { 40 | foreach ($this->routes as $definedRoute => $fn) { 41 | 42 | if ( 43 | $definedRoute === $route 44 | && ( 45 | ( 46 | is_array($fn[1]) 47 | && in_array($method, $fn[1]) 48 | ) 49 | || $fn[1] === $method 50 | ) 51 | ) { 52 | return $fn; 53 | } 54 | } 55 | 56 | return []; 57 | } 58 | } 59 | 60 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/State.php: -------------------------------------------------------------------------------- 1 | main = $main; 26 | $state_file_path = $this->main->getStateFilePath(); 27 | 28 | $state_dir = dirname($state_file_path); 29 | if (!is_dir($state_dir) && !mkdir($state_dir, 0755, true)) { 30 | throw new Exception("Cannot find or create the state directory: $state_dir"); 31 | } 32 | 33 | $this->state_file_path = $state_file_path; 34 | $this->sql_dir_path = $this->main->getInstallPath() 35 | . $this->main->getConf('podsumer', 'sql_dir'); 36 | 37 | $this->pdo = new PDO('sqlite:' . $this->state_file_path); 38 | $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); 39 | 40 | $this->optimizeSettings(); 41 | $this->checkDBInstall(); 42 | $this->checkDBVersion(); 43 | } 44 | 45 | protected function optimizeSettings() 46 | { 47 | $this->pdo->exec("PRAGMA journal_mode = WAL;"); 48 | $this->pdo->exec("PRAGMA synchronous = OFF;"); 49 | $this->pdo->exec("PRAGMA cache_size = -20000;"); 50 | $this->pdo->exec("PRAGMA foreign_keys = ON;"); 51 | $this->pdo->exec("PRAGMA temp_store = MEMORY;"); 52 | $this->pdo->exec('PRAGMA foreign_keys = ON'); 53 | } 54 | 55 | protected function installTables() 56 | { 57 | $table_sql = file_get_contents($this->sql_dir_path . '/tables.sql'); 58 | $this->pdo->exec($table_sql); 59 | } 60 | 61 | protected function checkDBInstall() 62 | { 63 | // Does the db file exist? 64 | if (!file_exists($this->state_file_path)) { 65 | throw new Exception('No DB file found at path: ' . $this->state_file_path); 66 | } 67 | 68 | // Do the tables expected exist? 69 | $this->installTables(); 70 | } 71 | 72 | protected function query(string $sql, array $params = []): array|bool 73 | { 74 | try { 75 | 76 | $stmt = $this->pdo->prepare($sql); 77 | $stmt->execute($params); 78 | 79 | return $stmt->fetchAll(); 80 | 81 | } catch (Exception $e) { 82 | $this->main->log($e->getMessage()); 83 | 84 | if ($this->main->getTestMode()) { 85 | throw $e; 86 | } 87 | 88 | 89 | return false; 90 | } 91 | } 92 | 93 | public function addFeed(Feed $feed): ?int 94 | { 95 | if (!$feed->feedLoaded()) { 96 | return 0; 97 | } 98 | 99 | $feed_url_hash = $feed->getUrlHash(); 100 | 101 | $feed_rec = []; 102 | $feed_rec['url_hash'] = $feed_url_hash; 103 | $feed_rec['url'] = $feed->getUrl(); 104 | $feed_rec['name'] = $feed->getTitle(); 105 | $feed_rec['last_update'] = $feed->getLastUpdated()->format('c'); 106 | $feed_rec['description'] = $feed->getDescription(); 107 | $feed_rec['image'] = $this->cacheFile($feed->getImage(), $feed_rec); 108 | $feed_rec['image_url'] = $feed->getImage(); 109 | 110 | $sql = 'INSERT INTO feeds (url_hash, name, last_update, url, description, image, image_url) VALUES (:url_hash, :name, :last_update, :url, :description, :image, :image_url) ON CONFLICT(url_hash) DO UPDATE SET id=id'; 111 | $this->query($sql, $feed_rec); 112 | $feed_id = $this->pdo->lastInsertId(); 113 | 114 | if ('0' !== $feed_id) { 115 | $feed->setFeedId(intval($feed_id)); 116 | } 117 | 118 | $items = $feed->getFeedItems(); 119 | $this->addFeedItems($items, $feed); 120 | 121 | return intval($feed_id); 122 | } 123 | 124 | protected function addFeedItems(\SimpleXMLElement $items, Feed $feed) 125 | { 126 | 127 | # Download custom image only for the first n episodes. 128 | 129 | $first_hundred = $this->main->getConf('podsumer', 'per_item_art_download') ?? 50; 130 | 131 | $feed_lookup = $this->getFeed($feed->getFeedId()); 132 | 133 | foreach ($items as $item) { 134 | $new_item = new Item($this->main, $item, $feed); 135 | $item_rec = [ 136 | 'feed_id' => $feed->getFeedId(), 137 | 'guid' => $new_item->getGuid(), 138 | 'name' => $new_item->getName(), 139 | 'published' => $new_item->getPublished()->format('c'), 140 | 'description' => $new_item->getDescription(), 141 | 'size' => $new_item->getSize(), 142 | 'audio_url' => $new_item->getAudioFileUrl(), 143 | 'image_url' => $new_item->getImage() ?: null, 144 | 'image' => ($first_hundred > 0) 145 | ? $this->cacheFile($new_item->getImage() ?: null, $feed_lookup) 146 | : null 147 | ]; 148 | 149 | $first_hundred--; 150 | 151 | $sql = 'INSERT INTO items (feed_id, guid, name, published, description, size, audio_url, image, image_url) VALUES (:feed_id, :guid, :name, :published, :description, :size, :audio_url, :image, :image_url) ON CONFLICT(guid) DO UPDATE SET name=:name, published=:published, description=:description, size=:size, audio_url=:audio_url, image=:image, image_url=:image_url'; 152 | $this->query($sql, $item_rec); 153 | } 154 | } 155 | 156 | public function cacheFile(string|null $url, array $feed): int|null 157 | { 158 | $this->main->log("Fetching $url"); 159 | 160 | if (!empty($url)) { 161 | $file = new File($this->main); 162 | return $file->cacheUrl($url, $feed); 163 | } 164 | 165 | return null; 166 | } 167 | 168 | public function getStateDirPath(): string 169 | { 170 | return dirname($this->state_file_path); 171 | } 172 | 173 | public function getFeeds(): array 174 | { 175 | $sql = 'SELECT id, name, last_update, url, description, image, image_url FROM feeds ORDER BY last_update DESC'; 176 | return $this->query($sql); 177 | } 178 | 179 | public function getFeed(int $id): array 180 | { 181 | $sql = 'SELECT id, name, description, url, image, image_url, last_update, url_hash FROM feeds WHERE id = :id'; 182 | return $this->query($sql, ['id' => $id])[0] ?? []; 183 | } 184 | 185 | public function getFeedForItem(int $item_id): array 186 | { 187 | $sql = 'SELECT feeds.id, feeds.name, feeds.description, feeds.url, feeds.image, feeds.image_url, feeds.last_update, feeds.url_hash FROM feeds JOIN items ON feeds.id = items.feed_id WHERE items.id = :id'; 188 | return $this->query($sql, ['id' => $item_id])[0] ?? []; 189 | } 190 | 191 | public function getFeedItem(int $item_id): array 192 | { 193 | $sql = 'SELECT items.name, items.feed_id, items.id, items.guid, items.audio_url, items.audio_file, COALESCE(items.image, feeds.image) AS image, items.size, items.published, items.description FROM items JOIN feeds ON feeds.id = items.feed_id WHERE items.id = :id ORDER BY items.published DESC'; 194 | return $this->query($sql, ['id' => $item_id])[0]; 195 | } 196 | 197 | public function getFeedItems(int $feed_id): array 198 | { 199 | $sql = 'SELECT items.name, items.feed_id, items.id, items.guid, items.audio_url, items.audio_file, COALESCE(items.image, feeds.image) AS image, items.size, items.published, items.description FROM items JOIN feeds ON feeds.id = items.feed_id WHERE items.feed_id = :id ORDER BY items.published DESC'; 200 | return $this->query($sql, ['id' => $feed_id]); 201 | } 202 | 203 | public function getFeedByHash(string $hash): array 204 | { 205 | $sql = 'SELECT id, name, last_update, url, description FROM feeds WHERE url_hash = :hash'; 206 | return $this->query($sql, ['hash' => $hash]); 207 | } 208 | 209 | public function getFileById(int $file_id): array 210 | { 211 | $sql = 'SELECT files.id, url, url_hash, mimetype, filename, size, cached, storage_mode, file_contents.content_hash, file_contents.data FROM files JOIN file_contents ON files.content_hash = file_contents.content_hash WHERE files.id = :file_id'; 212 | $file = $this->query($sql, ['file_id' => $file_id])[0] ?? []; 213 | 214 | if (!empty($file) && $file['storage_mode'] === 'DISK') { 215 | $filename = $file['data']; 216 | 217 | try { 218 | $file['data'] = $this->loadFile($filename); 219 | } catch (Exception $e) { 220 | 221 | } 222 | 223 | $file['filename'] = $filename; 224 | } 225 | 226 | return $file; 227 | } 228 | 229 | public function getFileByUrlHash(string $url_hash): array 230 | { 231 | $sql = 'SELECT files.id, url, url_hash, mimetype, filename, size, cached, storage_mode, file_contents.content_hash, file_contents.data FROM files JOIN file_contents ON files.content_hash = file_contents.content_hash WHERE url_hash = :url_hash'; 232 | $file = $this->query($sql, ['url_hash' => $url_hash])[0] ?? []; 233 | 234 | if (!empty($file) && $file['storage_mode'] === 'DISK') { 235 | $filename = $file['data']; 236 | 237 | try { 238 | $file['data'] = $this->loadFile($filename); 239 | } catch (Exception $e) { 240 | } 241 | 242 | $file['filename'] = $filename; 243 | } 244 | 245 | return $file; 246 | } 247 | 248 | 249 | public function addFile(string $url, string $contents, array $feed): int 250 | { 251 | $finfo = new \finfo(\FILEINFO_MIME); 252 | $mimetype = $finfo->buffer($contents); 253 | $content_hash = md5($contents); 254 | $filename = basename($url); 255 | 256 | $file = [ 257 | 'url' => $url, 258 | 'url_hash' => md5($url), 259 | 'filename' => $filename, 260 | 'mimetype' => $mimetype, 261 | 'size' => strlen($contents), 262 | 'cached' => time(), 263 | 'content_hash' => $content_hash, 264 | 'storage_mode' => ($this->main->getConf('podsumer', 'store_media_on_disk')) 265 | ? 'DISK' 266 | : 'DB' 267 | ]; 268 | 269 | $file['content_id'] = $this->addFileContents($content_hash, $contents, $filename, $feed); 270 | 271 | $sql = 'INSERT INTO files (url, url_hash, filename, size, cached, content_hash, mimetype, content_id, storage_mode) VALUES (:url, :url_hash, :filename, :size, :cached, :content_hash, :mimetype, :content_id, :storage_mode) ON CONFLICT(url_hash) DO UPDATE SET size=:size, cached=:cached, content_hash=:content_hash, mimetype=:mimetype, content_id=:content_id, storage_mode=:storage_mode'; 272 | $this->query($sql, $file); 273 | 274 | $sql = 'SELECT id FROM files WHERE content_hash = :content_hash'; 275 | $fid = $this->query($sql, ['content_hash' => $content_hash])[0]['id']; 276 | 277 | return intval($fid); 278 | } 279 | 280 | protected function addFileContents(string $content_hash, string $contents, ?string $filename = null, ?array $feed = null): int 281 | { 282 | $file_content = [ 283 | 'content_hash' => $content_hash, 284 | 'data' => $contents 285 | ]; 286 | 287 | $sql = 'INSERT INTO file_contents (content_hash, data) VALUES (:content_hash, :data) ON CONFLICT(content_hash) DO UPDATE SET id=id'; 288 | $this->query($sql, $file_content); 289 | 290 | $sql = 'SELECT id FROM file_contents WHERE content_hash = :content_hash'; 291 | $fcid = $this->query($sql, ['content_hash' => $content_hash])[0]['id']; 292 | 293 | return $fcid; 294 | } 295 | 296 | public function deleteFeed(int $feed_id) 297 | { 298 | $vars = ['feed_id' => $feed_id]; 299 | 300 | $sql = 'DELETE FROM file_contents WHERE id IN (SELECT content_id FROM feeds JOIN files ON feeds.image = files.id WHERE feeds.id = :feed_id)'; 301 | $this->query($sql, $vars); 302 | 303 | $sql = 'DELETE FROM file_contents WHERE id IN (SELECT content_id FROM items LEFT JOIN files ON items.image = files.id WHERE items.feed_id = :feed_id)'; 304 | $this->query($sql, $vars); 305 | 306 | $sql = 'DELETE FROM file_contents WHERE id IN (SELECT content_id FROM items LEFT JOIN files ON items.audio_file = files.id WHERE feed_id = :feed_id)'; 307 | $this->query($sql, $vars); 308 | 309 | $sql = 'DELETE FROM feeds WHERE id = :feed_id'; 310 | $this->query($sql, $vars); 311 | 312 | $this->query('VACUUM'); 313 | } 314 | 315 | public function deleteItemMedia(int $item_id) 316 | { 317 | $vars = ['item_id' => $item_id]; 318 | 319 | $sql = 'DELETE FROM file_contents WHERE id IN (SELECT content_id FROM items LEFT JOIN files ON items.audio_file = files.id WHERE items.id = :item_id)'; 320 | $this->query($sql, $vars); 321 | 322 | $sql = 'UPDATE items SET audio_file = NULL WHERE id = :item_id'; 323 | $this->query($sql, $vars); 324 | 325 | $this->query('VACUUM'); 326 | } 327 | 328 | public function setItemAudioFile(int $item_id, int $file_id) 329 | { 330 | $sql = 'UPDATE items SET audio_file = :file_id WHERE id=:id'; 331 | $this->query($sql, ['id' => $item_id, 'file_id' => $file_id]); 332 | } 333 | 334 | public function setItemImageFile(int $item_id, int $file_id) 335 | { 336 | $sql = 'UPDATE items SET image = :file_id WHERE id=:id'; 337 | $this->query($sql, ['id' => $item_id, 'file_id' => $file_id]); 338 | } 339 | 340 | public function setFeedImageFile(int $feed_id, int $file_id) 341 | { 342 | $sql = 'UPDATE feeds SET image = :file_id WHERE id=:id'; 343 | $this->query($sql, ['id' => $feed_id, 'file_id' => $file_id]); 344 | } 345 | 346 | protected function loadFile(string $filename): string 347 | { 348 | $contents = false; 349 | if (file_exists($filename)) { 350 | $contents = file_get_contents($filename); 351 | } 352 | 353 | if (!$contents) { 354 | throw new Exception("Could not open: $filename"); 355 | } 356 | 357 | return $contents; 358 | } 359 | 360 | public function getVersion(): int 361 | { 362 | return self::VERSION; 363 | } 364 | 365 | public function getLibrarySize(): int 366 | { 367 | $sql = 'SELECT SUM(size) AS `size` FROM files'; 368 | $size = $this->query($sql)[0]['size']; 369 | 370 | return intval($size); 371 | } 372 | } 373 | 374 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/TStateSchemaMigrations.php: -------------------------------------------------------------------------------- 1 | cur_version = intval($this->query('SELECT MAX(version) AS version FROM versions')[0]['version']) ?? 0; 19 | 20 | while (self::VERSION > $this->cur_version) { 21 | $new_version = $this->cur_version + 1; 22 | $upgradeFunc = $this->versions[$new_version]; 23 | 24 | if ($this->$upgradeFunc()) { 25 | $updated = $this->query("INSERT INTO versions (version) VALUES ($new_version)"); 26 | 27 | //@codeCoverageIgnoreStart 28 | if (false === $updated) { 29 | throw new Exception("Could set new DB version."); 30 | break; 31 | } 32 | //@codeCoverageIgnoreEnd 33 | 34 | $this->cur_version = $new_version; 35 | } else { 36 | //@codeCoverageIgnoreStart 37 | throw new Exception("Could not upgrade DB"); 38 | //@codeCoverageIgnoreEnd 39 | } 40 | } 41 | } 42 | 43 | public function addDiskStorage(): bool { 44 | 45 | $addStorageMode = $this->query("ALTER TABLE `files` ADD COLUMN storage_mode TEXT CHECK(storage_mode IN ('DB','DISK')) NOT NULL DEFAULT 'DB'"); 46 | $addFeedImageUrl = $this->query("ALTER TABLE `feeds` ADD COLUMN image_url"); 47 | $addItemImageUrl = $this->query("ALTER TABLE `items` ADD COLUMN image_url"); 48 | 49 | return $addStorageMode !== false && $addFeedImageUrl !== false && $addItemImageUrl !== false; 50 | } 51 | } 52 | 53 | -------------------------------------------------------------------------------- /src/Brickner/Podsumer/Template.php: -------------------------------------------------------------------------------- 1 | main = $main; 16 | $this->base_template = $this->main->getConf('podsumer', 'base_template'); 17 | $this->template_dir = $this->main->getConf('podsumer', 'template_dir'); 18 | } 19 | 20 | static public function render(Main $main, string $template_name, array $vars = []) 21 | { 22 | $t = new self($main); 23 | $base_template = $main->getConf('podsumer', 'base_template'); 24 | $t->renderTemplate($template_name, $vars, $base_template . '.html.php'); 25 | } 26 | 27 | static public function renderXml(Main $main, string $template_name, array $vars = []) 28 | { 29 | $t = new self($main); 30 | $t->renderTemplate('', $vars, $template_name . '.xml.php'); 31 | } 32 | 33 | protected function renderTemplate(string $template_name, array $vars, string $base_template) 34 | { 35 | $PAGE_TITLE = $this->main->getConf('podsumer', 'default_page_title'); 36 | $LANGUAGE = $this->main->getConf('podsumer', 'language'); 37 | 38 | $BODY = ''; 39 | if (!empty($template_name)) { 40 | $BODY = $this->getTemplatePath($template_name . '.html.php'); 41 | } 42 | 43 | $cleaned_vars = $this->encodeVars($vars); 44 | extract($cleaned_vars); 45 | $db_size = $this->main->getDbSize() + $this->main->getState()->getLibrarySize(); 46 | 47 | include($this->getTemplatePath($base_template)); 48 | } 49 | 50 | protected function getTemplatePath(string $template_name) 51 | { 52 | return $this->main->getInstallPath() . 53 | $this->template_dir . 54 | \DIRECTORY_SEPARATOR . 55 | $template_name; 56 | } 57 | 58 | protected function encodeVars(array $vars): array 59 | { 60 | array_walk_recursive($vars, function (&$var) { 61 | $var = strip_tags(strval($var), '

'); 62 | #$var = htmlentities(strip_tags(strval($var)), ENT_QUOTES | ENT_SUBSTITUTE | ENT_XML1, '', false); 63 | }); 64 | 65 | return $vars; 66 | } 67 | 68 | public function hyperlinkUrls(string $text): string 69 | { 70 | $reg = '/(http|https|ftp|ftps)\:\/\/[a-zA-Z0-9\-\.]+\.[a-zA-Z]{2,}(\/\S*)?/'; 71 | $formatText = preg_replace($reg, '$0', $text); 72 | 73 | $reg2 = '/(?<=\s|\A)([0-9a-zA-Z\-\.]+\.[a-zA-Z0-9\/]{2,})(?=\s|$|\,|\.)/'; 74 | $formatText = preg_replace($reg2, '$0', $formatText); 75 | 76 | $emailRegex = '/(\S+\@\S+\.\S+)\b/'; 77 | $formatText = preg_replace($emailRegex, '$1', $formatText); 78 | $formatText = nl2br($formatText); 79 | 80 | return $formatText; 81 | } 82 | } 83 | 84 | -------------------------------------------------------------------------------- /templates/base.html.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | <?= $PAGE_TITLE ?: 'Podsumer' ?> 5 | 6 | 7 | 8 | 9 | 10 |

11 |

12 | Feeds 13 |  |  14 | OPML 15 |  |  16 | GB 17 |

18 | 19 |
20 |
21 |

22 | 23 | Thank You for Listening With Podsumer 24 |
25 | 26 | If you find this open source project of value please consider 27 | sponsoring 28 | or contributing to 29 | further development. 30 | 31 | 32 |

33 |

34 |
35 | Released under the MIT License – Database version: main->getState()->getVersion(); ?> 36 |

37 |
38 | 39 | 40 | -------------------------------------------------------------------------------- /templates/feed.html.php: -------------------------------------------------------------------------------- 1 |
2 |

3 | 4 |

5 | 6 |

7 | RSS 8 |  |  9 | Refresh 10 |

11 | 12 | $item): ?> 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | MB 24 |  |  25 | 26 | 27 |  |  28 | Delete Audio 29 | 30 | 31 |
32 | 33 |
34 | 35 |
36 | -------------------------------------------------------------------------------- /templates/home.html.php: -------------------------------------------------------------------------------- 1 |
2 | 3 | 4 |
5 |

No Feeds

6 |
7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 |

16 | 17 | 18 | 19 |

20 |

21 | 22 | 23 |  |  24 | RSS 25 |  |  26 | Refresh 27 |  |  28 | Delete 29 | 30 |
31 | 32 |

33 |
34 | 35 | 36 | 37 |
38 |

Add Feed(s)

39 | Upload OPML: 40 | 41 |    42 | 43 |
44 |
45 | 46 | -------------------------------------------------------------------------------- /templates/item.html.php: -------------------------------------------------------------------------------- 1 |
2 | 3 |

4 | 5 |

6 | 7 |

8 | 9 |

10 | 11 |

12 | 13 |

14 | 15 |
16 | 17 | 21 | -------------------------------------------------------------------------------- /templates/opml.xml.php: -------------------------------------------------------------------------------- 1 | ' ?> 2 | 3 | 4 | Podsumer Feeds 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /templates/rss.xml.php: -------------------------------------------------------------------------------- 1 | ' ?> 2 | 3 | 4 | <?= $feed['name'] ?> 5 | 6 | 7 | en-us 8 | podsumer 9 | 10 | 11 | 12 | 13 | 14 | 15 | <?= $item['name'] ?> 16 | ]]> 17 | 18 | 19 | /item?item_id= 20 | /item?item_id= 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /templates/tests/base.html.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /templates/tests/feed.xml.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /templates/tests/test.html.php: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /tests/Brickner/Podsumer/ConfigTest.php: -------------------------------------------------------------------------------- 1 | root . DIRECTORY_SEPARATOR . 'conf/test.conf'); 14 | $this->assertEquals($config::class, Config::class); 15 | } 16 | 17 | public function testGet(): void 18 | { 19 | $config = new Config($this->root . DIRECTORY_SEPARATOR . 'conf/test.conf'); 20 | $user = $config->get('podsumer', 'user'); 21 | 22 | $this->assertEquals($user, 'user'); 23 | } 24 | 25 | public function testGetGroup(): void 26 | { 27 | $config = new Config($this->root . DIRECTORY_SEPARATOR . 'conf/test.conf'); 28 | $group = $config->get('podsumer'); 29 | 30 | $this->assertEquals(is_array($group), true); 31 | } 32 | 33 | public function testGetNonExistent(): void 34 | { 35 | $config = new Config($this->root . DIRECTORY_SEPARATOR . 'conf/test.conf'); 36 | $empty = $config->get('podsumer', 'non-existent'); 37 | 38 | $this->assertEquals($empty, null); 39 | } 40 | 41 | public function testBadParsePath(): void 42 | { 43 | $this->expectException(Exception::class); 44 | $config = new Config($this->root . DIRECTORY_SEPARATOR . 'conf/bad.txt'); 45 | } 46 | 47 | public function testParseExecption(): void 48 | { 49 | $this->expectException(Exception::class); 50 | $config = new Config($this->root . DIRECTORY_SEPARATOR . 'conf/test_bad.conf'); 51 | } 52 | 53 | public function testEmptyConfig(): void 54 | { 55 | $this->expectException(Exception::class); 56 | $config = new Config($this->root . DIRECTORY_SEPARATOR . 'conf/test_empty.conf'); 57 | } 58 | 59 | } 60 | 61 | -------------------------------------------------------------------------------- /tests/Brickner/Podsumer/FSStateTest.php: -------------------------------------------------------------------------------- 1 | 'http', 22 | 'HTTP_HOST' => 'example.com', 23 | 'REQUEST_URI' => '/', 24 | 'REQUEST_METHOD' => 'GET', 25 | 'REMOTE_ADDR' => '127.0.0.1', 26 | ]; 27 | 28 | $tmp_main = new Main($this->root, $env, [], [], true); 29 | unlink($tmp_main->getStateFilePath()); 30 | exec('rm -rf ' . $this->root . '/state/media_test'); 31 | 32 | $this->main = new Main($this->root, $env, [], [], true); 33 | 34 | $this->main->setConf(true, 'podsumer', 'store_media_on_disk'); 35 | $this->main->setConf('state/media_test', 'podsumer', 'media_dir'); 36 | 37 | $this->state = new FSState($this->main); 38 | $this->main->setState($this->state); 39 | } 40 | 41 | public function testGetMediaDir() 42 | { 43 | # Write this test 44 | 45 | $this->assertTrue( 46 | str_ends_with($this->state->getMediaDir(), 'state/media_test') 47 | ); 48 | } 49 | 50 | public function testGetFeedDir() 51 | { 52 | $feed = new Feed(self::TEST_FEED_URL); 53 | $name = $feed->getTitle(); 54 | $this->main->getState()->addFeed($feed); 55 | $feed = $this->main->getState()->getFeed(1); 56 | 57 | $this->assertTrue( 58 | str_ends_with( 59 | $this->main->getState()->getFeedDir($feed['name']), 60 | "state/media_test/$name" 61 | ) 62 | ); 63 | } 64 | 65 | public function testBadMediaDir() 66 | { 67 | $this->expectException(Exception::class); 68 | 69 | $this->main->setInstallPath('/'); 70 | $this->main->setConf('/dev/random', 'podsumer', 'media_dir'); 71 | 72 | $this->feed = new Feed(self::TEST_FEED_URL); 73 | $this->main->getState()->addFeed($this->feed); 74 | } 75 | 76 | public function testDeleteFeed() 77 | { 78 | $this->expectNotToPerformAssertions(); 79 | 80 | $this->feed = new Feed(self::TEST_FEED_URL); 81 | $feed_id = $this->main->getState()->addFeed($this->feed); 82 | $feed_data = $this->main->getState()->getFeed($feed_id); 83 | 84 | $item = $this->main->getState()->getFeedItems(1)[0]; 85 | $file = new File($this->main); 86 | $file_id = $file->cacheUrl($item['audio_url'], $feed_data); 87 | $this->main->getState()->setItemAudioFile($item['id'], $file_id); 88 | 89 | $this->main->getState()->deleteFeed(1); 90 | } 91 | 92 | public function testDeleteItemMedia() 93 | { 94 | $this->expectNotToPerformAssertions(); 95 | 96 | $this->feed = new Feed(self::TEST_FEED_URL); 97 | $this->state->addFeed($this->feed); 98 | 99 | $item = $this->main->getState()->getFeedItems(1)[0]; 100 | 101 | $feed_data = $this->main->getState()->getFeed(1); 102 | 103 | $file = new File($this->main); 104 | $file_id = $file->cacheUrl($item['audio_url'], $feed_data); 105 | $this->main->getState()->setItemAudioFile($item['id'], $file_id); 106 | 107 | $this->state->deleteItemMedia($item['id']); 108 | } 109 | 110 | } 111 | 112 | -------------------------------------------------------------------------------- /tests/Brickner/Podsumer/FeedTest.php: -------------------------------------------------------------------------------- 1 | feed = new Feed('https://feeds.npr.org/500005/podcast.xml'); 13 | } 14 | 15 | public function testLoadFeed(): void 16 | { 17 | $this->assertEquals(true, $this->feed->feedLoaded()); 18 | } 19 | 20 | public function testLoadFeedBadURL(): void 21 | { 22 | $this->expectException(Exception::class); 23 | $feed = new Feed('example.com'); 24 | } 25 | 26 | public function testGetTitle(): void 27 | { 28 | $this->assertEquals('NPR News Now', $this->feed->getTitle()); 29 | } 30 | 31 | public function testGetLastUpdated(): void 32 | { 33 | $this->assertEquals(DateTime::class, $this->feed->getLastUpdated()::class); 34 | } 35 | 36 | public function testGetDescription(): void 37 | { 38 | $this->assertEquals(true, is_string($this->feed->getDescription())); 39 | } 40 | 41 | public function testGetImage(): void 42 | { 43 | $this->assertEquals(true, is_string($this->feed->getImage())); 44 | } 45 | 46 | public function testGetUrl(): void 47 | { 48 | $this->assertEquals(true, is_string($this->feed->getUrl())); 49 | } 50 | 51 | public function testGetUrlHash(): void 52 | { 53 | $this->assertEquals(true, is_string($this->feed->getUrlHash())); 54 | } 55 | 56 | public function testGetFeedItems(): void 57 | { 58 | $this->assertEquals(SimpleXMLElement::class, $this->feed->getFeedItems()::class); 59 | } 60 | 61 | public function testSetFeedId(): void 62 | { 63 | $this->feed->setFeedId(33); 64 | $this->assertEquals(33, $this->feed->getFeedId()); 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /tests/Brickner/Podsumer/MainTest.php: -------------------------------------------------------------------------------- 1 | root, [], [], [], true); 29 | $this->assertEquals($main::class, Main::class); 30 | } 31 | 32 | public function testRun(): void 33 | { 34 | $env = ['REQUEST_URI' => '/']; 35 | 36 | $main = $this->dummyMain($env); 37 | $main->run(); 38 | 39 | $this->assertEquals(http_response_code(), 200); 40 | } 41 | 42 | public function testRunNotFound(): void 43 | { 44 | $env = ['REQUEST_URI' => '/made-up-url']; 45 | 46 | $main = $this->dummyMain($env); 47 | $main->run(); 48 | 49 | $this->assertEquals(http_response_code(), 404); 50 | } 51 | 52 | public function testRunException(): void 53 | { 54 | $env = ['REQUEST_URI' => '/exception']; 55 | 56 | $main = $this->dummyMain($env); 57 | $main->run(); 58 | 59 | $this->assertEquals(http_response_code(), 500); 60 | } 61 | 62 | public function testGetUrl(): void 63 | { 64 | $env = ['REQUEST_URI' => '/home']; 65 | 66 | $main = $this->dummyMain($env); 67 | $url = $main->getUrl(); 68 | 69 | $this->assertEquals('http://example.com/home', $url); 70 | } 71 | 72 | public function testGetArg(): void 73 | { 74 | $request = ['arg1' => 'hello']; 75 | 76 | $main = $this->dummyMain([], $request); 77 | $arg = $main->getArg('arg1'); 78 | 79 | $this->assertEquals($arg, 'hello'); 80 | } 81 | 82 | public function testGetArgs(): void 83 | { 84 | $request = ['arg1' => 'hello', 'arg2' => 'hello world']; 85 | 86 | $main = $this->dummyMain([], $request); 87 | $args = $main->getArgs(); 88 | 89 | $this->assertEquals($args, $request); 90 | } 91 | 92 | public function testGetUploads(): void 93 | { 94 | $main = $this->dummyMain([], [], ['test' => 1]); 95 | $uploads = $main->getUploads(); 96 | 97 | $this->assertEquals(1, $uploads['test']); 98 | } 99 | 100 | public function testParseUrl(): void 101 | { 102 | $env = ['REQUEST_URI' => '/parse-me']; 103 | 104 | $main = $this->dummyMain($env); 105 | $parsed_url = $main->parseUrl('path'); 106 | 107 | $this->assertEquals('/parse-me', $parsed_url); 108 | } 109 | 110 | public function testGetQueryParams(): void 111 | { 112 | $env = ['REQUEST_URI' => '/parse-me?test=1']; 113 | 114 | $main = $this->dummyMain($env); 115 | $params = $main->getQueryParams(); 116 | 117 | $this->assertEquals(['test' => '1'], $params); 118 | } 119 | 120 | public function testGetRoute(): void 121 | { 122 | $env = ['REQUEST_URI' => '/route']; 123 | 124 | $main = $this->dummyMain($env); 125 | $route = $main->getRoute(); 126 | 127 | $this->assertEquals('/route', $route); 128 | } 129 | 130 | public function testGetMethod(): void 131 | { 132 | $env = ['REQUEST_METHOD' => 'GET']; 133 | 134 | $main = $this->dummyMain($env); 135 | $method = $main->getMethod(); 136 | 137 | $this->assertEquals('GET', $method); 138 | } 139 | 140 | public function testGetHost(): void 141 | { 142 | $env = ['HTTP_HOST' => 'example.com']; 143 | 144 | $main = $this->dummyMain($env); 145 | $host = $main->getHost(); 146 | 147 | $this->assertEquals('example.com', $host); 148 | } 149 | 150 | public function testGetRemoteAddress(): void 151 | { 152 | $env = ['REMOTE_ADDR' => '127.0.0.1']; 153 | 154 | $main = $this->dummyMain($env); 155 | $ip = $main->getRemoteAddress(); 156 | 157 | $this->assertEquals('127.0.0.1', $ip); 158 | } 159 | 160 | public function testGetConfigPath(): void 161 | { 162 | $main = $this->dummyMain(); 163 | $conf_path = $main->getConfigPath(); 164 | 165 | $this->assertEquals($this->root . 'conf/podsumer.conf', $conf_path); 166 | } 167 | 168 | public function testGetState(): void 169 | { 170 | $main = $this->dummyMain(); 171 | $state = $main->getState(); 172 | 173 | $this->assertTrue(is_a($state::class, State::class, true)); 174 | } 175 | 176 | public function testGetStateFilePath() 177 | { 178 | $main = $this->dummyMain(); 179 | $path = $main->getStateFilePath(); 180 | $this->assertEquals(true, is_String($path)); 181 | } 182 | 183 | protected function dummyMain(array $env = [], array $request = [], array $files = []) 184 | { 185 | $env = array_merge([ 186 | 'REQUEST_SCHEME' => 'http', 187 | 'HTTP_HOST' => 'example.com', 188 | 'REQUEST_URI' => '/', 189 | 'REQUEST_METHOD' => 'GET', 190 | 'REMOTE_ADDR' => '127.0.0.1', 191 | ], $env); 192 | 193 | $main = new Main($this->root, $env, $request, $files, true); 194 | 195 | return $main; 196 | } 197 | } 198 | 199 | -------------------------------------------------------------------------------- /tests/Brickner/Podsumer/OPMLTest.php: -------------------------------------------------------------------------------- 1 | $tmp]); 15 | $this->assertEquals(true, is_array($opml)); 16 | unlink($tmp); 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /tests/Brickner/Podsumer/StateTest.php: -------------------------------------------------------------------------------- 1 | 'http', 21 | 'HTTP_HOST' => 'example.com', 22 | 'REQUEST_URI' => '/', 23 | 'REQUEST_METHOD' => 'GET', 24 | 'REMOTE_ADDR' => '127.0.0.1', 25 | ]; 26 | 27 | $tmp_main = new Main($this->root, $env, [], [], true); 28 | unlink($tmp_main->getStateFilePath()); 29 | 30 | $this->main = new Main($this->root, $env, [], [], true); 31 | $this->state = new State($this->main); 32 | } 33 | 34 | public function testGetStateDirPath() 35 | { 36 | $path = dirname($this->main->getStateFilePath()); 37 | $this->assertEquals($path, $this->state->getStateDirPath()); 38 | } 39 | 40 | public function testAddFeed() 41 | { 42 | $this->expectNotToPerformAssertions(); 43 | $this->feed = new Feed(self::TEST_FEED_URL); 44 | $this->state->addFeed($this->feed); 45 | } 46 | 47 | public function testAddDuplicateFeed() 48 | { 49 | $this->expectNotToPerformAssertions(); 50 | $this->feed = new Feed(self::TEST_FEED_URL); 51 | $this->state->addFeed($this->feed); 52 | $this->state->addFeed($this->feed); 53 | } 54 | 55 | public function testGetFeed() 56 | { 57 | $this->feed = new Feed(self::TEST_FEED_URL); 58 | $this->state->addFeed($this->feed); 59 | $feed = $this->state->getFeed(1); 60 | $this->assertEquals(1, $feed['id']); 61 | } 62 | 63 | public function testGetFeeds() 64 | { 65 | $this->feed = new Feed(self::TEST_FEED_URL); 66 | $this->state->addFeed($this->feed); 67 | $feeds = $this->state->getFeeds(); 68 | $this->assertEquals(1, count($feeds)); 69 | } 70 | 71 | public function testGetFeedItem() 72 | { 73 | $this->feed = new Feed(self::TEST_FEED_URL); 74 | $this->state->addFeed($this->feed); 75 | $item = $this->state->getFeedItem(1); 76 | $this->assertEquals(1, $item['id']); 77 | } 78 | 79 | public function testGetFeedItems() 80 | { 81 | $this->feed = new Feed(self::TEST_FEED_URL); 82 | $this->state->addFeed($this->feed); 83 | $items = $this->state->getFeedItems(1); 84 | $this->assertEquals(1, count($items)); 85 | } 86 | 87 | public function testGetFeedByHash() 88 | { 89 | $this->feed = new Feed(self::TEST_FEED_URL); 90 | $this->state->addFeed($this->feed); 91 | $feed = $this->state->getFeed(1); 92 | $hash = $feed['url_hash']; 93 | $feed_by_hash = $this->state->getFeedByHash($hash)[0]; 94 | 95 | $this->assertEquals(1, $feed_by_hash['id']); 96 | } 97 | 98 | public function testDeleteFeed() 99 | { 100 | $this->expectNotToPerformAssertions(); 101 | $this->feed = new Feed(self::TEST_FEED_URL); 102 | $this->state->addFeed($this->feed); 103 | $this->state->deleteFeed(1); 104 | } 105 | 106 | public function testDeleteItemMedia() 107 | { 108 | $this->expectNotToPerformAssertions(); 109 | $this->feed = new Feed(self::TEST_FEED_URL); 110 | $this->state->addFeed($this->feed); 111 | $item = $this->state->getFeedItem(1); 112 | $this->state->deleteItemMedia($item['id']); 113 | } 114 | 115 | public function testGetVersion() 116 | { 117 | // Assert it is greater than 0 118 | $this->assertGreaterThan(0, $this->state->getVersion()); 119 | } 120 | } 121 | 122 | -------------------------------------------------------------------------------- /tests/Brickner/Podsumer/TemplateTest.php: -------------------------------------------------------------------------------- 1 | 'http', 21 | 'HTTP_HOST' => 'example.com', 22 | 'REQUEST_URI' => '/', 23 | 'REQUEST_METHOD' => 'GET', 24 | 'REMOTE_ADDR' => '127.0.0.1', 25 | ]; 26 | 27 | $this->main = new Main($this->root, $env, [], [], true); 28 | Template::render($this->main, 'test', ['test' => 'test']); 29 | $out = ob_get_contents(); 30 | ob_end_clean(); 31 | 32 | $this->assertEquals('test', $out); 33 | } 34 | 35 | public function testXmlTemplate(): void 36 | { 37 | ob_start(); 38 | 39 | $env = [ 40 | 'REQUEST_SCHEME' => 'http', 41 | 'HTTP_HOST' => 'example.com', 42 | 'REQUEST_URI' => '/', 43 | 'REQUEST_METHOD' => 'GET', 44 | 'REMOTE_ADDR' => '127.0.0.1', 45 | ]; 46 | 47 | $this->main = new Main($this->root, $env, [], [], true); 48 | Template::renderXml($this->main, 'feed', ['test' => 'test']); 49 | $out = ob_get_contents(); 50 | ob_end_clean(); 51 | 52 | $this->assertEquals('test', $out); 53 | } 54 | 55 | public function testHyperlinkUrls(): void 56 | { 57 | $env = [ 58 | 'REQUEST_SCHEME' => 'http', 59 | 'HTTP_HOST' => 'example.com', 60 | 'REQUEST_URI' => '/', 61 | 'REQUEST_METHOD' => 'GET', 62 | 'REMOTE_ADDR' => '127.0.0.1', 63 | ]; 64 | 65 | $this->main = new Main($this->root, $env, [], [], true); 66 | $template = new Template($this->main); 67 | 68 | $test = $template->hyperlinkUrls('This is a link: https://example.com'); 69 | 70 | $this->assertEquals('This is a link: https://example.com', $test); 71 | } 72 | } 73 | 74 | -------------------------------------------------------------------------------- /www/ai.txt: -------------------------------------------------------------------------------- 1 | User-Agent: * 2 | Disallow: / 3 | Disallow: * 4 | -------------------------------------------------------------------------------- /www/index.php: -------------------------------------------------------------------------------- 1 | run(); 25 | 26 | /** 27 | * Home 28 | * Path: / 29 | * HTTP Method: GET 30 | * 31 | * Renders the default page. 32 | */ 33 | #[Route('/', 'GET', true)] 34 | function home(array $args): void 35 | { 36 | global $main; 37 | $feeds = $main->getState()->getFeeds(); 38 | 39 | $vars = ['feeds' => $feeds]; 40 | Template::render($main, 'home', $vars); 41 | } 42 | 43 | /** 44 | * Add new feed(s) 45 | * Path: /add 46 | * HTTP Method: POST 47 | * 48 | * Adds new feed(s) based on entered URL or URLs from an uploaded OPML file. 49 | */ 50 | #[Route('/add', 'POST', true)] 51 | function add(array $args): void 52 | { 53 | global $main; 54 | 55 | # Add a single feed via a URL. URL is validated automatically. 56 | 57 | if (!empty($args['url'])) { 58 | $feed = new Feed($args['url']); 59 | $main->getState()->addFeed($feed); 60 | } 61 | 62 | # Add an array of feeds via uploaded OPML file. 63 | 64 | $uploads = $main->getUploads(); 65 | 66 | if (count(array_filter($uploads['opml'])) > 2) { 67 | 68 | $feed_urls = OPML::parse($uploads['opml']); 69 | 70 | foreach ($feed_urls as $url) { 71 | $feed = new Feed($url); 72 | $main->getState()->addFeed($feed); 73 | } 74 | } 75 | 76 | # Send user to home to see newly added feed(s). 77 | 78 | $main->redirect('/'); 79 | } 80 | 81 | #[Route('/feed', 'GET', true)] 82 | function feed(array $args): void 83 | { 84 | global $main; 85 | 86 | $vars = [ 87 | 'feed' => $main->getState()->getFeed(intval($args['id'])), 88 | 'items' => $main->getState()->getFeedItems(intval($args['id'])) 89 | ]; 90 | 91 | if (empty($vars['feed']) || empty($vars['items'])) { 92 | $main->setResponseCode(404); 93 | return; 94 | } 95 | 96 | Template::render($main, 'feed', $vars); 97 | } 98 | 99 | #[Route('/item', 'GET', true)] 100 | function item(array $args): void 101 | { 102 | global $main; 103 | 104 | if (empty($args['item_id'])) { 105 | $main->setResponseCode(404); 106 | return; 107 | } 108 | 109 | $item = $main->getState()->getFeedItem(intval($args['item_id'])); 110 | $feed = $main->getState()->getFeed(intval($item['feed_id'])); 111 | 112 | $vars = [ 113 | 'item' => $item, 114 | 'feed' => $feed 115 | ]; 116 | 117 | if (empty($vars['feed']) || empty($vars['item'])) { 118 | $main->setResponseCode(404); 119 | return; 120 | } 121 | 122 | Template::render($main, 'item', $vars); 123 | } 124 | 125 | #[Route('/delete_feed', 'GET', true)] 126 | function delete_feed(array $args) 127 | { 128 | global $main; 129 | 130 | $feed_id = intval($args['feed_id']); 131 | $main->getState()->deleteFeed($feed_id); 132 | 133 | $main->redirect('/'); 134 | } 135 | 136 | #[Route('/delete_audio', 'GET', true)] 137 | function delete_audio(array $args) 138 | { 139 | global $main; 140 | 141 | $item_id = intval($args['item_id']); 142 | $main->getState()->deleteItemMedia($item_id); 143 | $item = $main->getState()->getFeedItem($item_id); 144 | 145 | $main->redirect('/feed?id=' . $item['feed_id']); 146 | } 147 | 148 | #[Route('/rss', 'GET')] 149 | function rss(array $args) 150 | { 151 | global $main; 152 | 153 | if (empty($args['feed_id'])) { 154 | $main->setResponseCode(404); 155 | return; 156 | } 157 | 158 | doRefresh(intval($args['feed_id'])); 159 | 160 | $feed_id = intval($args['feed_id']); 161 | 162 | $items = $main->getState()->getFeedItems($feed_id); 163 | $feed = $main->getState()->getFeed($feed_id); 164 | 165 | $vars = [ 166 | 'items' => $items, 167 | 'feed' => $feed, 168 | 'host' => $main->getBaseUrl() 169 | ]; 170 | 171 | if (empty($vars['feed']) || empty($vars['items'])) { 172 | $main->setResponseCode(404); 173 | return; 174 | } 175 | 176 | Template::renderXml($main, 'rss', $vars); 177 | } 178 | 179 | #[Route('/opml', 'GET', true)] 180 | function opml(array $args) 181 | { 182 | global $main; 183 | 184 | $feeds = $main->getState()->getFeeds(); 185 | 186 | $vars = [ 187 | 'feeds' => $feeds, 188 | 'host' => $main->getBaseUrl() 189 | ]; 190 | 191 | header("Content-disposition: attachment; filename=\"podsumer.opml\""); 192 | header("Content-Type: text/x-opml"); 193 | 194 | Template::renderXml($main, 'opml', $vars); 195 | } 196 | 197 | #[Route('/file', 'GET')] 198 | function file_cache(array $args): ?string 199 | { 200 | global $main; 201 | 202 | $file = new File($main); 203 | 204 | if (!empty($args['file_id'])) { 205 | $file_data = $file->cacheForId(intval($args['file_id'])); 206 | 207 | } else { 208 | $main->setResponseCode(404); 209 | } 210 | 211 | if (empty($file_data)) { 212 | $main->setResponseCode(404); 213 | return null; 214 | } 215 | 216 | $data = $file_data['data']; 217 | $size = strlen($data); 218 | 219 | header('Content-Type: ' . $file_data['mimetype']); 220 | header('Accept-Ranges: bytes'); 221 | 222 | $headers = $main->getHeaders(); 223 | 224 | $range = $headers['Range'] ?? null; 225 | $data = $file_data['data']; 226 | if (!empty($range)) { 227 | $range = str_replace('bytes=', '', $range); # 'bytes=0-10' 228 | $range = explode ('-', $range); # '0-10' => ['0', '10'] 229 | $range = array_map('intval', $range); # ['0', '10'] => [0, 10] 230 | $start = $range[0]; 231 | $end = $range[1] ?: $size-1; 232 | 233 | $data = substr($data, $start, $end-$start+1 ); 234 | $main->log("$start, $end"); 235 | 236 | if (strlen($data) <= $size) { 237 | $main->setResponseCode(206); 238 | } 239 | 240 | header("Content-Range: bytes $start-$end/$size"); 241 | } 242 | 243 | header('Content-Length: ' . strlen($data)); 244 | 245 | if (array_key_exists('return', $args) && $args['return'] === true) { 246 | return $data; 247 | } 248 | 249 | if (array_key_exists('is_head', $args) && $args['is_head'] === true) { 250 | return null; 251 | } 252 | 253 | echo $data; 254 | 255 | return null; 256 | } 257 | 258 | #[Route('/audio', 'GET')] 259 | function audio_cache(array $args) 260 | { 261 | global $main; 262 | 263 | if (empty($args['item_id'])) { 264 | $main->setResponseCode(404); 265 | return; 266 | } 267 | 268 | $item_id = intval($args['item_id']); 269 | $item = $main->getState()->getFeedItem($item_id); 270 | 271 | $feed = $main->getState()->getFeedForItem($item_id); 272 | 273 | $file = new File($main); 274 | $file_id = $file->cacheUrl($item['audio_url'], $feed); 275 | 276 | $main->getState()->setItemAudioFile($item_id, $file_id); 277 | 278 | file_cache(['file_id' => $file_id]); 279 | } 280 | 281 | #[Route('/image', 'GET')] 282 | function image_cache(array $args) 283 | { 284 | global $main; 285 | 286 | if (array_key_exists('item_id', $args)) { 287 | 288 | $item_id = intval($args['item_id']); 289 | $item = $main->getState()->getFeedItem($item_id); 290 | $feed = $main->getState()->getFeed($item['feed_id']); 291 | 292 | $file_id = $item['image']; 293 | 294 | } elseif (array_key_exists('feed_id', $args)) { 295 | 296 | $feed_id = intval($args['feed_id']); 297 | $feed = $main->getState()->getFeed($feed_id); 298 | 299 | $file_id = $feed['image']; 300 | 301 | } else { 302 | 303 | $main->setResponseCode(404); 304 | return; 305 | } 306 | 307 | $file_data = $main->getState()->getFileById($file_id); 308 | 309 | $file = new File($main); 310 | $file_id = $file->cacheUrl($file_data['url'], $feed); 311 | 312 | if (array_key_exists('item_id', $args)) { 313 | $main->getState()->setItemImageFile($item_id, $file_id); 314 | } elseif (array_key_exists('feed_id', $args)) { 315 | $main->getState()->setFeedImageFile($feed_id, $file_id); 316 | } 317 | 318 | file_cache(['file_id' => $file_id]); 319 | } 320 | 321 | #[Route('/refresh', 'GET', true)] 322 | function refresh(array $args) 323 | { 324 | global $main; 325 | 326 | if (empty($args['feed_id'])) { 327 | $main->setResponseCode(404); 328 | return; 329 | } 330 | 331 | doRefresh(intval($args['feed_id'])); 332 | 333 | $main->redirect('/feed?id=' . intval($args['feed_id'])); 334 | } 335 | 336 | function doRefresh(int $feed_id) { 337 | 338 | global $main; 339 | 340 | if (!empty($feed_id)) { 341 | $feed = $main->getState()->getFeed(intval($feed_id)); 342 | $refresh_feed = new Feed($feed['url'] ?? null); 343 | $refresh_feed->setFeedId(intval($feed_id)); 344 | $main->getState()->addFeed($refresh_feed); 345 | } 346 | } 347 | 348 | -------------------------------------------------------------------------------- /www/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: / 3 | --------------------------------------------------------------------------------