├── .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 | [](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 |
48 | Card View
49 |
50 | |
51 |
52 | List View
53 |
54 | |
55 |
56 | Compact View
57 |
58 | |
59 |
60 | Dark Mode
61 |
62 | |
63 |
64 |
65 |
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 |
2 |
3 | This project has moved! Please use the
codeberg.org/danb/rss:latest
docker image instead.
4 |
Project on Codeberg
5 |
6 |
7 |
8 |
9 |
16 |
17 |
18 |
19 |
20 | « Back to home
21 |
22 |
23 |
24 |
Search
25 |
33 |
34 |
35 |
36 |
Tags
37 |
38 |
39 |
40 |
41 |
42 |
43 |
Feeds
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | Posts
58 | / #{{ tag }}
59 | / {{ feeds[0].name }}
60 | ?= {{ search }}
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 | « Previous Page
79 | Next Page »
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
155 |
--------------------------------------------------------------------------------
/resources/js/Parts/Feed.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | {{ feed.name }}
5 |
6 |
11 |
12 |
13 |
{{ feed.url }}
14 |
15 | {{ tag }}
16 |
17 |
18 |
24 |
25 | Reloading feed...
26 |
27 |
28 | Reloaded, refresh to show changes
29 |
30 |
31 |
32 |
33 |
75 |
--------------------------------------------------------------------------------
/resources/js/Parts/FormatSelector.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
9 |
14 |
21 |
22 |
23 |
35 |
--------------------------------------------------------------------------------
/resources/js/Parts/Post.vue:
--------------------------------------------------------------------------------
1 |
2 |
39 |
40 |
72 |
--------------------------------------------------------------------------------
/resources/js/Parts/Tag.vue:
--------------------------------------------------------------------------------
1 |
2 | {{ tag }}
4 |
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 |