├── .dockerignore ├── .editorconfig ├── .env.example ├── .gitattributes ├── .gitignore ├── .php-cs-fixer.dist.php ├── LICENSE ├── README.md ├── app ├── Config │ ├── ConfiguredFeed.php │ ├── ConfiguredFeedList.php │ ├── ConfiguredFeedProvider.php │ └── RssConfig.php ├── Console │ └── Commands │ │ ├── PrunePostsCommand.php │ │ ├── TestFeedCommand.php │ │ └── UpdateFeedsCommand.php ├── Http │ ├── Controllers │ │ ├── Controller.php │ │ ├── FeedController.php │ │ └── PostViewController.php │ └── Middleware │ │ ├── HandleInertiaRequests.php │ │ └── TrustProxies.php ├── Jobs │ ├── FetchPostThumbnailJob.php │ └── RefreshFeedJob.php ├── Models │ ├── Feed.php │ └── Post.php ├── Providers │ └── AppServiceProvider.php └── Rss │ ├── FeedPostFetcher.php │ ├── PostProvider.php │ ├── PostPruner.php │ ├── PostThumbnailFetcher.php │ └── RssParser.php ├── artisan ├── bootstrap ├── app.php ├── cache │ └── .gitignore └── providers.php ├── composer.json ├── composer.lock ├── config ├── app.php ├── auth.php ├── cache.php ├── database.php ├── filesystems.php ├── logging.php ├── mail.php ├── queue.php └── session.php ├── database ├── .gitignore ├── factories │ ├── FeedFactory.php │ ├── PostFactory.php │ └── UserFactory.php ├── migrations │ ├── 2019_08_19_000000_create_failed_jobs_table.php │ ├── 2022_06_29_124112_create_jobs_table.php │ ├── 2022_06_29_124605_create_feeds_table.php │ └── 2022_06_29_124610_create_posts_table.php └── seeders │ └── DatabaseSeeder.php ├── docker-compose.testing.yml ├── docker ├── .env.container ├── Dockerfile ├── cron ├── nginx.conf ├── run.sh └── services.conf ├── package-lock.json ├── package.json ├── phpunit.xml ├── postcss.config.js ├── public ├── .htaccess ├── icons │ ├── rss-128.png │ └── rss-32.png ├── index.php └── robots.txt ├── resources ├── css │ └── app.css ├── js │ ├── Pages │ │ └── Posts.vue │ ├── Parts │ │ ├── Feed.vue │ │ ├── FormatSelector.vue │ │ ├── Post.vue │ │ └── Tag.vue │ ├── app.js │ └── util.js └── views │ └── app.blade.php ├── routes ├── console.php └── web.php ├── storage ├── app │ ├── .gitignore │ └── public │ │ └── .gitignore ├── database │ └── .gitignore ├── framework │ ├── .gitignore │ ├── cache │ │ ├── .gitignore │ │ └── data │ │ │ └── .gitignore │ ├── sessions │ │ └── .gitignore │ ├── testing │ │ └── .gitignore │ └── views │ │ └── .gitignore └── logs │ └── .gitignore ├── tailwind.config.js ├── tests ├── Feature │ ├── Commands │ │ └── PrunePostsCommandTest.php │ ├── ConfiguredFeedTest.php │ ├── FeedControllerTest.php │ ├── Jobs │ │ └── RefreshFeedJobTest.php │ ├── PostThumbnailFetcherTest.php │ └── PostViewControllerTest.php ├── GeneratesTestData.php ├── TestCase.php └── Unit │ ├── RssConfigTest.php │ └── RssParserTest.php └── vite.config.mjs /.dockerignore: -------------------------------------------------------------------------------- 1 | /vendor 2 | /node_modules 3 | /feeds.txt 4 | /bootstrap/cache/* 5 | /public/storage 6 | /storage/app/* 7 | /storage/database/* 8 | /storage/framework/* 9 | /storage/logs/* 10 | /.github 11 | /.idea 12 | /.*.cache 13 | /test-files 14 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | 14 | [*.{yml,yaml}] 15 | indent_size = 2 16 | 17 | [docker-compose.yml] 18 | indent_size = 4 19 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | APP_NAME=RSS 2 | APP_ENV=local 3 | APP_KEY= 4 | APP_DEBUG=true 5 | 6 | QUEUE_CONNECTION=database 7 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.blade.php diff=html 4 | *.css diff=css 5 | *.html diff=html 6 | *.md diff=markdown 7 | *.php diff=php 8 | 9 | /.github export-ignore 10 | CHANGELOG.md export-ignore 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /feeds.txt 2 | /feeds.txt.* 3 | /node_modules 4 | /public/hot 5 | /public/storage 6 | /public/css/*.css 7 | /public/js/*.js 8 | /public/build 9 | /storage/*.key 10 | /vendor 11 | .DS_Store 12 | .env 13 | .env.backup 14 | /.phpunit.cache 15 | /.phpunit.result.cache 16 | Homestead.json 17 | Homestead.yaml 18 | npm-debug.log 19 | yarn-error.log 20 | /.idea 21 | /.vscode 22 | .php-cs-fixer.cache 23 | /test-files 24 | -------------------------------------------------------------------------------- /.php-cs-fixer.dist.php: -------------------------------------------------------------------------------- 1 | exclude(['vendor', 'resources', 'docker', 'storage', 'bootstrap']) 5 | ->in(__DIR__); 6 | 7 | $config = new PhpCsFixer\Config(); 8 | 9 | return $config->setRules([ 10 | '@PSR12' => true, 11 | 'array_syntax' => ['syntax' => 'short'], 12 | ])->setFinder($finder); 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Dan Brown 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # RSS 2 | 3 | A simple, opinionated, RSS feed aggregator. 4 | 5 | [![PHPUnit](https://github.com/ssddanbrown/rss/actions/workflows/phpunit.yml/badge.svg?branch=main)](https://github.com/ssddanbrown/rss/actions/workflows/phpunit.yml) 6 | 7 | ## Features 8 | 9 | The following features are built into the application: 10 | 11 | - Supports RSS and ATOM formats. 12 | - Regular auto-fetching of RSS feeds. 13 | - Every hour by default, configurable down to 5 mins. 14 | - Custom feed names and colors. 15 | - Feed-based tags for categorization. 16 | - Ability to hide feed posts by default. 17 | - 3 different post layout modes (card, list, compact). 18 | - Fetching of page open-graph images. 19 | - Feeds managed via a single plaintext file. 20 | - System-based dark/light theme. 21 | - Post title/description search. 22 | - Ready-to-use docker image. 23 | - Mobile screen compatible. 24 | - Built-in support to prune old post data. 25 | 26 | ## Limitations 27 | 28 | The below possibly expected features are missing from this application. 29 | This is not a list of planned features. Please see the [Low Maintenance Project](#low-maintenance-project) section below for more info. 30 | 31 | - No import of full post/article content. 32 | - No feed management via the UI. 33 | - No user system or user management system. 34 | - No authentication or authorization built-in. 35 | - No customization, extension or plugin system. 36 | - No organisation upon simple feed-level tagging. 37 | - Error handling is limited and will likely not alert clearly upon issue. 38 | 39 | Upon the above, it's quite likely you'll come across issues. This project was created to meet a personal need while learning some new technologies. Much of the logic is custom written instead of using battle-tested libraries. 40 | 41 | ## Screenshots 42 | 43 | 44 | 45 | 46 | 47 | 51 | 55 | 59 | 63 | 64 | 65 |
48 | Card View 49 | 50 | 52 | List View 53 | 54 | 56 | Compact View 57 | 58 | 60 | Dark Mode 61 | 62 |
66 | 67 | 68 | ## Docker Usage 69 | 70 | A pre-built docker image is available to run the application. 71 | Storage data is confined to a single `/app/storage` directory for easy volume mounting. 72 | Port 80 is exposed by default for application access. This application does not support HTTPS, for that you should instead use a proxy layer such as nginx. 73 | 74 | #### Docker Run Command Example 75 | 76 | In the below command, the application will be accessible at http://localhost:8080 on the host and the files would be stored in a `/home/barry/rss` directory. In this example, feeds would be configured in a `/home/barry/rss/feeds.txt` file. 77 | 78 | ```shell 79 | docker run -d \ 80 | --restart unless-stopped \ 81 | -p 8080:80 \ 82 | -v /home/barry/rss:/app/storage \ 83 | ghcr.io/ssddanbrown/rss:latest 84 | ``` 85 | 86 | #### Docker Compose Example 87 | 88 | In the below `docker-compose.yml` example, the application will be accessible at http://localhost:8080 on the host and the files would be stored in a `./rss-files` directory relative to the docker-compose.yml file. In this example, feeds would be configured in a `./rss-files/feeds.txt` file. 89 | 90 | ```yml 91 | --- 92 | version: "2" 93 | services: 94 | rss: 95 | image: ghcr.io/ssddanbrown/rss:latest 96 | container_name: rss 97 | environment: 98 | - APP_NAME=RSS 99 | volumes: 100 | - ./rss-files:/app/storage 101 | ports: 102 | - "8080:80" 103 | restart: unless-stopped 104 | ``` 105 | 106 | 107 | ### Building the Docker Image 108 | 109 | If you'd like to build the image from scratch, instead of using the pre-built image, you can do so like this: 110 | 111 | ```shell 112 | docker build -f docker/Dockerfile . 113 | ``` 114 | 115 | ## Feed Configuration 116 | 117 | Feed configuration is handled by a plaintext file on the host system. 118 | By default, using our docker image, this configuration would be located in a `feeds.txt` file within the path you mounted to `/app/storage`. 119 | 120 | The format of this file can be seen below: 121 | 122 | ```txt 123 | https://feed.url.com/feed.xml feed-name #tag-a #tag-b 124 | https://example.com/feed.xml Example #updates #news 125 | 126 | # Lines starting with a hash are considered comments. 127 | # Empty lines are fine and will be ignored. 128 | 129 | # Underscores in names will be converted to spaces. 130 | https://example.com/feed-b.xml News_Site #news 131 | 132 | # Feed color can be set using square brackets after the name. 133 | # The color must be a CSS-compatible color value. 134 | https://example.com/feed-c.xml Blue_News[#0078b9] #news #blue 135 | 136 | # Feeds starting with a '-' are flagged as hidden. 137 | # Posts for hidden feeds won't be shown on the homepage 138 | # but can be seen via any type of active filter. 139 | - https://example.com/feed-d.xml Cat_Facts #cats #facts 140 | ``` 141 | 142 | ## App Configuration 143 | 144 | The application allows some configuration through variables. 145 | These can be set via the `.env` file or, when using docker, via environment variables. 146 | 147 | ```shell 148 | # The name of the application. 149 | # Only really shown in the title/browser-tab. 150 | APP_NAME=RSS 151 | 152 | # The path to the config file. 153 | # Defaults to `storage/feeds.txt` within the application folder. 154 | APP_CONFIG_FILE=/app/storage/feeds.txt 155 | 156 | # Enable or disable the loading of post thumbnails. 157 | # Does not control them within the UI, but controls the fetching 158 | # when posts are fetched. 159 | # Defaults to true. 160 | APP_LOAD_POST_THUMBNAILS=true 161 | 162 | # The number of minutes before a feed is considered outdated and 163 | # therefore should be updated upon request. 164 | # This effectively has a minimum of 5 minutes in the docker setup. 165 | APP_FEED_UPDATE_FREQUENCY=60 166 | 167 | # The number of days to wait before a post should be pruned. 168 | # Uses the post published_at time to determine lifetime. 169 | # Setting this to false disables any auto-pruning. 170 | # If active, pruning will auto-run daily. 171 | # Defaults to false (No pruning) 172 | APP_PRUNE_POSTS_AFTER_DAYS=30 173 | ``` 174 | 175 | ## Usage Behind a Reverse Proxy 176 | 177 | When using behind a reverse proxy, ensure common forwarding headers are set so that the application can properly detect the right host and path to use. 178 | The below shows a sub-path proxy config location block for nginx. Note the `X-Forwarded-Prefix` header to make the application aware of sub-path usage. 179 | 180 | ```nginx 181 | location /rss/ { 182 | proxy_pass http://container-ip:80/; 183 | proxy_set_header Host $host; 184 | proxy_set_header X-Real-IP $remote_addr; 185 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 186 | proxy_set_header X-Forwarded-Proto $scheme; 187 | proxy_set_header X-Forwarded-Host $host; 188 | proxy_set_header X-Forwarded-Port $server_port; 189 | proxy_set_header X-Forwarded-Prefix "/rss/"; 190 | proxy_redirect off; 191 | } 192 | ``` 193 | 194 | ## Manual Install 195 | 196 | Manually installing the application is not recommended unless you are performing development work on the project. 197 | Instead, use of the docker image is advised. 198 | 199 | This project is based upon Laravel so the requirements and install process are much the same. 200 | You will need git, PHP, composer and NodeJS installed. Installation would generally be as follows: 201 | 202 | ```shell 203 | # Clone down and enter the project 204 | git clone https://github.com/ssddanbrown/rss.git 205 | cd rss 206 | 207 | # Install PHP dependencies via composer 208 | # This will check you meet the minimum PHP version and extensions required. 209 | composer install 210 | 211 | # Create database file 212 | touch storage/database/database.sqlite 213 | 214 | # Copy config, generate app key, migrate database & link storage 215 | cp .env.example .env 216 | php artisan key:generate 217 | php artisan migrate 218 | php artisan storage:link 219 | 220 | # Install JS dependencies & build CSS/JS 221 | npm install 222 | npm run build 223 | ``` 224 | 225 | For a production server you'd really want to have a webserver active to server the `public` directory and handle PHP. 226 | You'd also need a process to run the laravel queue system in addition to a cron job to run the schedule. 227 | 228 | On a development system, These can be done like so: 229 | 230 | ```shell 231 | # Serve the app 232 | php artisan serve 233 | 234 | # Watch the queue 235 | php artisan queue:listen 236 | 237 | # Work the schedule 238 | php artisan schedule:work 239 | ``` 240 | 241 | ## Low Maintenance Project 242 | 243 | This is a low maintenance project. The scope of features and support are purposefully kept narrow for my purposes to ensure longer term maintenance is viable. I'm not looking to grow this into a bigger project at all. 244 | 245 | Issues and PRs raised for bugs are perfectly fine assuming they don't significantly increase the scope of the project. Please don't open PRs for new features that expand the scope. 246 | 247 | ## Development 248 | 249 | This project uses [PHPUnit](https://phpunit.de/) for testing. Tests will use their own in-memory SQLite instance. Tests can be ran like so: 250 | 251 | ```shell 252 | ./vendor/bin/phpunit 253 | ``` 254 | 255 | [PHP CS Fixer](https://github.com/FriendsOfPHP/PHP-CS-Fixer) is used for formatting. This can be ran like so: 256 | 257 | ```bash 258 | ./vendor/bin/php-cs-fixer fix 259 | ``` 260 | 261 | A command is built-in to test RSS feeds where needed. This will just provide a boolean yes/no fetchable status result, but you can run it with debugging with breakpoints for further diagnosis: 262 | 263 | ```bash 264 | php artisan rss:test-feed https://danb.me/blog/index.xml 265 | ``` 266 | 267 | ## Attribution 268 | 269 | This is primarily built using the following great projects and technologies: 270 | 271 | - [Laravel](https://laravel.com/) - [MIT License](https://github.com/laravel/framework/blob/10.x/LICENSE.md) 272 | - [InertiaJS](https://inertiajs.com/) - [MIT License](https://github.com/inertiajs/inertia/blob/master/LICENSE) 273 | - [SQLite](https://www.sqlite.org/index.html) - [Public Domain](https://www.sqlite.org/copyright.html) 274 | - [TailwindCSS](https://tailwindcss.com/) - [MIT License](https://github.com/tailwindlabs/tailwindcss/blob/master/LICENSE) 275 | - [Vue.js](https://vuejs.org/) - [MIT License](https://github.com/vuejs/vue/blob/main/LICENSE) 276 | - [PHPUnit](https://phpunit.de/) - [BSD-3-Clause-Like](https://github.com/sebastianbergmann/phpunit/blob/main/LICENSE) 277 | - [Bootstrap Icons](https://icons.getbootstrap.com/) - [MIT License](https://github.com/twbs/icons/blob/main/LICENSE.md) 278 | -------------------------------------------------------------------------------- /app/Config/ConfiguredFeed.php: -------------------------------------------------------------------------------- 1 | $this->name, 27 | 'color' => $this->color, 28 | 'url' => $this->url, 29 | 'tags' => $this->tags, 30 | 'hidden' => $this->hidden, 31 | 'reloading' => $this->reloading, 32 | 'outdated' => $this->isOutdated(), 33 | ]; 34 | } 35 | 36 | public function isOutdated(): bool 37 | { 38 | $configFrequency = intval(config('app.feed_update_frequency')); 39 | $expiry = time() - intval($configFrequency * 60); 40 | return $this->feed->last_fetched_at <= $expiry; 41 | } 42 | 43 | public function startReloading(): void 44 | { 45 | $refreshJob = new RefreshFeedJob($this->feed); 46 | dispatch($refreshJob); 47 | $this->reloading = true; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/Config/ConfiguredFeedList.php: -------------------------------------------------------------------------------- 1 | feeds = $feeds; 20 | } 21 | 22 | public function getFeedIds(): array 23 | { 24 | return array_map(fn (ConfiguredFeed $feed) => $feed->feed->id, $this->feeds); 25 | } 26 | 27 | public function getMappedById(): array 28 | { 29 | $map = []; 30 | 31 | foreach ($this->feeds as $feed) { 32 | $map[$feed->feed->id] = $feed; 33 | } 34 | 35 | return $map; 36 | } 37 | 38 | public function reloadOutdatedFeeds(): int 39 | { 40 | $refreshCount = 0; 41 | 42 | foreach ($this->feeds as $feed) { 43 | if ($feed->isOutdated()) { 44 | $feed->startReloading(); 45 | $refreshCount++; 46 | } 47 | } 48 | 49 | return $refreshCount; 50 | } 51 | 52 | public function getIterator(): Traversable 53 | { 54 | return new ArrayIterator($this->feeds); 55 | } 56 | 57 | public function jsonSerialize(): mixed 58 | { 59 | return array_values($this->feeds); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /app/Config/ConfiguredFeedProvider.php: -------------------------------------------------------------------------------- 1 | config = new RssConfig(); 17 | 18 | $configFilePath = config('app.config_file'); 19 | if ($configFilePath && file_exists($configFilePath)) { 20 | $contents = file_get_contents($configFilePath); 21 | $this->config->parseFromString($contents); 22 | $this->feeds = $this->getConfiguredFeeds(); 23 | } 24 | } 25 | 26 | public function loadFromString(string $config): void 27 | { 28 | $this->config = new RssConfig(); 29 | $this->config->parseFromString($config); 30 | $this->feeds = $this->getConfiguredFeeds(); 31 | } 32 | 33 | /** 34 | * @return ConfiguredFeed[] 35 | */ 36 | protected function getConfiguredFeeds(): array 37 | { 38 | $configuredFeeds = []; 39 | $feedUrls = $this->config->getFeedUrls(); 40 | $feeds = Feed::query()->whereIn('url', $feedUrls)->get()->keyBy('url'); 41 | 42 | foreach ($feedUrls as $feedUrl) { 43 | $feed = $feeds->get($feedUrl); 44 | if (!$feed) { 45 | $feed = (new Feed())->forceCreate([ 46 | 'url' => $feedUrl, 47 | 'last_fetched_at' => 0, 48 | 'last_accessed_at' => 0, 49 | ]); 50 | } 51 | 52 | $configured = new ConfiguredFeed( 53 | $feed, 54 | $this->config->getName($feedUrl), 55 | $feedUrl, 56 | $this->config->getColor($feedUrl), 57 | $this->config->getTags($feedUrl), 58 | $this->config->getHidden($feedUrl), 59 | ); 60 | 61 | $configuredFeeds[] = $configured; 62 | } 63 | 64 | return $configuredFeeds; 65 | } 66 | 67 | protected function updateLastAccessedForFeeds(array $feeds) 68 | { 69 | $ids = array_map(fn (ConfiguredFeed $feed) => $feed->feed->id, $feeds); 70 | 71 | Feed::query()->whereIn('id', $ids)->update([ 72 | 'last_accessed_at' => now() 73 | ]); 74 | } 75 | 76 | public function getAll() 77 | { 78 | $this->updateLastAccessedForFeeds($this->feeds); 79 | return new ConfiguredFeedList($this->feeds); 80 | } 81 | 82 | public function getVisible() 83 | { 84 | $feeds = array_filter($this->feeds, function (ConfiguredFeed $feed) { 85 | return !$feed->hidden; 86 | }); 87 | 88 | $this->updateLastAccessedForFeeds($feeds); 89 | return new ConfiguredFeedList($feeds); 90 | } 91 | 92 | public function get(string $feedUrl): ?ConfiguredFeed 93 | { 94 | foreach ($this->feeds as $feed) { 95 | if ($feed->url === $feedUrl) { 96 | $this->updateLastAccessedForFeeds([$feed]); 97 | return $feed; 98 | } 99 | } 100 | 101 | return null; 102 | } 103 | 104 | public function getAsList(string $feedUrl): ConfiguredFeedList 105 | { 106 | $feed = $this->get($feedUrl); 107 | $feeds = $feed ? [$feed] : []; 108 | return new ConfiguredFeedList($feeds); 109 | } 110 | 111 | public function getForTag(string $tag) 112 | { 113 | $feeds = array_filter($this->feeds, function (ConfiguredFeed $feed) use ($tag) { 114 | return in_array($tag, $feed->tags); 115 | }); 116 | 117 | $this->updateLastAccessedForFeeds($feeds); 118 | return new ConfiguredFeedList($feeds); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /app/Config/RssConfig.php: -------------------------------------------------------------------------------- 1 | 12 | */ 13 | protected array $feeds = []; 14 | 15 | /** 16 | * Get all feed URLs 17 | * @returns string[] 18 | */ 19 | public function getFeedUrls(): array 20 | { 21 | return array_keys($this->feeds); 22 | } 23 | 24 | /** 25 | * Add a new feed to the config. 26 | */ 27 | public function addFeed(string $feed, string $name, array $tags = [], string $color = '', bool $hidden = false): void 28 | { 29 | $this->feeds[$feed] = [ 30 | 'name' => $name, 31 | 'tags' => $tags, 32 | 'color' => $color, 33 | 'hidden' => $hidden, 34 | ]; 35 | } 36 | 37 | /** 38 | * Remove a feed from the config. 39 | * Returns a boolean indicating if the feed existed. 40 | */ 41 | public function removeFeed(string $feed): bool 42 | { 43 | $exists = isset($this->feeds[$feed]); 44 | 45 | if ($exists) { 46 | unset($this->feeds[$feed]); 47 | } 48 | 49 | return $exists; 50 | } 51 | 52 | /** 53 | * Get the tags for the given feed. 54 | * @return string[] 55 | */ 56 | public function getTags(string $feed): array 57 | { 58 | return $this->feeds[$feed]['tags'] ?? []; 59 | } 60 | 61 | /** 62 | * Get the name for the given feed. 63 | */ 64 | public function getName(string $feed): string 65 | { 66 | return $this->feeds[$feed]['name'] ?? ''; 67 | } 68 | 69 | /** 70 | * Get the color for the given feed. 71 | */ 72 | public function getColor(string $feed): string 73 | { 74 | return $this->feeds[$feed]['color'] ?? ''; 75 | } 76 | 77 | /** 78 | * Get the hidden status for the given feed. 79 | */ 80 | public function getHidden(string $feed): bool 81 | { 82 | return $this->feeds[$feed]['hidden'] ?? false; 83 | } 84 | 85 | /** 86 | * Get the configuration as a string. 87 | */ 88 | public function toString(): string 89 | { 90 | $lines = []; 91 | 92 | foreach ($this->feeds as $feed => $details) { 93 | $line = "{$feed} {$details['name']}"; 94 | if ($details['color']) { 95 | $line .= "[{$details['color']}]"; 96 | } 97 | 98 | $tags = $details['tags']; 99 | 100 | foreach ($tags as $tag) { 101 | $line .= " {$tag}"; 102 | } 103 | 104 | if ($details['hidden']) { 105 | $line = '-' . $line; 106 | } 107 | 108 | $lines[] = $line; 109 | } 110 | 111 | return implode("\n", $lines); 112 | } 113 | 114 | /** 115 | * Parse out RSS feeds from the given string. 116 | */ 117 | public function parseFromString(string $configString): void 118 | { 119 | $lines = explode("\n", $configString); 120 | 121 | foreach ($lines as $line) { 122 | $line = trim($line); 123 | $hidden = false; 124 | 125 | if (str_starts_with($line, '-')) { 126 | $hidden = true; 127 | $line = ltrim($line, '- '); 128 | } 129 | 130 | $parts = explode(' ', $line); 131 | if (empty($line) || str_starts_with($line, '#') || count($parts) < 2) { 132 | continue; 133 | } 134 | 135 | $url = $parts[0]; 136 | $name = $parts[1]; 137 | $color = ''; 138 | 139 | $matches = []; 140 | if (preg_match('/^(.*)\[(.*)\]$/', $name, $matches)) { 141 | $name = $matches[1]; 142 | $color = $matches[2]; 143 | } 144 | 145 | $name = str_replace('_', ' ', $name); 146 | $tags = array_filter(array_slice($parts, 2), fn ($str) => str_starts_with($str, '#')); 147 | 148 | if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) { 149 | $this->addFeed($url, $name, $tags, $color, $hidden); 150 | } 151 | } 152 | } 153 | 154 | /** 155 | * Encode to be used for a URL. 156 | * Attempts two different formats, url encoded vs compressed, and 157 | * returns the smaller. 158 | * URL encoded format is prefixed with a 't'. 159 | * Compressed format is prefixed with a 'c'. 160 | */ 161 | public function encodeForUrl(): string 162 | { 163 | $configString = $this->toString(); 164 | 165 | $urlEncoded = 't' . urlencode($configString); 166 | $compressed = 'c' . base64_encode(gzcompress($configString)); 167 | 168 | return strlen($urlEncoded) > strlen($compressed) ? $compressed : $urlEncoded; 169 | } 170 | 171 | /** 172 | * Decode the config from the given URL encoded config string that's 173 | * been created using the `encodeForUrl()` function. 174 | */ 175 | public function decodeFromUrl(string $urlConfigString): void 176 | { 177 | if (empty($urlConfigString)) { 178 | return; 179 | } 180 | 181 | $typeByte = $urlConfigString[0]; 182 | $configStr = ''; 183 | 184 | if ($typeByte === 't') { 185 | $configStr = urldecode(substr($urlConfigString, 1)); 186 | } 187 | 188 | if ($typeByte === 'c') { 189 | $configStr = gzuncompress(base64_decode(substr($urlConfigString, 1))); 190 | } 191 | 192 | $this->parseFromString($configStr); 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /app/Console/Commands/PrunePostsCommand.php: -------------------------------------------------------------------------------- 1 | option('days', null); 31 | 32 | if (!is_null($days)) { 33 | $retention = intval($days); 34 | } else { 35 | $configuredPruneDays = config('app.prune_posts_after_days'); 36 | 37 | if (!$configuredPruneDays) { 38 | $this->line("No prune retention time set therefore no posts will be pruned."); 39 | return 0; 40 | } 41 | 42 | $retention = intval($configuredPruneDays); 43 | } 44 | 45 | $acceptsRisk = $this->confirm("This will delete all posts older than {$retention} day(s). Do you want to continue?", true); 46 | 47 | if ($acceptsRisk) { 48 | $deleteCount = $pruner->prune($retention); 49 | $this->line("Deleted {$deleteCount} posts from the system"); 50 | } 51 | 52 | return 0; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/Console/Commands/TestFeedCommand.php: -------------------------------------------------------------------------------- 1 | argument('url'); 31 | 32 | $posts = $postFetcher->fetchForFeed((new Feed())->forceFill(['url' => $url])); 33 | if (count($posts) === 0) { 34 | $this->error('No posts fetched. Either data could not be fetched or the feed data was not recognised as valid.'); 35 | return Command::FAILURE; 36 | } 37 | 38 | $count = count($posts); 39 | $this->line("Found {$count} posts:"); 40 | foreach ($posts as $post) { 41 | $this->line("[{$post->url}] {$post->title}"); 42 | } 43 | 44 | return Command::SUCCESS; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/Console/Commands/UpdateFeedsCommand.php: -------------------------------------------------------------------------------- 1 | getAll(); 30 | $feeds->reloadOutdatedFeeds(); 31 | 32 | return 0; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /app/Http/Controllers/Controller.php: -------------------------------------------------------------------------------- 1 | get('url', ''); 19 | $feed = $this->feedProvider->get($url); 20 | if (is_null($feed)) { 21 | return response()->json(null, 404); 22 | } 23 | 24 | return response()->json($feed); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /app/Http/Controllers/PostViewController.php: -------------------------------------------------------------------------------- 1 | feedProvider->getAll(); 23 | $displayFeeds->reloadOutdatedFeeds(); 24 | 25 | $postFeeds = $this->feedProvider->getVisible(); 26 | 27 | return $this->renderPostsView($request, $displayFeeds, $postFeeds); 28 | } 29 | 30 | public function tag(Request $request, string $tag) 31 | { 32 | $feeds = $this->feedProvider->getForTag('#' . $tag); 33 | $feeds->reloadOutdatedFeeds(); 34 | 35 | return $this->renderPostsView($request, $feeds, $feeds, ['tag' => $tag]); 36 | } 37 | 38 | public function feed(Request $request, string $feed) 39 | { 40 | $feed = urldecode($feed); 41 | 42 | $feeds = $this->feedProvider->getAsList($feed); 43 | $feeds->reloadOutdatedFeeds(); 44 | 45 | return $this->renderPostsView($request, $feeds, $feeds, ['feed' => $feed]); 46 | } 47 | 48 | protected function renderPostsView(Request $request, ConfiguredFeedList $displayFeeds, ConfiguredFeedList $postFeeds, array $additionalData = []) 49 | { 50 | $page = max(intval($request->get('page')), 1); 51 | $query = $request->get('query', ''); 52 | $subFilter = null; 53 | 54 | if ($query) { 55 | $subFilter = function (Builder $where) use ($query) { 56 | $where->where('title', 'like', '%' . $query . '%') 57 | ->orWhere('description', 'like', '%' . $query . '%'); 58 | }; 59 | } 60 | 61 | $posts = $this->postProvider->getLatest( 62 | $postFeeds, 63 | 100, 64 | $page, 65 | $subFilter 66 | ); 67 | 68 | $coreData = [ 69 | 'feeds' => $displayFeeds, 70 | 'posts' => $posts, 71 | 'page' => $page, 72 | 'search' => $query, 73 | 'tag' => '', 74 | 'feed' => '', 75 | ]; 76 | 77 | return Inertia::render('Posts', array_merge($coreData, $additionalData)); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /app/Http/Middleware/HandleInertiaRequests.php: -------------------------------------------------------------------------------- 1 | |string|null 14 | */ 15 | protected $proxies = '*'; 16 | 17 | /** 18 | * The headers that should be used to detect proxies. 19 | * 20 | * @var int 21 | */ 22 | protected $headers = 23 | Request::HEADER_X_FORWARDED_FOR | 24 | Request::HEADER_X_FORWARDED_HOST | 25 | Request::HEADER_X_FORWARDED_PORT | 26 | Request::HEADER_X_FORWARDED_PROTO | 27 | Request::HEADER_X_FORWARDED_PREFIX | 28 | Request::HEADER_X_FORWARDED_AWS_ELB; 29 | } 30 | -------------------------------------------------------------------------------- /app/Jobs/FetchPostThumbnailJob.php: -------------------------------------------------------------------------------- 1 | fetchAndStoreForPost($this->post); 42 | } 43 | 44 | /** 45 | * Get the unique key for these jobs. 46 | */ 47 | public function uniqueId(): string 48 | { 49 | return strval($this->post->id); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/Jobs/RefreshFeedJob.php: -------------------------------------------------------------------------------- 1 | fetchForFeed($this->feed); 42 | $loadThumbs = config('app.load_post_thumbnails', false); 43 | 44 | foreach ($freshPosts as $post) { 45 | $post = $this->feed->posts()->updateOrCreate( 46 | ['guid' => $post->guid], 47 | $post->getAttributes(), 48 | ); 49 | 50 | if ($loadThumbs && $post->wasRecentlyCreated) { 51 | dispatch(new FetchPostThumbnailJob($post)); 52 | } 53 | } 54 | 55 | $this->feed->last_fetched_at = time(); 56 | $this->feed->save(); 57 | } 58 | 59 | /** 60 | * Get the unique key for these jobs. 61 | */ 62 | public function uniqueId(): string 63 | { 64 | return strval($this->feed->id); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/Models/Feed.php: -------------------------------------------------------------------------------- 1 | hasMany(Post::class); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /app/Models/Post.php: -------------------------------------------------------------------------------- 1 | app->singleton(ConfiguredFeedProvider::class, function ($app) { 16 | $provider = new ConfiguredFeedProvider(); 17 | $provider->loadFromConfig(); 18 | return $provider; 19 | }); 20 | } 21 | 22 | /** 23 | * Bootstrap any application services. 24 | */ 25 | public function boot(): void 26 | { 27 | // 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/Rss/FeedPostFetcher.php: -------------------------------------------------------------------------------- 1 | withUserAgent('rss/4.5.6')->get($feed->url); 22 | if (!$feedResponse->successful()) { 23 | return []; 24 | } 25 | 26 | $rssData = ltrim($feedResponse->body()); 27 | $tagStart = explode(' ', substr($rssData, 0, 20))[0]; 28 | $validStarts = ['rssParser->rssDataToPosts($rssData); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /app/Rss/PostProvider.php: -------------------------------------------------------------------------------- 1 | when($condition, $condition) 15 | ->whereIn('feed_id', $feeds->getFeedIds()) 16 | ->orderBy('published_at', 'desc') 17 | ->take($count) 18 | ->skip(($page - 1) * $count) 19 | ->get(); 20 | 21 | $this->loadFeedsToPostCollection($posts, $feeds); 22 | 23 | return $posts; 24 | } 25 | 26 | /** 27 | * @param Collection $posts 28 | */ 29 | protected function loadFeedsToPostCollection(Collection $posts, ConfiguredFeedList $feeds): void 30 | { 31 | $feedsById = $feeds->getMappedById(); 32 | 33 | foreach ($posts as $post) { 34 | $post->setAttribute('feed', $feedsById[$post->feed_id] ?? []); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /app/Rss/PostPruner.php: -------------------------------------------------------------------------------- 1 | where('published_at', '<', $oldestAcceptable) 23 | ->select(['id', 'thumbnail']) 24 | ->chunk(250, function (Collection $posts) use (&$ids) { 25 | array_push($ids, ...$posts->pluck('id')->all()); 26 | $this->deletePostsThumbnails($posts); 27 | }); 28 | 29 | foreach (array_chunk($ids, 250) as $idChunk) { 30 | Post::query() 31 | ->whereIn('id', $idChunk) 32 | ->delete(); 33 | } 34 | 35 | return count($ids); 36 | } 37 | 38 | /** 39 | * @param Collection $posts 40 | */ 41 | protected function deletePostsThumbnails(Collection $posts) 42 | { 43 | $storage = Storage::disk('public'); 44 | 45 | foreach ($posts as $post) { 46 | if ($post->thumbnail && $storage->exists($post->thumbnail)) { 47 | $storage->delete($post->thumbnail); 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /app/Rss/PostThumbnailFetcher.php: -------------------------------------------------------------------------------- 1 | getThumbLinkFromUrl($post->url); 14 | if (!$imageUrl) { 15 | return false; 16 | } 17 | 18 | $imageInfo = $this->downloadImageFromUrl($imageUrl); 19 | if (!$imageInfo) { 20 | return false; 21 | } 22 | 23 | $path = "thumbs/{$post->feed_id}/{$post->id}.{$imageInfo['extension']}"; 24 | $complete = Storage::disk('public')->put($path, $imageInfo['data']); 25 | if (!$complete) { 26 | return false; 27 | } 28 | 29 | $post->thumbnail = $path; 30 | $post->save(); 31 | 32 | return true; 33 | } 34 | 35 | /** 36 | * @return null|array{extension: string, data: string} 37 | */ 38 | protected function downloadImageFromUrl(string $url): ?array 39 | { 40 | $imageResponse = Http::timeout(10)->withUserAgent('rss/4.5.6')->get($url); 41 | if (!$imageResponse->successful()) { 42 | return null; 43 | } 44 | 45 | $imageData = $imageResponse->body(); 46 | // > 1MB 47 | if (strlen($imageData) > 1000000) { 48 | return null; 49 | } 50 | 51 | $tempFile = tmpfile(); 52 | fwrite($tempFile, $imageData); 53 | $mimeSplit = explode('/', mime_content_type($tempFile) ?: ''); 54 | if (count($mimeSplit) < 2 || $mimeSplit[0] !== 'image') { 55 | return null; 56 | } 57 | 58 | $extension = $mimeSplit[1]; 59 | return ['data' => $imageData, 'extension' => $extension]; 60 | } 61 | 62 | protected function getThumbLinkFromUrl(string $url): string 63 | { 64 | $pageResponse = Http::timeout(10)->withUserAgent('rss/4.5.6')->get($url); 65 | if (!$pageResponse->successful()) { 66 | return ''; 67 | } 68 | 69 | $postHead = substr($pageResponse->body(), 0, 1000000); 70 | $metaMatches = []; 71 | $metaPattern = '/]*property=["\']og:image["\'].*?>/'; 72 | if (!preg_match($metaPattern, $postHead, $metaMatches)) { 73 | return ''; 74 | } 75 | 76 | $linkMatches = []; 77 | $linkPattern = '/content=["\'](.*?)["\']/'; 78 | if (!preg_match($linkPattern, $metaMatches[0], $linkMatches)) { 79 | return ''; 80 | } 81 | 82 | $link = html_entity_decode($linkMatches[1]); 83 | 84 | if (!str_starts_with($link, 'https://') && !str_starts_with($link, 'http://')) { 85 | return ''; 86 | } 87 | 88 | return $link; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /app/Rss/RssParser.php: -------------------------------------------------------------------------------- 1 | parseRssDataToPosts($rssData); 19 | } catch (Exception $exception) { 20 | return []; 21 | } 22 | } 23 | 24 | /** 25 | * @return Post[] 26 | * @throws Exception 27 | */ 28 | public function parseRssDataToPosts(string $rssData): array 29 | { 30 | $rssData = trim($rssData); 31 | 32 | $rssXml = new SimpleXMLElement($rssData); 33 | $items = is_iterable($rssXml->channel->item ?? null) ? iterator_to_array($rssXml->channel->item, false) : []; 34 | 35 | $isAtom = false; 36 | if (empty($items)) { 37 | $items = is_iterable($rssXml->entry ?? null) ? iterator_to_array($rssXml->entry, false) : []; 38 | $isAtom = true; 39 | } 40 | 41 | $posts = []; 42 | 43 | foreach ($items as $item) { 44 | $postData = $isAtom ? $this->getPostDataForAtomItem($item) : $this->getPostDataForRssItem($item); 45 | 46 | if (!$this->isValidPostData($postData)) { 47 | continue; 48 | } 49 | 50 | $posts[] = (new Post())->forceFill($postData); 51 | } 52 | 53 | return $posts; 54 | } 55 | 56 | protected function getPostDataForRssItem(SimpleXMLElement $item): array 57 | { 58 | $date = DateTime::createFromFormat(DateTime::RSS, $item->pubDate ?? ''); 59 | $item = [ 60 | 'title' => mb_substr(strval($item->title ?? ''), 0, 250), 61 | 'description' => $this->formatDescription(strval($item->description) ?: ''), 62 | 'url' => strval($item->link ?? ''), 63 | 'guid' => strval($item->guid ?? ''), 64 | 'published_at' => $date ? $date->getTimestamp() : 0, 65 | ]; 66 | 67 | if (empty($item['guid'])) { 68 | $item['guid'] = $item['url']; 69 | } 70 | 71 | return $item; 72 | } 73 | 74 | protected function formatDescription(string $description): string 75 | { 76 | $decoded = trim(html_entity_decode(strip_tags($description))); 77 | $decoded = preg_replace('/\s+/', ' ', $decoded); 78 | 79 | if (strlen($decoded) > 200) { 80 | return mb_substr($decoded, 0, 200) . '...'; 81 | } 82 | 83 | return $decoded; 84 | } 85 | 86 | protected function getPostDataForAtomItem(SimpleXMLElement $item): array 87 | { 88 | $date = new DateTime(strval($item->published ?? $item->updated ?? '')); 89 | return [ 90 | 'title' => html_entity_decode(mb_substr(strval($item->title ?? ''), 0, 250)), 91 | 'description' => $this->formatDescription(strval($item->summary) ?: strval($item->content) ?: ''), 92 | 'url' => $item->link ? strval($item->link->attributes()['href']) : '', 93 | 'guid' => strval($item->id ?? ''), 94 | 'published_at' => $date ? $date->getTimestamp() : 0, 95 | ]; 96 | } 97 | 98 | /** 99 | * @param array{title: string, description: string, url: string, published_at: int} $item 100 | */ 101 | protected function isValidPostData(array $item): bool 102 | { 103 | if (empty($item['title']) || empty($item['url']) || empty($item['guid'])) { 104 | return false; 105 | } 106 | 107 | if (!str_starts_with($item['url'], 'https://') && !str_starts_with($item['url'], 'http://')) { 108 | return false; 109 | } 110 | 111 | if ($item['published_at'] <= 1000) { 112 | return false; 113 | } 114 | 115 | return true; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /artisan: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | handleCommand(new ArgvInput); 14 | 15 | exit($status); 16 | -------------------------------------------------------------------------------- /bootstrap/app.php: -------------------------------------------------------------------------------- 1 | withRouting( 9 | web: __DIR__.'/../routes/web.php', 10 | commands: __DIR__.'/../routes/console.php', 11 | health: '/up', 12 | ) 13 | ->withMiddleware(function (Middleware $middleware) { 14 | $middleware->use([ 15 | // \Illuminate\Http\Middleware\TrustHosts::class, 16 | \App\Http\Middleware\TrustProxies::class, 17 | \Illuminate\Http\Middleware\HandleCors::class, 18 | \Illuminate\Foundation\Http\Middleware\PreventRequestsDuringMaintenance::class, 19 | \Illuminate\Http\Middleware\ValidatePostSize::class, 20 | \Illuminate\Foundation\Http\Middleware\TrimStrings::class, 21 | \Illuminate\Foundation\Http\Middleware\ConvertEmptyStringsToNull::class, 22 | ]); 23 | }) 24 | ->withExceptions(function (Exceptions $exceptions) { 25 | // 26 | })->create(); 27 | -------------------------------------------------------------------------------- /bootstrap/cache/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /bootstrap/providers.php: -------------------------------------------------------------------------------- 1 | env('APP_NAME', 'RSS'), 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Application Configuration File 24 | |-------------------------------------------------------------------------- 25 | | 26 | | This value specifies the path to an RSS config file on the local 27 | | filesystem to be loaded as a default option if there's no 28 | | config set for the current user-level session. 29 | | 30 | */ 31 | 32 | 'config_file' => env('APP_CONFIG_FILE', storage_path('feeds.txt')), 33 | 34 | /* 35 | |-------------------------------------------------------------------------- 36 | | Application Load Post Thumbnails 37 | |-------------------------------------------------------------------------- 38 | | 39 | | This value specifies whether the application should fetch and 40 | | download RSS post thumbnails since this can take up space 41 | | and require extra time to process feeds. 42 | | 43 | */ 44 | 45 | 'load_post_thumbnails' => env('APP_LOAD_POST_THUMBNAILS', true), 46 | 47 | 48 | /* 49 | |-------------------------------------------------------------------------- 50 | | Application Feed Update Frequency 51 | |-------------------------------------------------------------------------- 52 | | 53 | | This value specifies how often a feed should be updated. This is not a 54 | | specific guarantee of update on this interval but instead the age in 55 | | minutes of when a feed would be considered outdated. 56 | | 57 | */ 58 | 59 | 'feed_update_frequency' => env('APP_FEED_UPDATE_FREQUENCY', 60), 60 | 61 | /* 62 | |-------------------------------------------------------------------------- 63 | | Application Prune Posts After Days 64 | |-------------------------------------------------------------------------- 65 | | 66 | | How many days old posts should exist before they're pruned from the system. 67 | | Setting this to false will disable any auto-pruning otherwise pruning 68 | | will run on a daily basis. 69 | | 70 | */ 71 | 72 | 'prune_posts_after_days' => env('APP_PRUNE_POSTS_AFTER_DAYS', false), 73 | 74 | 75 | /* 76 | |-------------------------------------------------------------------------- 77 | | Application Environment 78 | |-------------------------------------------------------------------------- 79 | | 80 | | This value determines the "environment" your application is currently 81 | | running in. This may determine how you prefer to configure various 82 | | services the application utilizes. Set this in your ".env" file. 83 | | 84 | */ 85 | 86 | 'env' => env('APP_ENV', 'production'), 87 | 88 | /* 89 | |-------------------------------------------------------------------------- 90 | | Application Debug Mode 91 | |-------------------------------------------------------------------------- 92 | | 93 | | When your application is in debug mode, detailed error messages with 94 | | stack traces will be shown on every error that occurs within your 95 | | application. If disabled, a simple generic error page is shown. 96 | | 97 | */ 98 | 99 | 'debug' => (bool) env('APP_DEBUG', false), 100 | 101 | /* 102 | |-------------------------------------------------------------------------- 103 | | Application URL 104 | |-------------------------------------------------------------------------- 105 | | 106 | | This URL is used by the console to properly generate URLs when using 107 | | the Artisan command line tool. You should set this to the root of 108 | | your application so that it is used when running Artisan tasks. 109 | | 110 | */ 111 | 112 | 'url' => '', 113 | 'asset_url' => '', 114 | 115 | /* 116 | |-------------------------------------------------------------------------- 117 | | Application Timezone 118 | |-------------------------------------------------------------------------- 119 | | 120 | | Here you may specify the default timezone for your application, which 121 | | will be used by the PHP date and date-time functions. We have gone 122 | | ahead and set this to a sensible default for you out of the box. 123 | | 124 | */ 125 | 126 | 'timezone' => 'UTC', 127 | 128 | /* 129 | |-------------------------------------------------------------------------- 130 | | Application Locale Configuration 131 | |-------------------------------------------------------------------------- 132 | | 133 | | The application locale determines the default locale that will be used 134 | | by the translation service provider. You are free to set this value 135 | | to any of the locales which will be supported by the application. 136 | | 137 | */ 138 | 139 | 'locale' => 'en', 140 | 141 | /* 142 | |-------------------------------------------------------------------------- 143 | | Application Fallback Locale 144 | |-------------------------------------------------------------------------- 145 | | 146 | | The fallback locale determines the locale to use when the current one 147 | | is not available. You may change the value to correspond to any of 148 | | the language folders that are provided through your application. 149 | | 150 | */ 151 | 152 | 'fallback_locale' => 'en', 153 | 154 | /* 155 | |-------------------------------------------------------------------------- 156 | | Faker Locale 157 | |-------------------------------------------------------------------------- 158 | | 159 | | This locale will be used by the Faker PHP library when generating fake 160 | | data for your database seeds. For example, this will be used to get 161 | | localized telephone numbers, street address information and more. 162 | | 163 | */ 164 | 165 | 'faker_locale' => 'en_US', 166 | 167 | /* 168 | |-------------------------------------------------------------------------- 169 | | Encryption Key 170 | |-------------------------------------------------------------------------- 171 | | 172 | | This key is utilized by Laravel's encryption services and should be set 173 | | to a random, 32 character string to ensure that all encrypted values 174 | | are secure. You should do this prior to deploying the application. 175 | | 176 | */ 177 | 178 | 'cipher' => 'AES-256-CBC', 179 | 180 | 'key' => env('APP_KEY'), 181 | 182 | 'previous_keys' => [ 183 | ...array_filter( 184 | explode(',', env('APP_PREVIOUS_KEYS', '')) 185 | ), 186 | ], 187 | 188 | /* 189 | |-------------------------------------------------------------------------- 190 | | Maintenance Mode Driver 191 | |-------------------------------------------------------------------------- 192 | | 193 | | These configuration options determine the driver used to determine and 194 | | manage Laravel's "maintenance mode" status. The "cache" driver will 195 | | allow maintenance mode to be controlled across multiple machines. 196 | | 197 | | Supported drivers: "file", "cache" 198 | | 199 | */ 200 | 201 | 'maintenance' => [ 202 | 'driver' => env('APP_MAINTENANCE_DRIVER', 'file'), 203 | 'store' => env('APP_MAINTENANCE_STORE', 'database'), 204 | ], 205 | 206 | ]; 207 | -------------------------------------------------------------------------------- /config/auth.php: -------------------------------------------------------------------------------- 1 | [ 17 | 'guard' => env('AUTH_GUARD', 'web'), 18 | 'passwords' => env('AUTH_PASSWORD_BROKER', 'users'), 19 | ], 20 | 21 | /* 22 | |-------------------------------------------------------------------------- 23 | | Authentication Guards 24 | |-------------------------------------------------------------------------- 25 | | 26 | | Next, you may define every authentication guard for your application. 27 | | Of course, a great default configuration has been defined for you 28 | | which utilizes session storage plus the Eloquent user provider. 29 | | 30 | | All authentication guards have a user provider, which defines how the 31 | | users are actually retrieved out of your database or other storage 32 | | system used by the application. Typically, Eloquent is utilized. 33 | | 34 | | Supported: "session" 35 | | 36 | */ 37 | 38 | 'guards' => [ 39 | 'web' => [ 40 | 'driver' => 'session', 41 | 'provider' => 'users', 42 | ], 43 | ], 44 | 45 | /* 46 | |-------------------------------------------------------------------------- 47 | | User Providers 48 | |-------------------------------------------------------------------------- 49 | | 50 | | All authentication guards have a user provider, which defines how the 51 | | users are actually retrieved out of your database or other storage 52 | | system used by the application. Typically, Eloquent is utilized. 53 | | 54 | | If you have multiple user tables or models you may configure multiple 55 | | providers to represent the model / table. These providers may then 56 | | be assigned to any extra authentication guards you have defined. 57 | | 58 | | Supported: "database", "eloquent" 59 | | 60 | */ 61 | 62 | 'providers' => [ 63 | 'users' => [ 64 | 'driver' => 'eloquent', 65 | 'model' => env('AUTH_MODEL', App\Models\User::class), 66 | ], 67 | 68 | // 'users' => [ 69 | // 'driver' => 'database', 70 | // 'table' => 'users', 71 | // ], 72 | ], 73 | 74 | /* 75 | |-------------------------------------------------------------------------- 76 | | Resetting Passwords 77 | |-------------------------------------------------------------------------- 78 | | 79 | | These configuration options specify the behavior of Laravel's password 80 | | reset functionality, including the table utilized for token storage 81 | | and the user provider that is invoked to actually retrieve users. 82 | | 83 | | The expiry time is the number of minutes that each reset token will be 84 | | considered valid. This security feature keeps tokens short-lived so 85 | | they have less time to be guessed. You may change this as needed. 86 | | 87 | | The throttle setting is the number of seconds a user must wait before 88 | | generating more password reset tokens. This prevents the user from 89 | | quickly generating a very large amount of password reset tokens. 90 | | 91 | */ 92 | 93 | 'passwords' => [ 94 | 'users' => [ 95 | 'provider' => 'users', 96 | 'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'), 97 | 'expire' => 60, 98 | 'throttle' => 60, 99 | ], 100 | ], 101 | 102 | /* 103 | |-------------------------------------------------------------------------- 104 | | Password Confirmation Timeout 105 | |-------------------------------------------------------------------------- 106 | | 107 | | Here you may define the amount of seconds before a password confirmation 108 | | window expires and users are asked to re-enter their password via the 109 | | confirmation screen. By default, the timeout lasts for three hours. 110 | | 111 | */ 112 | 113 | 'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800), 114 | 115 | ]; 116 | -------------------------------------------------------------------------------- /config/cache.php: -------------------------------------------------------------------------------- 1 | 'file', 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Cache Stores 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here you may define all of the cache "stores" for your application as 26 | | well as their drivers. You may even define multiple stores for the 27 | | same cache driver to group types of items stored in your caches. 28 | | 29 | | Supported drivers: "apc", "array", "database", "file", "memcached", 30 | | "redis", "dynamodb", "octane", "null" 31 | | 32 | */ 33 | 34 | 'stores' => [ 35 | 36 | 'array' => [ 37 | 'driver' => 'array', 38 | 'serialize' => false, 39 | ], 40 | 41 | 'database' => [ 42 | 'driver' => 'database', 43 | 'table' => env('DB_CACHE_TABLE', 'cache'), 44 | 'connection' => env('DB_CACHE_CONNECTION'), 45 | 'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'), 46 | ], 47 | 48 | 'file' => [ 49 | 'driver' => 'file', 50 | 'path' => storage_path('framework/cache/data'), 51 | 'lock_path' => storage_path('framework/cache/data'), 52 | ], 53 | 54 | 'memcached' => [ 55 | 'driver' => 'memcached', 56 | 'persistent_id' => env('MEMCACHED_PERSISTENT_ID'), 57 | 'sasl' => [ 58 | env('MEMCACHED_USERNAME'), 59 | env('MEMCACHED_PASSWORD'), 60 | ], 61 | 'options' => [ 62 | // Memcached::OPT_CONNECT_TIMEOUT => 2000, 63 | ], 64 | 'servers' => [ 65 | [ 66 | 'host' => env('MEMCACHED_HOST', '127.0.0.1'), 67 | 'port' => env('MEMCACHED_PORT', 11211), 68 | 'weight' => 100, 69 | ], 70 | ], 71 | ], 72 | 73 | 'redis' => [ 74 | 'driver' => 'redis', 75 | 'connection' => env('REDIS_CACHE_CONNECTION', 'cache'), 76 | 'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'), 77 | ], 78 | 79 | 'dynamodb' => [ 80 | 'driver' => 'dynamodb', 81 | 'key' => env('AWS_ACCESS_KEY_ID'), 82 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 83 | 'region' => env('AWS_DEFAULT_REGION', 'us-east-1'), 84 | 'table' => env('DYNAMODB_CACHE_TABLE', 'cache'), 85 | 'endpoint' => env('DYNAMODB_ENDPOINT'), 86 | ], 87 | 88 | 'octane' => [ 89 | 'driver' => 'octane', 90 | ], 91 | 92 | ], 93 | 94 | /* 95 | |-------------------------------------------------------------------------- 96 | | Cache Key Prefix 97 | |-------------------------------------------------------------------------- 98 | | 99 | | When utilizing the APC, database, memcached, Redis, and DynamoDB cache 100 | | stores, there might be other applications using the same cache. For 101 | | that reason, you may prefix every cache key to avoid collisions. 102 | | 103 | */ 104 | 105 | 'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'laravel'), '_').'_cache_'), 106 | 107 | ]; 108 | -------------------------------------------------------------------------------- /config/database.php: -------------------------------------------------------------------------------- 1 | 'sqlite', 19 | 20 | /* 21 | |-------------------------------------------------------------------------- 22 | | Database Connections 23 | |-------------------------------------------------------------------------- 24 | | 25 | | Here are each of the database connections setup for your application. 26 | | Of course, examples of configuring each database platform that is 27 | | supported by Laravel is shown below to make development simple. 28 | | 29 | | 30 | | All database work in Laravel is done through the PHP PDO facilities 31 | | so make sure you have the driver for your particular database of 32 | | choice installed on your machine before you begin development. 33 | | 34 | */ 35 | 36 | 'connections' => [ 37 | 38 | 'sqlite' => [ 39 | 'driver' => 'sqlite', 40 | // 'url' => env('DATABASE_URL'), 41 | 'database' => env('DB_DATABASE', storage_path('database/database.sqlite')), 42 | 'prefix' => '', 43 | 'foreign_key_constraints' => true, 44 | ], 45 | 46 | ], 47 | 48 | /* 49 | |-------------------------------------------------------------------------- 50 | | Migration Repository Table 51 | |-------------------------------------------------------------------------- 52 | | 53 | | This table keeps track of all the migrations that have already run for 54 | | your application. Using this information, we can determine which of 55 | | the migrations on disk haven't actually been run on the database. 56 | | 57 | */ 58 | 59 | 'migrations' => [ 60 | 'table' => 'migrations', 61 | 'update_date_on_publish' => true, 62 | ], 63 | 64 | ]; 65 | -------------------------------------------------------------------------------- /config/filesystems.php: -------------------------------------------------------------------------------- 1 | env('FILESYSTEM_DISK', 'local'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Filesystem Disks 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may configure as many filesystem "disks" as you wish, and you 24 | | may even configure multiple disks of the same driver. Defaults have 25 | | been set up for each driver as an example of the required values. 26 | | 27 | | Supported Drivers: "local", "ftp", "sftp", "s3" 28 | | 29 | */ 30 | 31 | 'disks' => [ 32 | 33 | 'local' => [ 34 | 'driver' => 'local', 35 | 'root' => storage_path('app'), 36 | 'throw' => false, 37 | ], 38 | 39 | 'public' => [ 40 | 'driver' => 'local', 41 | 'root' => storage_path('app/public'), 42 | 'url' => env('APP_URL').'/storage', 43 | 'visibility' => 'public', 44 | 'throw' => false, 45 | ], 46 | 47 | 's3' => [ 48 | 'driver' => 's3', 49 | 'key' => env('AWS_ACCESS_KEY_ID'), 50 | 'secret' => env('AWS_SECRET_ACCESS_KEY'), 51 | 'region' => env('AWS_DEFAULT_REGION'), 52 | 'bucket' => env('AWS_BUCKET'), 53 | 'url' => env('AWS_URL'), 54 | 'endpoint' => env('AWS_ENDPOINT'), 55 | 'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false), 56 | 'throw' => false, 57 | ], 58 | 59 | ], 60 | 61 | /* 62 | |-------------------------------------------------------------------------- 63 | | Symbolic Links 64 | |-------------------------------------------------------------------------- 65 | | 66 | | Here you may configure the symbolic links that will be created when the 67 | | `storage:link` Artisan command is executed. The array keys should be 68 | | the locations of the links and the values should be their targets. 69 | | 70 | */ 71 | 72 | 'links' => [ 73 | public_path('storage') => storage_path('app/public'), 74 | ], 75 | 76 | ]; 77 | -------------------------------------------------------------------------------- /config/logging.php: -------------------------------------------------------------------------------- 1 | env('LOG_CHANNEL', 'stack'), 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | Deprecations Log Channel 26 | |-------------------------------------------------------------------------- 27 | | 28 | | This option controls the log channel that should be used to log warnings 29 | | regarding deprecated PHP and library features. This allows you to get 30 | | your application ready for upcoming major versions of dependencies. 31 | | 32 | */ 33 | 34 | 'deprecations' => [ 35 | 'channel' => env('LOG_DEPRECATIONS_CHANNEL', 'null'), 36 | 'trace' => env('LOG_DEPRECATIONS_TRACE', false), 37 | ], 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Log Channels 42 | |-------------------------------------------------------------------------- 43 | | 44 | | Here you may configure the log channels for your application. Laravel 45 | | utilizes the Monolog PHP logging library, which includes a variety 46 | | of powerful log handlers and formatters that you're free to use. 47 | | 48 | | Available Drivers: "single", "daily", "slack", "syslog", 49 | | "errorlog", "monolog", "custom", "stack" 50 | | 51 | */ 52 | 53 | 'channels' => [ 54 | 55 | 'stack' => [ 56 | 'driver' => 'stack', 57 | 'channels' => explode(',', env('LOG_STACK', 'single')), 58 | 'ignore_exceptions' => false, 59 | ], 60 | 61 | 'single' => [ 62 | 'driver' => 'single', 63 | 'path' => storage_path('logs/laravel.log'), 64 | 'level' => env('LOG_LEVEL', 'debug'), 65 | 'replace_placeholders' => true, 66 | ], 67 | 68 | 'daily' => [ 69 | 'driver' => 'daily', 70 | 'path' => storage_path('logs/laravel.log'), 71 | 'level' => env('LOG_LEVEL', 'debug'), 72 | 'days' => env('LOG_DAILY_DAYS', 14), 73 | 'replace_placeholders' => true, 74 | ], 75 | 76 | 'slack' => [ 77 | 'driver' => 'slack', 78 | 'url' => env('LOG_SLACK_WEBHOOK_URL'), 79 | 'username' => env('LOG_SLACK_USERNAME', 'Laravel Log'), 80 | 'emoji' => env('LOG_SLACK_EMOJI', ':boom:'), 81 | 'level' => env('LOG_LEVEL', 'critical'), 82 | 'replace_placeholders' => true, 83 | ], 84 | 85 | 'papertrail' => [ 86 | 'driver' => 'monolog', 87 | 'level' => env('LOG_LEVEL', 'debug'), 88 | 'handler' => env('LOG_PAPERTRAIL_HANDLER', SyslogUdpHandler::class), 89 | 'handler_with' => [ 90 | 'host' => env('PAPERTRAIL_URL'), 91 | 'port' => env('PAPERTRAIL_PORT'), 92 | 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), 93 | ], 94 | 'processors' => [PsrLogMessageProcessor::class], 95 | ], 96 | 97 | 'stderr' => [ 98 | 'driver' => 'monolog', 99 | 'level' => env('LOG_LEVEL', 'debug'), 100 | 'handler' => StreamHandler::class, 101 | 'formatter' => env('LOG_STDERR_FORMATTER'), 102 | 'with' => [ 103 | 'stream' => 'php://stderr', 104 | ], 105 | 'processors' => [PsrLogMessageProcessor::class], 106 | ], 107 | 108 | 'syslog' => [ 109 | 'driver' => 'syslog', 110 | 'level' => env('LOG_LEVEL', 'debug'), 111 | 'facility' => env('LOG_SYSLOG_FACILITY', LOG_USER), 112 | 'replace_placeholders' => true, 113 | ], 114 | 115 | 'errorlog' => [ 116 | 'driver' => 'errorlog', 117 | 'level' => env('LOG_LEVEL', 'debug'), 118 | 'replace_placeholders' => true, 119 | ], 120 | 121 | 'null' => [ 122 | 'driver' => 'monolog', 123 | 'handler' => NullHandler::class, 124 | ], 125 | 126 | 'emergency' => [ 127 | 'path' => storage_path('logs/laravel.log'), 128 | ], 129 | 130 | ], 131 | 132 | ]; 133 | -------------------------------------------------------------------------------- /config/mail.php: -------------------------------------------------------------------------------- 1 | env('MAIL_MAILER', 'log'), 18 | 19 | /* 20 | |-------------------------------------------------------------------------- 21 | | Mailer Configurations 22 | |-------------------------------------------------------------------------- 23 | | 24 | | Here you may configure all of the mailers used by your application plus 25 | | their respective settings. Several examples have been configured for 26 | | you and you are free to add your own as your application requires. 27 | | 28 | | Laravel supports a variety of mail "transport" drivers that can be used 29 | | when delivering an email. You may specify which one you're using for 30 | | your mailers below. You may also add additional mailers if needed. 31 | | 32 | | Supported: "smtp", "sendmail", "mailgun", "ses", "ses-v2", 33 | | "postmark", "log", "array", "failover", "roundrobin" 34 | | 35 | */ 36 | 37 | 'mailers' => [ 38 | 39 | 'smtp' => [ 40 | 'transport' => 'smtp', 41 | 'url' => env('MAIL_URL'), 42 | 'host' => env('MAIL_HOST', '127.0.0.1'), 43 | 'port' => env('MAIL_PORT', 2525), 44 | 'encryption' => env('MAIL_ENCRYPTION', 'tls'), 45 | 'username' => env('MAIL_USERNAME'), 46 | 'password' => env('MAIL_PASSWORD'), 47 | 'timeout' => null, 48 | 'local_domain' => env('MAIL_EHLO_DOMAIN'), 49 | ], 50 | 51 | 'ses' => [ 52 | 'transport' => 'ses', 53 | ], 54 | 55 | 'postmark' => [ 56 | 'transport' => 'postmark', 57 | // 'message_stream_id' => env('POSTMARK_MESSAGE_STREAM_ID'), 58 | // 'client' => [ 59 | // 'timeout' => 5, 60 | // ], 61 | ], 62 | 63 | 'sendmail' => [ 64 | 'transport' => 'sendmail', 65 | 'path' => env('MAIL_SENDMAIL_PATH', '/usr/sbin/sendmail -bs -i'), 66 | ], 67 | 68 | 'log' => [ 69 | 'transport' => 'log', 70 | 'channel' => env('MAIL_LOG_CHANNEL'), 71 | ], 72 | 73 | 'array' => [ 74 | 'transport' => 'array', 75 | ], 76 | 77 | 'failover' => [ 78 | 'transport' => 'failover', 79 | 'mailers' => [ 80 | 'smtp', 81 | 'log', 82 | ], 83 | ], 84 | 85 | 'roundrobin' => [ 86 | 'transport' => 'roundrobin', 87 | 'mailers' => [ 88 | 'ses', 89 | 'postmark', 90 | ], 91 | ], 92 | 93 | ], 94 | 95 | /* 96 | |-------------------------------------------------------------------------- 97 | | Global "From" Address 98 | |-------------------------------------------------------------------------- 99 | | 100 | | You may wish for all emails sent by your application to be sent from 101 | | the same address. Here you may specify a name and address that is 102 | | used globally for all emails that are sent by your application. 103 | | 104 | */ 105 | 106 | 'from' => [ 107 | 'address' => env('MAIL_FROM_ADDRESS', 'hello@example.com'), 108 | 'name' => env('MAIL_FROM_NAME', 'Example'), 109 | ], 110 | 111 | ]; 112 | -------------------------------------------------------------------------------- /config/queue.php: -------------------------------------------------------------------------------- 1 | env('QUEUE_CONNECTION', 'database'), 17 | 18 | /* 19 | |-------------------------------------------------------------------------- 20 | | Queue Connections 21 | |-------------------------------------------------------------------------- 22 | | 23 | | Here you may configure the connection options for every queue backend 24 | | used by your application. An example configuration is provided for 25 | | each backend supported by Laravel. You're also free to add more. 26 | | 27 | | Drivers: "sync", "database", "beanstalkd", "sqs", "redis", "null" 28 | | 29 | */ 30 | 31 | 'connections' => [ 32 | 33 | 'sync' => [ 34 | 'driver' => 'sync', 35 | ], 36 | 37 | 'database' => [ 38 | 'driver' => 'database', 39 | 'table' => 'jobs', 40 | 'queue' => 'default', 41 | 'retry_after' => 90, 42 | 'after_commit' => false, 43 | ], 44 | 45 | ], 46 | 47 | /* 48 | |-------------------------------------------------------------------------- 49 | | Job Batching 50 | |-------------------------------------------------------------------------- 51 | | 52 | | The following options configure the database and table that store job 53 | | batching information. These options can be updated to any database 54 | | connection and table which has been defined by your application. 55 | | 56 | */ 57 | 58 | 'batching' => [ 59 | 'database' => env('DB_CONNECTION', 'sqlite'), 60 | 'table' => 'job_batches', 61 | ], 62 | 63 | /* 64 | |-------------------------------------------------------------------------- 65 | | Failed Queue Jobs 66 | |-------------------------------------------------------------------------- 67 | | 68 | | These options configure the behavior of failed queue job logging so you 69 | | can control how and where failed jobs are stored. Laravel ships with 70 | | support for storing failed jobs in a simple file or in a database. 71 | | 72 | | Supported drivers: "database-uuids", "dynamodb", "file", "null" 73 | | 74 | */ 75 | 76 | 'failed' => [ 77 | 'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'), 78 | 'database' => env('DB_CONNECTION', 'sqlite'), 79 | 'table' => 'failed_jobs', 80 | ], 81 | 82 | ]; 83 | -------------------------------------------------------------------------------- /config/session.php: -------------------------------------------------------------------------------- 1 | 'file', 22 | 23 | /* 24 | |-------------------------------------------------------------------------- 25 | | Session Lifetime 26 | |-------------------------------------------------------------------------- 27 | | 28 | | Here you may specify the number of minutes that you wish the session 29 | | to be allowed to remain idle before it expires. If you want them 30 | | to expire immediately when the browser is closed then you may 31 | | indicate that via the expire_on_close configuration option. 32 | | 33 | */ 34 | 35 | 'lifetime' => env('SESSION_LIFETIME', 120), 36 | 37 | 'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false), 38 | 39 | /* 40 | |-------------------------------------------------------------------------- 41 | | Session Encryption 42 | |-------------------------------------------------------------------------- 43 | | 44 | | This option allows you to easily specify that all of your session data 45 | | should be encrypted before it's stored. All encryption is performed 46 | | automatically by Laravel and you may use the session like normal. 47 | | 48 | */ 49 | 50 | 'encrypt' => env('SESSION_ENCRYPT', false), 51 | 52 | /* 53 | |-------------------------------------------------------------------------- 54 | | Session File Location 55 | |-------------------------------------------------------------------------- 56 | | 57 | | When utilizing the "file" session driver, the session files are placed 58 | | on disk. The default storage location is defined here; however, you 59 | | are free to provide another location where they should be stored. 60 | | 61 | */ 62 | 63 | 'files' => storage_path('framework/sessions'), 64 | 65 | /* 66 | |-------------------------------------------------------------------------- 67 | | Session Database Connection 68 | |-------------------------------------------------------------------------- 69 | | 70 | | When using the "database" or "redis" session drivers, you may specify a 71 | | connection that should be used to manage these sessions. This should 72 | | correspond to a connection in your database configuration options. 73 | | 74 | */ 75 | 76 | 'connection' => env('SESSION_CONNECTION'), 77 | 78 | /* 79 | |-------------------------------------------------------------------------- 80 | | Session Database Table 81 | |-------------------------------------------------------------------------- 82 | | 83 | | When using the "database" session driver, you may specify the table to 84 | | be used to store sessions. Of course, a sensible default is defined 85 | | for you; however, you're welcome to change this to another table. 86 | | 87 | */ 88 | 89 | 'table' => env('SESSION_TABLE', 'sessions'), 90 | 91 | /* 92 | |-------------------------------------------------------------------------- 93 | | Session Cache Store 94 | |-------------------------------------------------------------------------- 95 | | 96 | | When using one of the framework's cache driven session backends, you may 97 | | define the cache store which should be used to store the session data 98 | | between requests. This must match one of your defined cache stores. 99 | | 100 | | Affects: "apc", "dynamodb", "memcached", "redis" 101 | | 102 | */ 103 | 104 | 'store' => env('SESSION_STORE'), 105 | 106 | /* 107 | |-------------------------------------------------------------------------- 108 | | Session Sweeping Lottery 109 | |-------------------------------------------------------------------------- 110 | | 111 | | Some session drivers must manually sweep their storage location to get 112 | | rid of old sessions from storage. Here are the chances that it will 113 | | happen on a given request. By default, the odds are 2 out of 100. 114 | | 115 | */ 116 | 117 | 'lottery' => [2, 100], 118 | 119 | /* 120 | |-------------------------------------------------------------------------- 121 | | Session Cookie Name 122 | |-------------------------------------------------------------------------- 123 | | 124 | | Here you may change the name of the session cookie that is created by 125 | | the framework. Typically, you should not need to change this value 126 | | since doing so does not grant a meaningful security improvement. 127 | | 128 | */ 129 | 130 | 'cookie' => env( 131 | 'SESSION_COOKIE', 132 | Str::slug(env('APP_NAME', 'laravel'), '_').'_session' 133 | ), 134 | 135 | /* 136 | |-------------------------------------------------------------------------- 137 | | Session Cookie Path 138 | |-------------------------------------------------------------------------- 139 | | 140 | | The session cookie path determines the path for which the cookie will 141 | | be regarded as available. Typically, this will be the root path of 142 | | your application, but you're free to change this when necessary. 143 | | 144 | */ 145 | 146 | 'path' => env('SESSION_PATH', '/'), 147 | 148 | /* 149 | |-------------------------------------------------------------------------- 150 | | Session Cookie Domain 151 | |-------------------------------------------------------------------------- 152 | | 153 | | This value determines the domain and subdomains the session cookie is 154 | | available to. By default, the cookie will be available to the root 155 | | domain and all subdomains. Typically, this shouldn't be changed. 156 | | 157 | */ 158 | 159 | 'domain' => env('SESSION_DOMAIN'), 160 | 161 | /* 162 | |-------------------------------------------------------------------------- 163 | | HTTPS Only Cookies 164 | |-------------------------------------------------------------------------- 165 | | 166 | | By setting this option to true, session cookies will only be sent back 167 | | to the server if the browser has a HTTPS connection. This will keep 168 | | the cookie from being sent to you when it can't be done securely. 169 | | 170 | */ 171 | 172 | 'secure' => env('SESSION_SECURE_COOKIE'), 173 | 174 | /* 175 | |-------------------------------------------------------------------------- 176 | | HTTP Access Only 177 | |-------------------------------------------------------------------------- 178 | | 179 | | Setting this value to true will prevent JavaScript from accessing the 180 | | value of the cookie and the cookie will only be accessible through 181 | | the HTTP protocol. It's unlikely you should disable this option. 182 | | 183 | */ 184 | 185 | 'http_only' => env('SESSION_HTTP_ONLY', true), 186 | 187 | /* 188 | |-------------------------------------------------------------------------- 189 | | Same-Site Cookies 190 | |-------------------------------------------------------------------------- 191 | | 192 | | This option determines how your cookies behave when cross-site requests 193 | | take place, and can be used to mitigate CSRF attacks. By default, we 194 | | will set this value to "lax" to permit secure cross-site requests. 195 | | 196 | | See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value 197 | | 198 | | Supported: "lax", "strict", "none", null 199 | | 200 | */ 201 | 202 | 'same_site' => env('SESSION_SAME_SITE', 'lax'), 203 | 204 | /* 205 | |-------------------------------------------------------------------------- 206 | | Partitioned Cookies 207 | |-------------------------------------------------------------------------- 208 | | 209 | | Setting this value to true will tie the cookie to the top-level site for 210 | | a cross-site context. Partitioned cookies are accepted by the browser 211 | | when flagged "secure" and the Same-Site attribute is set to "none". 212 | | 213 | */ 214 | 215 | 'partitioned' => env('SESSION_PARTITIONED_COOKIE', false), 216 | 217 | ]; 218 | -------------------------------------------------------------------------------- /database/.gitignore: -------------------------------------------------------------------------------- 1 | *.sqlite* 2 | -------------------------------------------------------------------------------- /database/factories/FeedFactory.php: -------------------------------------------------------------------------------- 1 | 9 | */ 10 | class FeedFactory extends Factory 11 | { 12 | /** 13 | * Define the model's default state. 14 | * 15 | * @return array 16 | */ 17 | public function definition(): array 18 | { 19 | $url = $this->faker->url() . '?query=' . random_int(0, 1000); 20 | return [ 21 | 'url' => $url, 22 | 'last_fetched_at' => now()->subHours(random_int(0, 100))->unix(), 23 | 'last_accessed_at' => time(), 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /database/factories/PostFactory.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class PostFactory extends Factory 12 | { 13 | /** 14 | * Define the model's default state. 15 | * 16 | * @return array 17 | */ 18 | public function definition(): array 19 | { 20 | $url = $this->faker->url() . '?query=' . random_int(0, 1000); 21 | return [ 22 | 'feed_id' => Feed::factory(), 23 | 'published_at' => now()->subHours(random_int(0, 200))->unix(), 24 | 'title' => $this->faker->title(), 25 | 'description' => $this->faker->words(50, true), 26 | 'url' => $url, 27 | 'guid' => $url, 28 | 'thumbnail' => '' 29 | ]; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /database/factories/UserFactory.php: -------------------------------------------------------------------------------- 1 | 10 | */ 11 | class UserFactory extends Factory 12 | { 13 | /** 14 | * Define the model's default state. 15 | * 16 | * @return array 17 | */ 18 | public function definition(): array 19 | { 20 | return [ 21 | 'name' => fake()->name(), 22 | 'email' => fake()->unique()->safeEmail(), 23 | 'email_verified_at' => now(), 24 | 'password' => '$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', // password 25 | 'remember_token' => Str::random(10), 26 | ]; 27 | } 28 | 29 | /** 30 | * Indicate that the model's email address should be unverified. 31 | */ 32 | public function unverified(): static 33 | { 34 | return $this->state(fn (array $attributes) => [ 35 | 'email_verified_at' => null, 36 | ]); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /database/migrations/2019_08_19_000000_create_failed_jobs_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->string('uuid')->unique(); 16 | $table->text('connection'); 17 | $table->text('queue'); 18 | $table->longText('payload'); 19 | $table->longText('exception'); 20 | $table->timestamp('failed_at')->useCurrent(); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | */ 27 | public function down(): void 28 | { 29 | Schema::dropIfExists('failed_jobs'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /database/migrations/2022_06_29_124112_create_jobs_table.php: -------------------------------------------------------------------------------- 1 | bigIncrements('id'); 15 | $table->string('queue')->index(); 16 | $table->longText('payload'); 17 | $table->unsignedTinyInteger('attempts'); 18 | $table->unsignedInteger('reserved_at')->nullable(); 19 | $table->unsignedInteger('available_at'); 20 | $table->unsignedInteger('created_at'); 21 | }); 22 | } 23 | 24 | /** 25 | * Reverse the migrations. 26 | */ 27 | public function down(): void 28 | { 29 | Schema::dropIfExists('jobs'); 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /database/migrations/2022_06_29_124605_create_feeds_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->string('url', 250)->unique(); 16 | $table->timestamp('last_fetched_at')->index(); 17 | $table->timestamp('last_accessed_at')->index(); 18 | $table->timestamps(); 19 | }); 20 | } 21 | 22 | /** 23 | * Reverse the migrations. 24 | */ 25 | public function down(): void 26 | { 27 | Schema::dropIfExists('feeds'); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /database/migrations/2022_06_29_124610_create_posts_table.php: -------------------------------------------------------------------------------- 1 | id(); 15 | $table->foreignId('feed_id')->index(); 16 | $table->timestamp('published_at')->index(); 17 | $table->string('title', 250); 18 | $table->text('description'); 19 | $table->string('url', 250); 20 | $table->string('guid', 250); 21 | $table->string('thumbnail')->default(''); 22 | $table->timestamps(); 23 | 24 | $table->unique(['feed_id', 'guid']); 25 | }); 26 | } 27 | 28 | /** 29 | * Reverse the migrations. 30 | */ 31 | public function down(): void 32 | { 33 | Schema::dropIfExists('posts'); 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /database/seeders/DatabaseSeeder.php: -------------------------------------------------------------------------------- 1 | create(); 16 | 17 | // \App\Models\User::factory()->create([ 18 | // 'name' => 'Test User', 19 | // 'email' => 'test@example.com', 20 | // ]); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /docker-compose.testing.yml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | # WARNING 4 | # This file is for development testing only. 5 | # Refer to the readme for an example that you might want to use 6 | # for running the application in actual usage. 7 | 8 | version: "2" 9 | services: 10 | rss: 11 | container_name: rss_dev 12 | build: 13 | context: ./ 14 | dockerfile: ./docker/Dockerfile 15 | environment: 16 | - APP_NAME=RSS 17 | - APP_FEED_UPDATE_FREQUENCY=5 18 | volumes: 19 | - ./test-files:/app/storage 20 | ports: 21 | - "8080:80" 22 | restart: unless-stopped 23 | -------------------------------------------------------------------------------- /docker/.env.container: -------------------------------------------------------------------------------- 1 | APP_NAME=RSS 2 | APP_ENV=production 3 | APP_KEY= 4 | APP_DEBUG=false 5 | 6 | # Path to configuration 7 | APP_CONFIG_FILE=/app/storage/feeds.txt 8 | QUEUE_CONNECTION=database 9 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | # NPM asset build 2 | FROM node:20 3 | COPY . /app 4 | WORKDIR /app 5 | RUN npm ci && npm run build 6 | 7 | # Composer dependancies build 8 | FROM composer:2 9 | COPY . /app 10 | WORKDIR /app 11 | RUN composer install --no-dev 12 | 13 | ###################### 14 | # Main app container # 15 | ###################### 16 | 17 | FROM ubuntu:22.04 18 | 19 | # Copy our app files 20 | COPY . /app 21 | WORKDIR /app 22 | 23 | # Install dependencies 24 | ARG DEBIAN_FRONTEND=noninteractive 25 | RUN set -xe && \ 26 | apt-get update -yqq && \ 27 | apt-get install software-properties-common curl supervisor nginx cron -yqq && \ 28 | add-apt-repository ppa:ondrej/php && \ 29 | apt-get update -yqq && \ 30 | apt-get install php8.3-cli php8.3-fpm php8.3-cgi php8.3-common php8.3-curl php8.3-mbstring \ 31 | php8.3-xml php8.3-zip php8.3-gd php8.3-sqlite3 php8.3-bcmath -yqq 32 | 33 | # Copy requirements from other containers 34 | COPY --from=0 /app/public/build /app/public/build 35 | COPY --from=1 /app/vendor /app/vendor 36 | 37 | # Make required files changes using passed-though files 38 | # Then create directory for PHP-FPM socket 39 | # Then setup crontab 40 | # Then run any app-side commands 41 | RUN cp docker/.env.container /app/.env && \ 42 | cp docker/nginx.conf /etc/nginx/sites-enabled/rss.conf && \ 43 | rm /etc/nginx/sites-enabled/default && \ 44 | mkdir /run/php && \ 45 | chmod +x /app/docker/run.sh && \ 46 | crontab -u www-data /app/docker/cron && \ 47 | php artisan key:generate && \ 48 | php artisan route:cache 49 | 50 | # Run our process wrapper script 51 | EXPOSE 80/tcp 52 | CMD /app/docker/run.sh 53 | -------------------------------------------------------------------------------- /docker/cron: -------------------------------------------------------------------------------- 1 | * * * * * cd /app && /usr/bin/php artisan schedule:run >> /dev/null 2>&1 2 | -------------------------------------------------------------------------------- /docker/nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | listen 80; 3 | listen [::]:80; 4 | server_name _; 5 | root /app/public; 6 | 7 | index index.php; 8 | 9 | charset utf-8; 10 | 11 | location / { 12 | try_files $uri $uri/ /index.php?$query_string; 13 | } 14 | 15 | location = /favicon.ico { access_log off; log_not_found off; } 16 | location = /robots.txt { access_log off; log_not_found off; } 17 | 18 | error_page 404 /index.php; 19 | 20 | location ~ \.php$ { 21 | fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; 22 | fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; 23 | include fastcgi_params; 24 | } 25 | 26 | location ~* \.(css|gif|jpg|js|png|avif|ico|webp|jpeg)$ { 27 | access_log off; 28 | expires 1w; 29 | } 30 | 31 | location ~ /\.(?!well-known).* { 32 | deny all; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /docker/run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Move to app directory 4 | cd /app || exit 5 | 6 | # Setup 7 | mkdir -p /app/storage/database 8 | touch -a /app/storage/database/database.sqlite 9 | touch -a /app/storage/feeds.txt 10 | mkdir -p /app/storage/framework/cache 11 | mkdir -p /app/storage/framework/sessions 12 | mkdir -p /app/storage/framework/testing 13 | mkdir -p /app/storage/framework/views 14 | 15 | php artisan storage:link 16 | php artisan migrate --force 17 | php artisan config:cache 18 | php artisan view:cache 19 | 20 | # Set runtime permissions 21 | chown -R www-data:www-data /app 22 | 23 | # Run supervisord 24 | supervisord -c /app/docker/services.conf 25 | -------------------------------------------------------------------------------- /docker/services.conf: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | user=root 4 | 5 | [program:cron] 6 | command=cron -f 7 | autorestart=true 8 | redirect_stderr=true 9 | stdout_logfile=/dev/stdout 10 | stdout_logfile_maxbytes=0 11 | stopasgroup=true 12 | killasgroup=true 13 | 14 | [program:nginx] 15 | command=/usr/sbin/nginx -g "daemon off;" 16 | autorestart=true 17 | redirect_stderr=true 18 | stdout_logfile=/dev/stdout 19 | stdout_logfile_maxbytes=0 20 | stopasgroup=true 21 | killasgroup=true 22 | 23 | [program:php-fpm] 24 | command=/usr/sbin/php-fpm8.3 -F 25 | autorestart=true 26 | redirect_stderr=true 27 | stdout_logfile=/dev/stdout 28 | stdout_logfile_maxbytes=0 29 | stopasgroup=true 30 | killasgroup=true 31 | 32 | [program:php-queue-worker] 33 | command=/app/artisan queue:work --sleep=3 --tries=3 --max-time=3600 34 | autorestart=true 35 | redirect_stderr=true 36 | stdout_logfile=/dev/stdout 37 | stdout_logfile_maxbytes=0 38 | stopasgroup=true 39 | killasgroup=true 40 | user=www-data 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "vite", 5 | "build": "vite build" 6 | }, 7 | "devDependencies": { 8 | "@vitejs/plugin-vue": "^5.0.1", 9 | "autoprefixer": "^10.4.16", 10 | "laravel-vite-plugin": "^1.0.1", 11 | "postcss": "^8.4.32", 12 | "tailwindcss": "^3.4.0", 13 | "vite": "^5.0.10" 14 | }, 15 | "dependencies": { 16 | "@inertiajs/vue3": "^1.0.14", 17 | "axios": "^1.6.3", 18 | "vue": "^3.4.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | ./tests/Unit 8 | 9 | 10 | ./tests/Feature 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ./app 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | }, 5 | } 6 | -------------------------------------------------------------------------------- /public/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | 3 | Options -MultiViews -Indexes 4 | 5 | 6 | RewriteEngine On 7 | 8 | # Handle Authorization Header 9 | RewriteCond %{HTTP:Authorization} . 10 | RewriteRule .* - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 11 | 12 | # Redirect Trailing Slashes If Not A Folder... 13 | RewriteCond %{REQUEST_FILENAME} !-d 14 | RewriteCond %{REQUEST_URI} (.+)/$ 15 | RewriteRule ^ %1 [L,R=301] 16 | 17 | # Send Requests To Front Controller... 18 | RewriteCond %{REQUEST_FILENAME} !-d 19 | RewriteCond %{REQUEST_FILENAME} !-f 20 | RewriteRule ^ index.php [L] 21 | 22 | -------------------------------------------------------------------------------- /public/icons/rss-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ssddanbrown/rss/b6ef210cb62913a58d6fc6b5aefbe02ec4f6ddfc/public/icons/rss-128.png -------------------------------------------------------------------------------- /public/icons/rss-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ssddanbrown/rss/b6ef210cb62913a58d6fc6b5aefbe02ec4f6ddfc/public/icons/rss-32.png -------------------------------------------------------------------------------- /public/index.php: -------------------------------------------------------------------------------- 1 | handleRequest(Request::capture()); 18 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /resources/css/app.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | #app { 6 | @apply h-full; 7 | } 8 | 9 | .rss-post:hover img { 10 | @apply saturate-100; 11 | } 12 | -------------------------------------------------------------------------------- /resources/js/Pages/Posts.vue: -------------------------------------------------------------------------------- 1 | 87 | 155 | -------------------------------------------------------------------------------- /resources/js/Parts/Feed.vue: -------------------------------------------------------------------------------- 1 | 33 | 75 | -------------------------------------------------------------------------------- /resources/js/Parts/FormatSelector.vue: -------------------------------------------------------------------------------- 1 | 23 | 35 | -------------------------------------------------------------------------------- /resources/js/Parts/Post.vue: -------------------------------------------------------------------------------- 1 | 40 | 72 | -------------------------------------------------------------------------------- /resources/js/Parts/Tag.vue: -------------------------------------------------------------------------------- 1 | 5 | 6 | 18 | -------------------------------------------------------------------------------- /resources/js/app.js: -------------------------------------------------------------------------------- 1 | import { createApp, h } from 'vue' 2 | import { createInertiaApp, Link } from '@inertiajs/vue3' 3 | import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers'; 4 | 5 | // Monkey-Patching of 'URL' constructor to use URL set via the `` tag since 6 | // inertia.js does not seem to have a dynamic way to set a base URL or use base tags. 7 | (function(nativeURL) { 8 | const configuredBase = document.querySelector('base').href; 9 | window.URL = function(url, base) { 10 | if (base === window.location.toString()) { 11 | base = configuredBase; 12 | } 13 | return new nativeURL(url, base); 14 | } 15 | })(URL); 16 | 17 | createInertiaApp({ 18 | resolve: name => resolvePageComponent(`./Pages/${name}.vue`, import.meta.glob('./Pages/**/*.vue')), 19 | setup({ el, App, props, plugin }) { 20 | createApp({ render: () => h(App, props) }) 21 | .component('Link', Link) 22 | .use(plugin) 23 | .mount(el) 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /resources/js/util.js: -------------------------------------------------------------------------------- 1 | const formats = [ 2 | {limit: 45, label: 'second', div: 60}, 3 | {limit: 50, label: 'minute', div: 60}, 4 | {limit: 22, label: 'hour', div: 24}, 5 | {limit: 6, label: 'day', div: 7}, 6 | {limit: 51, label: 'week', div: 52}, 7 | {limit: 10000, label: 'year', div: 1}, 8 | ]; 9 | 10 | 11 | /** 12 | * @param {Number} timestamp 13 | */ 14 | export function timestampToRelativeTime(timestamp) { 15 | let label = ''; 16 | let count = Math.abs((Date.now() / 1000) - timestamp); 17 | 18 | for (const format of formats) { 19 | label = format.label; 20 | if (count < format.limit) { 21 | break; 22 | } 23 | count = count / format.div; 24 | } 25 | 26 | const int = Math.round(count); 27 | 28 | return `${int} ${label}${int === 1 ? '' : 's'}`; 29 | } 30 | -------------------------------------------------------------------------------- /resources/views/app.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ config('app.name') }} 8 | 9 | 10 | 11 | 12 | @if(!app()->runningUnitTests()) 13 | @vite(['resources/css/app.css', 'resources/js/app.js']) 14 | @endif 15 | 16 | @inertiaHead 17 | 18 | 19 | @inertia 20 | 21 | 22 | -------------------------------------------------------------------------------- /routes/console.php: -------------------------------------------------------------------------------- 1 | everyFiveMinutes(); 8 | Schedule::command(PrunePostsCommand::class, ['-n'])->daily(); 9 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | create(['published_at' => $now - ($day * 2)]); 23 | Post::factory(13)->create(['published_at' => $now - ($day * 0.5)]); 24 | 25 | $this->assertEquals(24, Post::query()->count()); 26 | 27 | $this->artisan('rss:prune-posts --days=1') 28 | ->expectsConfirmation('This will delete all posts older than 1 day(s). Do you want to continue?', 'yes') 29 | ->expectsOutput('Deleted 11 posts from the system') 30 | ->assertExitCode(0); 31 | 32 | $this->assertEquals(13, Post::query()->count()); 33 | } 34 | 35 | public function test_command_deletes_post_thumbnail_if_existing(): void 36 | { 37 | $post = Post::factory()->createOne(['published_at' => 50]); 38 | $thumb = 'thumbs/' . Str::random() . '.png'; 39 | $post->thumbnail = $thumb; 40 | $post->save(); 41 | 42 | Storage::disk('public')->put($thumb, 'test-img-data'); 43 | 44 | $this->assertTrue(Storage::disk('public')->exists($thumb)); 45 | 46 | $this->artisan('rss:prune-posts --days=1') 47 | ->expectsConfirmation('This will delete all posts older than 1 day(s). Do you want to continue?', 'yes') 48 | ->assertExitCode(0); 49 | 50 | $this->assertFalse(Storage::disk('public')->exists($thumb)); 51 | } 52 | 53 | public function test_command_defaults_to_config_option_time(): void 54 | { 55 | Post::factory()->createOne(['published_at' => time() - (86400 * 10.1)]); 56 | Post::factory()->createOne(['published_at' => time() - (86400 * 9.5)]); 57 | config()->set('app.prune_posts_after_days', 10); 58 | 59 | $this->assertEquals(2, Post::query()->count()); 60 | 61 | $this->artisan('rss:prune-posts') 62 | ->expectsConfirmation('This will delete all posts older than 10 day(s). Do you want to continue?', 'yes') 63 | ->assertExitCode(0); 64 | 65 | $this->assertEquals(1, Post::query()->count()); 66 | } 67 | 68 | public function test_command_defaults_to_no_action_if_config_false(): void 69 | { 70 | Post::factory()->createOne(['published_at' => time() - (86400 * 10.1)]); 71 | config()->set('app.prune_posts_after_days', false); 72 | 73 | $this->assertEquals(1, Post::query()->count()); 74 | 75 | $this->artisan('rss:prune-posts') 76 | ->expectsOutput('No prune retention time set therefore no posts will be pruned.') 77 | ->assertExitCode(0); 78 | 79 | $this->assertEquals(1, Post::query()->count()); 80 | } 81 | 82 | public function test_command_deletes_all_posts_in_range(): void 83 | { 84 | Post::factory(500)->create(['published_at' => time() - (86400 * 10.1)]); 85 | 86 | $this->assertEquals(500, Post::query()->count()); 87 | 88 | $this->artisan('rss:prune-posts --days=3') 89 | ->expectsConfirmation('This will delete all posts older than 3 day(s). Do you want to continue?', 'yes') 90 | ->assertExitCode(0); 91 | 92 | $this->assertEquals(0, Post::query()->count()); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /tests/Feature/ConfiguredFeedTest.php: -------------------------------------------------------------------------------- 1 | last_fetched_at = $now - (59 * 60); 18 | 19 | $configuredFeed = new ConfiguredFeed( 20 | $feed, 21 | 'My great feed', 22 | 'https://example.com', 23 | '#fff', 24 | ['#a'], 25 | false, 26 | ); 27 | 28 | config()->set('app.feed_update_frequency', 60); 29 | $this->assertFalse($configuredFeed->isOutdated()); 30 | 31 | $feed->last_fetched_at = $now - (61 * 60); 32 | $this->assertTrue($configuredFeed->isOutdated()); 33 | 34 | config()->set('app.feed_update_frequency', 5); 35 | $feed->last_fetched_at = $now - (4 * 60); 36 | $this->assertFalse($configuredFeed->isOutdated()); 37 | 38 | $feed->last_fetched_at = $now - (6 * 60); 39 | $this->assertTrue($configuredFeed->isOutdated()); 40 | } 41 | 42 | public function test_start_reloading_dispatched_refresh_job(): void 43 | { 44 | $configuredFeed = new ConfiguredFeed( 45 | new Feed(), 46 | 'My great feed', 47 | 'https://example.com', 48 | '#fff', 49 | ['#a'], 50 | false, 51 | ); 52 | Queue::fake(); 53 | 54 | $configuredFeed->startReloading(); 55 | Queue::assertPushed(RefreshFeedJob::class); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /tests/Feature/FeedControllerTest.php: -------------------------------------------------------------------------------- 1 | generateStableTestData(); 20 | } 21 | 22 | public function test_get_feed(): void 23 | { 24 | $resp = $this->get('/feed?url=' . urlencode('http://example.com/a.xml')); 25 | $resp->assertOk(); 26 | $resp->assertJson([ 27 | 'name' => 'Feed A', 28 | 'color' => '#F00', 29 | 'tags' => ['#Tech', '#News'], 30 | 'url' => 'http://example.com/a.xml' 31 | ]); 32 | } 33 | 34 | public function test_non_existing_feed(): void 35 | { 36 | $resp = $this->get('/feed?url=' . urlencode('http://example.com/abc.xml')); 37 | $resp->assertNotFound(); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/Feature/Jobs/RefreshFeedJobTest.php: -------------------------------------------------------------------------------- 1 | create([ 20 | 'url' => 'https://example.com/feed.xml', 21 | 'last_fetched_at' => time() - 50000, 22 | ]); 23 | $job = new RefreshFeedJob($feed); 24 | Http::fake([ 25 | 'example.com/*' => Http::response(<< 27 | 28 | 29 | 30 | BookStack Release v22.06 31 | https://www.bookstackapp.com/blog/bookstack-release-v22-06/ 32 | Fri, 24 Jun 2022 11:00:00 +0000 33 | 34 | https://www.bookstackapp.com/blog/bookstack-release-v22-06/ 35 | A little description 36 | 37 | 38 | 39 | END) 40 | ]); 41 | 42 | $this->assertEquals(0, $feed->posts()->count()); 43 | 44 | dispatch_sync($job); 45 | 46 | /** @var Post[] $posts */ 47 | $posts = $feed->posts()->get(); 48 | $this->assertCount(1, $posts); 49 | $this->assertGreaterThan(time() - 10, $feed->refresh()->last_fetched_at); 50 | 51 | $this->assertDatabaseHas('posts', [ 52 | 'feed_id' => $feed->id, 53 | 'title' => 'BookStack Release v22.06', 54 | 'url' => 'https://www.bookstackapp.com/blog/bookstack-release-v22-06/', 55 | 'description' => 'A little description', 56 | 'published_at' => 1656068400, 57 | ]); 58 | } 59 | 60 | public function test_job_is_unique_per_feed(): void 61 | { 62 | $feedA = Feed::factory()->create(['url' => 'https://example.com/feed.xml']); 63 | $feedB = Feed::factory()->create(['url' => 'https://example-b.com/feed.xml']); 64 | 65 | Queue::fake(); 66 | 67 | dispatch(new RefreshFeedJob($feedA)); 68 | dispatch(new RefreshFeedJob($feedA)); 69 | dispatch(new RefreshFeedJob($feedA)); 70 | dispatch(new RefreshFeedJob($feedB)); 71 | dispatch(new RefreshFeedJob($feedB)); 72 | 73 | Queue::assertPushed(RefreshFeedJob::class, 2); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /tests/Feature/PostThumbnailFetcherTest.php: -------------------------------------------------------------------------------- 1 | Http::response(<< 21 | 22 | 23 | 24 | 25 | END), 26 | 'exampleb.com/*' => Http::response($imageData), 27 | ]); 28 | $post = Post::factory()->create(['url' => 'http://example.com/cats']); 29 | 30 | $fetcher = new PostThumbnailFetcher(); 31 | $result = $fetcher->fetchAndStoreForPost($post); 32 | 33 | $this->assertTrue($result); 34 | $expectedPath = storage_path("app/public/thumbs/{$post->feed_id}/{$post->id}.png"); 35 | $this->assertFileExists($expectedPath); 36 | 37 | $content = file_get_contents($expectedPath); 38 | $this->assertEquals($imageData, $content); 39 | $this->assertEquals($post->thumbnail, "thumbs/{$post->feed_id}/{$post->id}.png"); 40 | 41 | unlink($expectedPath); 42 | } 43 | 44 | public function test_html_encoding_in_opengraph_url_is_decoded(): void 45 | { 46 | $imageData = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg=='); 47 | Http::fake([ 48 | 'example.com/*' => Http::response(<< 50 | 51 | 52 | 53 | 54 | END), 55 | 'https://exampleb.com/image.png?a=b&c=d' => Http::response($imageData), 56 | 'exampleb.com/*' => Http::response('', 404), 57 | ]); 58 | $post = Post::factory()->create(['url' => 'http://example.com/cats']); 59 | 60 | $fetcher = new PostThumbnailFetcher(); 61 | $result = $fetcher->fetchAndStoreForPost($post); 62 | $this->assertTrue($result); 63 | 64 | $expectedPath = storage_path("app/public/thumbs/{$post->feed_id}/{$post->id}.png"); 65 | unlink($expectedPath); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /tests/Feature/PostViewControllerTest.php: -------------------------------------------------------------------------------- 1 | generateStableTestData(); 21 | } 22 | 23 | public function test_home(): void 24 | { 25 | // Standard main page 26 | $resp = $this->get('/'); 27 | $resp->assertInertia(function (Assert $page) { 28 | $page->component('Posts'); 29 | $page->has('feeds', 3); 30 | $page->has('posts', 100); 31 | $page->where('page', 1); 32 | $page->where('search', ''); 33 | $page->where('feeds.0.name', 'Feed A'); 34 | $page->where('feeds.0.color', '#F00'); 35 | $page->where('feeds.0.tags.0', '#Tech'); 36 | }); 37 | 38 | // Pagination test 39 | $resp = $this->get('/?page=2'); 40 | $resp->assertInertia(function (Assert $page) { 41 | $page->component('Posts'); 42 | $page->has('feeds', 3); 43 | $page->has('posts', 50); 44 | $page->where('page', 2); 45 | }); 46 | 47 | // Search test 48 | $resp = $this->get('/?query=Special+title+for'); 49 | $resp->assertInertia(function (Assert $page) { 50 | $page->component('Posts'); 51 | $page->has('feeds', 3); 52 | $page->has('posts', 3); 53 | $page->where('search', 'Special title for'); 54 | }); 55 | } 56 | 57 | public function test_tag(): void 58 | { 59 | $resp = $this->get('/t/News'); 60 | $resp->assertInertia(function (Assert $page) { 61 | $page->component('Posts'); 62 | $page->has('feeds', 2); 63 | $page->has('posts', 100); 64 | $page->where('page', 1); 65 | $page->where('search', ''); 66 | $page->where('tag', 'News'); 67 | }); 68 | } 69 | 70 | public function test_feed(): void 71 | { 72 | $resp = $this->get('/f/' . urlencode(urlencode('http://example.com/b.xml'))); 73 | $resp->assertInertia(function (Assert $page) { 74 | $page->component('Posts'); 75 | $page->has('feeds', 1); 76 | $page->has('posts', 50); 77 | $page->where('page', 1); 78 | $page->where('search', ''); 79 | $page->where('tag', ''); 80 | $page->where('feed', 'http://example.com/b.xml'); 81 | }); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /tests/GeneratesTestData.php: -------------------------------------------------------------------------------- 1 | app->singleton(ConfiguredFeedProvider::class, function ($app) use ($config) { 20 | $provider = new ConfiguredFeedProvider(); 21 | $provider->loadFromString($config); 22 | return $provider; 23 | }); 24 | 25 | $feeds = [ 26 | Feed::factory(['url' => 'http://example.com/a.xml'])->create(), 27 | Feed::factory(['url' => 'http://example.com/b.xml'])->create(), 28 | Feed::factory(['url' => 'http://example.com/c.xml'])->create(), 29 | ]; 30 | 31 | foreach ($feeds as $feed) { 32 | Post::factory(49)->create(['feed_id' => $feed->id]); 33 | Post::factory()->create([ 34 | 'title' => "Special title for feed {$feed->url}", 35 | 'description' => "Special desc for feed {$feed->url}", 36 | 'feed_id' => $feed->id, 37 | ]); 38 | } 39 | 40 | return [ 41 | 'config' => $config, 42 | 'feeds' => $feeds, 43 | ]; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | addFeed('https://example.com', 'example a'); 14 | $config->addFeed('https://example-b.com/cats?test=abc#okay', 'example b'); 15 | 16 | $urls = $config->getFeedUrls(); 17 | $this->assertCount(2, $urls); 18 | $this->assertEquals('https://example.com', $urls[0]); 19 | $this->assertEquals('https://example-b.com/cats?test=abc#okay', $urls[1]); 20 | } 21 | 22 | public function test_remove_feed_url(): void 23 | { 24 | $config = new RssConfig(); 25 | $config->addFeed('https://example.com', 'a'); 26 | $config->addFeed('https://example-B.com/cats?test=abc#okay', 'b'); 27 | 28 | $existingRemoved = $config->removeFeed('https://example-B.com/cats?test=abc#okay'); 29 | $nonExistingRemoved = $config->removeFeed('https://example-c.com'); 30 | 31 | $this->assertTrue($existingRemoved); 32 | $this->assertFalse($nonExistingRemoved); 33 | $this->assertCount(1, $config->getFeedUrls()); 34 | } 35 | 36 | public function test_to_string(): void 37 | { 38 | $config = new RssConfig(); 39 | $config->addFeed('https://example.com', 'a', ['#cat', '#dog']); 40 | $config->addFeed('https://example-B.com/cats?test=abc#okay', 'b'); 41 | 42 | $expected = "https://example.com a #cat #dog\nhttps://example-B.com/cats?test=abc#okay b"; 43 | $this->assertEquals($expected, $config->toString()); 44 | } 45 | 46 | public function test_parse_from_string(): void 47 | { 48 | $config = new RssConfig(); 49 | $config->parseFromString(" 50 | https://example-B.com/cats?test=abc#okay a 51 | https://example.com b[#000] #dog #cat 52 | # A comment 53 | https://example-C.com/cats?test=abc#okay 54 | 55 | -https://example-hidden-a.com Hidden_A #news 56 | - https://example-hidden-b.com/ Hidden_B 57 | 58 | http://beans.com/feed.xml#food d_is_cool #cooking 59 | "); 60 | 61 | $this->assertCount(5, $config->getFeedUrls()); 62 | $this->assertCount(0, $config->getTags('https://example-B.com/cats?test=abc#okay')); 63 | $this->assertEquals(['#dog', '#cat'], $config->getTags('https://example.com')); 64 | $this->assertEquals(['#cooking'], $config->getTags('http://beans.com/feed.xml#food')); 65 | $this->assertEquals('a', $config->getName('https://example-B.com/cats?test=abc#okay')); 66 | $this->assertEquals('b', $config->getName('https://example.com')); 67 | $this->assertEquals('#000', $config->getColor('https://example.com')); 68 | $this->assertEquals('d is cool', $config->getName('http://beans.com/feed.xml#food')); 69 | $this->assertTrue($config->getHidden('https://example-hidden-a.com')); 70 | $this->assertTrue($config->getHidden('https://example-hidden-b.com/')); 71 | $this->assertFalse($config->getHidden('http://beans.com/feed.xml#food')); 72 | } 73 | 74 | 75 | public function test_encode_for_url_without_compression(): void 76 | { 77 | $config = new RssConfig(); 78 | $config->addFeed('https://a.com', 'a', ['#a', '#b']); 79 | 80 | $expected = 'thttps%3A%2F%2Fa.com+a+%23a+%23b'; 81 | $this->assertEquals($expected, $config->encodeForUrl()); 82 | } 83 | 84 | public function test_encode_for_url_with_compression(): void 85 | { 86 | $config = new RssConfig(); 87 | $config->addFeed('https://a.com', 'a', ['#a', '#b']); 88 | $config->addFeed('https://b.com', 'b', ['#a', '#b']); 89 | 90 | $expected = 'ceJzLKCkpKLbS10/US87PVUhUUAaiJK4MqGgSWDQJIgoAKUYM0w=='; 91 | $this->assertEquals($expected, $config->encodeForUrl()); 92 | } 93 | 94 | public function test_decode_from_url_without_compression(): void 95 | { 96 | $config = new RssConfig(); 97 | $config->decodeFromUrl('thttps%3A%2F%2Fa.com+a+%23a+%23b%0Ahttps%3A%2F%2Fb.com+b+%23a+%23b'); 98 | 99 | $this->assertCount(2, $config->getFeedUrls()); 100 | $this->assertEquals(['#a', '#b'], $config->getTags('https://a.com')); 101 | $this->assertEquals(['#a', '#b'], $config->getTags('https://b.com')); 102 | $this->assertEquals('a', $config->getName('https://a.com')); 103 | $this->assertEquals('b', $config->getName('https://b.com')); 104 | } 105 | 106 | public function test_decode_from_url_with_compression(): void 107 | { 108 | $config = new RssConfig(); 109 | $config->decodeFromUrl('ceJzLKCkpKLbS10/US87PVUhUUAaiJK4MqGgSWDQJIgoAKUYM0w=='); 110 | 111 | $this->assertCount(2, $config->getFeedUrls()); 112 | $this->assertEquals(['#a', '#b'], $config->getTags('https://a.com')); 113 | $this->assertEquals(['#a', '#b'], $config->getTags('https://b.com')); 114 | $this->assertEquals('a', $config->getName('https://a.com')); 115 | $this->assertEquals('b', $config->getName('https://b.com')); 116 | } 117 | 118 | public function test_decode_from_url_with_empty_input(): void 119 | { 120 | $config = new RssConfig(); 121 | $config->decodeFromUrl(''); 122 | 123 | $this->assertCount(0, $config->getFeedUrls()); 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /tests/Unit/RssParserTest.php: -------------------------------------------------------------------------------- 1 | assertIsArray($parser->rssDataToPosts('')); 15 | } 16 | 17 | 18 | public function test_it_parses_valid_posts(): void 19 | { 20 | $parser = new RssParser(); 21 | 22 | $posts = $parser->rssDataToPosts( 23 | << 25 | 26 | 27 | 28 | BookStack Release v22.06 29 | https://www.bookstackapp.com/blog/bookstack-release-v22-06/ 30 | Fri, 24 Jun 2022 11:00:00 +0000 31 | 32 | https://www.bookstackapp.com/blog/bookstack-release-v22-06/ 33 | BookStack v22.06 is now here! This release was primarily refinement focused but it does include some great new features that may streamline your usage of the platform. 34 | 35 | 36 | 37 | BookStack Release v22.04 38 | https://www.bookstackapp.com/blog/bookstack-release-v22-04/ 39 | Fri, 29 Apr 2022 12:00:00 +0000 40 | 41 | https://www.bookstackapp.com/blog/bookstack-release-v22-04/ 42 | Today brings the release of BookStack v22.04! This includes the much-awaited feature of easier page editor switching, in addition to a bunch of other additions and improvements. 43 | 44 | 45 | 46 | END 47 | ); 48 | 49 | $this->assertCount(2, $posts); 50 | $this->assertEquals('BookStack Release v22.06', $posts[0]->title); 51 | $this->assertEquals('https://www.bookstackapp.com/blog/bookstack-release-v22-06/', $posts[0]->guid); 52 | $this->assertEquals('https://www.bookstackapp.com/blog/bookstack-release-v22-06/', $posts[0]->url); 53 | $this->assertEquals(1656068400, $posts[0]->published_at); 54 | $this->assertEquals('BookStack v22.06 is now here! This release was primarily refinement focused but it does include some great new features that may streamline your usage of the platform.', $posts[0]->description); 55 | } 56 | 57 | public function test_it_parses_single_post(): void 58 | { 59 | $parser = new RssParser(); 60 | 61 | $posts = $parser->rssDataToPosts( 62 | << 64 | 65 | 66 | 67 | BookStack Release v22.06 68 | https://www.bookstackapp.com/blog/bookstack-release-v22-06/ 69 | Fri, 24 Jun 2022 11:00:00 +0000 70 | 71 | https://www.bookstackapp.com/blog/bookstack-release-v22-06/ 72 | BookStack v22.06 is now here! This release was primarily refinement focused but it does include some great new features that may streamline your usage of the platform. 73 | 74 | 75 | 76 | END 77 | ); 78 | 79 | $this->assertCount(1, $posts); 80 | $this->assertEquals('BookStack Release v22.06', $posts[0]->title); 81 | $this->assertEquals('https://www.bookstackapp.com/blog/bookstack-release-v22-06/', $posts[0]->guid); 82 | $this->assertEquals('https://www.bookstackapp.com/blog/bookstack-release-v22-06/', $posts[0]->url); 83 | $this->assertEquals(1656068400, $posts[0]->published_at); 84 | $this->assertEquals('BookStack v22.06 is now here! This release was primarily refinement focused but it does include some great new features that may streamline your usage of the platform.', $posts[0]->description); 85 | } 86 | 87 | public function test_it_parses_no_posts(): void 88 | { 89 | $parser = new RssParser(); 90 | 91 | $posts = $parser->rssDataToPosts( 92 | << 94 | 95 | 96 | 97 | 98 | END 99 | ); 100 | 101 | $this->assertCount(0, $posts); 102 | } 103 | 104 | public function test_invalid_posts_are_not_returned(): void 105 | { 106 | $parser = new RssParser(); 107 | 108 | $posts = $parser->rssDataToPosts( 109 | << 111 | 112 | 113 | 114 | Bad Link 115 | cats.com 116 | Fri, 24 Jun 2022 11:00:00 +0000 117 | https://www.bookstackapp.com/blog/bookstack-release-v22-06/ 118 | Post Desc 119 | 120 | 121 | 122 | https://www.bookstackapp.com/bad-title/ 123 | Fri, 24 Jun 2022 11:00:00 +0000 124 | https://www.bookstackapp.com/bad-title/ 125 | Post Desc 126 | 127 | 128 | Bad Date 129 | https://www.bookstackapp.com/blog/bookstack-release-v22-03/ 130 | Friday 131 | https://www.bookstackapp.com/blog/bookstack-release-v22-03/ 132 | Post Desc 133 | 134 | 135 | 136 | END 137 | ); 138 | 139 | $this->assertCount(0, $posts); 140 | } 141 | 142 | public function test_descriptions_in_html_are_parsed(): void 143 | { 144 | $parser = new RssParser(); 145 | 146 | $posts = $parser->rssDataToPosts( 147 | << 149 | 150 | 151 | 152 | BookStack Release v22.06 153 | https://www.bookstackapp.com/blog/bookstack-release-v22-06/ 154 | Fri, 24 Jun 2022 11:00:00 +0000 155 | https://www.bookstackapp.com/blog/bookstack-release-v22-06/ 156 | <span a="b">Some really cool text</span> &amp; with &pound; entities within 157 | 158 | 159 | 160 | END 161 | ); 162 | 163 | $this->assertEquals('Some really cool text & with £ entities within', $posts[0]->description); 164 | } 165 | 166 | public function test_it_parses_valid_atom_feeds(): void 167 | { 168 | $parser = new RssParser(); 169 | 170 | $posts = $parser->rssDataToPosts( 171 | << 173 | 174 | Example Atom Feed 175 | 176 | 177 | Example Post A 178 | 179 | 2022-06-09T17:00:00.000Z 180 | https://example.com/a 181 | 182 | <p>Example Post A</p> 183 | <p><a href="https://example/a">Read the full article</a></p> 184 | 185 | 186 | Example Team 187 | 188 | 189 | 190 | 191 | Example Post B 192 | 193 | 2022-06-08T17:00:00.000Z 194 | https://example.com/b 195 | 196 | <p>Example Post B</p> 197 | <p><a href="https://example/a">Read the full article</a></p> 198 | 199 | 200 | Example Team 201 | 202 | 203 | 204 | END 205 | ); 206 | 207 | $this->assertCount(2, $posts); 208 | $this->assertEquals('Example Post A', $posts[0]->title); 209 | $this->assertEquals('https://example.com/a', $posts[0]->guid); 210 | $this->assertEquals('https://example.com/a', $posts[0]->url); 211 | $this->assertEquals(1654794000, $posts[0]->published_at); 212 | $this->assertEquals("Example Post A Read the full article", $posts[0]->description); 213 | } 214 | 215 | public function test_atom_summary_used_over_content(): void 216 | { 217 | $parser = new RssParser(); 218 | 219 | $posts = $parser->rssDataToPosts( 220 | << 222 | 223 | Example Atom Feed 224 | 225 | 226 | Example Post A 227 | 228 | 2022-06-09T17:00:00.000Z 229 | https://example.com/a 230 | <p>Example Post A Content</p> 231 | <p>Example Post A Summary</p> 232 | 233 | Example Team 234 | 235 | 236 | 237 | END 238 | ); 239 | 240 | $this->assertEquals("Example Post A Summary", $posts[0]->description); 241 | } 242 | 243 | public function test_switcher_summary_used_over_content(): void 244 | { 245 | $parser = new RssParser(); 246 | 247 | $posts = $parser->rssDataToPosts( 248 | << 250 | 251 | Example Atom Feed 252 | 253 | 254 | Example Post A 255 | 256 | 2022-06-09T17:00:00.000Z 257 | https://example.com/a 258 | <p>Example Post A Content</p> 259 | <p>Example Post A Summary</p> 260 | 261 | Example Team 262 | 263 | 264 | 265 | END 266 | ); 267 | 268 | $this->assertEquals("Example Post A Summary", $posts[0]->description); 269 | } 270 | } 271 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import laravel from 'laravel-vite-plugin'; 3 | import vue from '@vitejs/plugin-vue'; 4 | 5 | export default defineConfig({ 6 | plugins: [ 7 | laravel([ 8 | 'resources/css/app.css', 9 | 'resources/js/app.js', 10 | ]), 11 | vue({ 12 | template: { 13 | transformAssetUrls: { 14 | base: null, 15 | includeAbsolute: false, 16 | }, 17 | }, 18 | }), 19 | ], 20 | }); 21 | --------------------------------------------------------------------------------