├── .dockerignore ├── .env.example ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .npmrc ├── .prettierignore ├── .prettierrc ├── Dockerfile ├── LICENSE ├── README.md ├── docker-compose.yml ├── eslint.config.js ├── migrations ├── 20240908T033145_rename_date_add_added_date.ts ├── 20240908T040000_update_date_fields_to_iso_format.ts └── 20240908T050000_add_ttr.ts ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── screenshots ├── home-dark.jpeg └── home-light.jpeg ├── src ├── app.css ├── app.d.ts ├── app.html ├── hooks.server.ts ├── lib │ ├── auth.test.ts │ ├── auth.ts │ ├── components │ │ ├── Button.svelte │ │ ├── IconButton.svelte │ │ ├── TextField.svelte │ │ └── Tooltip.svelte │ ├── db.test.ts │ ├── db.ts │ ├── feed.ts │ ├── types.ts │ └── utils.ts └── routes │ ├── +layout.server.ts │ ├── +layout.svelte │ ├── +page.server.ts │ ├── +page.svelte │ ├── Article.svelte │ ├── ArticleCard.svelte │ ├── [...path] │ └── +page.server.ts │ ├── articles │ ├── +server.ts │ ├── [articleId] │ │ ├── +server.ts │ │ ├── delete │ │ │ └── +server.ts │ │ └── update │ │ │ └── +server.ts │ ├── add │ │ └── +server.ts │ ├── clear │ │ └── +server.ts │ └── purge │ │ └── +server.ts │ ├── atom │ └── +server.ts │ ├── feed.xml │ └── +server.ts │ ├── feed │ └── +server.ts │ ├── health │ └── +server.ts │ ├── json │ └── +server.ts │ ├── login │ ├── +page.server.ts │ └── +page.svelte │ ├── logout │ └── +page.server.ts │ ├── rss.xml │ └── +server.ts │ └── rss │ └── +server.ts ├── static ├── favicon-dark.svg └── favicon-light.svg ├── svelte.config.js ├── tailwind.config.ts ├── tsconfig.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /build 3 | /data -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # required if PASSWORD is set 2 | AUTH_SECRET= 3 | 4 | # optional 5 | HOST= 6 | PORT= 7 | SECURE= 8 | PASSWORD= 9 | FEED_TITLE= 10 | FEED_DESCRIPTION= 11 | FEED_IMAGE= 12 | FEED_FAVICON= 13 | FEED_COPYRIGHT= 14 | AUTHOR_NAME= 15 | AUTHOR_EMAIL= 16 | AUTHOR_LINK= 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | paths-ignore: 7 | - .env.example 8 | - eslint.config.js 9 | - README.md 10 | branches: 11 | - main 12 | 13 | jobs: 14 | deploy: 15 | runs-on: ubuntu-latest 16 | 17 | env: 18 | NODE_OPTIONS: --max_old_space_size=4096 19 | 20 | permissions: 21 | contents: read 22 | deployments: write 23 | 24 | steps: 25 | - name: Checkout 🛎 26 | uses: actions/checkout@v4 27 | 28 | - name: Build docker image 🔧 29 | run: docker build . -f ./Dockerfile -t jacobshuman/readl8r:latest 30 | 31 | - name: Log in to docker hub 🔑 32 | uses: docker/login-action@v3 33 | with: 34 | username: ${{ secrets.DOCKER_USERNAME }} 35 | password: ${{ secrets.DOCKER_ACCESS_TOKEN }} 36 | 37 | - name: Push docker image 💨 38 | run: docker push jacobshuman/readl8r:latest 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | /data 4 | 5 | # Output 6 | .output 7 | .vercel 8 | /.svelte-kit 9 | /build 10 | 11 | # OS 12 | .DS_Store 13 | Thumbs.db 14 | 15 | # Env 16 | .env 17 | .env.* 18 | !.env.example 19 | !.env.test 20 | 21 | # Vite 22 | vite.config.js.timestamp-* 23 | vite.config.ts.timestamp-* 24 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # Package Managers 2 | package-lock.json 3 | pnpm-lock.yaml 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "useTabs": true, 3 | "singleQuote": true, 4 | "trailingComma": "none", 5 | "printWidth": 100, 6 | "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], 7 | "overrides": [ 8 | { 9 | "files": "*.svelte", 10 | "options": { 11 | "parser": "svelte" 12 | } 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-slim as build 2 | 3 | ENV PNPM_HOME="/pnpm" 4 | ENV PATH="$PNPM_HOME:$PATH" 5 | ENV NODE_OPTIONS=--max_old_space_size=12288 6 | 7 | RUN corepack enable 8 | COPY . /app 9 | WORKDIR /app 10 | 11 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile 12 | RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm build 13 | 14 | FROM node:20-slim 15 | 16 | ENV HOST=0.0.0.0 17 | ENV PORT=80 18 | ENV LANGUAGE="en" 19 | 20 | COPY --from=build /app /app 21 | WORKDIR /app 22 | 23 | RUN mkdir data 24 | 25 | HEALTHCHECK --interval=30s --timeout=10s --start-period=1s --retries=3 \ 26 | CMD wget --spider --timeout=1 http://${HOST}:${PORT}/health || exit 1 27 | 28 | CMD node build -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Jacob Shuman 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 | 2 | 3 | logo 4 | 5 | 6 | # readl8r 7 | 8 | > A no-nonsense read later service 9 | 10 | 11 | 12 | 13 | svelte badge 14 | 15 | 16 | 17 | 18 | 19 | tailwindcss badge 20 | 21 | 22 | 23 | 24 | 25 | docker badge 26 | 27 | 28 | 29 | ## :star: Features 30 | 31 | - :heavy_plus_sign: [Add an article](#heavy_plus_sign-add-an-article) by making a `POST` request (with `url` in a `JSON` body) to `/articles/add`. 32 | - :clipboard: [Get a `JSON` array of all articles](#clipboard-get-a-json-array-of-all-articles) by making a `GET` request to `/articles`. 33 | - :wastebasket: [Remove all articles](#wastebasket-remove-all-articles) by making a `DELETE` request to `/articles/clear`. 34 | - :file_cabinet: All articles are stored in a `/data/local.sqlite` SQLite database. 35 | - :inbox_tray: Get an [RSS](https://www.rssboard.org/rss-specification), [Atom](https://validator.w3.org/feed/docs/atom.html), and [JSON](https://www.jsonfeed.org/) feed of articles at [`/rss`](#inbox_tray-generate-rss2-feed-from-articles), [`/atom`](#inbox_tray-generate-atom-feed-from-articles), and [`/json`](#inbox_tray-generate-json-feed-from-articles) respectively. 36 | 37 | 38 | 39 | home page screenshot 40 | 41 | 42 | ## :rocket: Getting started 43 | 44 | ### :ship: Docker Compose 45 | 46 | Although you can clone/build readl8r locally, it's recommended for users to run the [docker image on Docker Hub](https://hub.docker.com/r/jacobshuman/readl8r). Copy the contents of this [docker-compose.yml](./docker-compose.yml) file to your computer and run: 47 | 48 | ```bash 49 | docker compose up 50 | ``` 51 | 52 | ### :palm_tree: Environment variables 53 | 54 | | Name | Required | Description | Default | 55 | | ------------------ | -------------------- | ------------------------------------------------------------------------------ | ----------- | 56 | | `AUTH_SECRET` | If `PASSWORD` is set | Used to sign auth JWTs | `undefined` | 57 | | `HOST` | No | Hostname or IP address where the service is hosted | `0.0.0.0` | 58 | | `PORT` | No | The port number used for the service | `80` | 59 | | `SECURE` | No | Indicates whether to use HTTPS (true) or HTTP (false) | `false` | 60 | | `PASSWORD` | No | Password required for authentication | `undefined` | 61 | | `FEED_TITLE` | No | Title of the feed (displayed on the web app) | `undefined` | 62 | | `FEED_DESCRIPTION` | No | Brief description of the feed's content and purpose (displayed on the web app) | `undefined` | 63 | | `FEED_IMAGE` | No | URL to an image that represents the feed (e.g., logo or banner) | `undefined` | 64 | | `FEED_FAVICON` | No | URL to the favicon to be displayed in browsers for the feed | `undefined` | 65 | | `FEED_COPYRIGHT` | No | Copyright information regarding the content of the feed | `undefined` | 66 | | `AUTHOR_NAME` | No | Name of the feed's author | `undefined` | 67 | | `AUTHOR_EMAIL` | No | Email address of the author | `undefined` | 68 | | `AUTHOR_LINK` | No | URL to the author's website or social media profile | `undefined` | 69 | 70 | ### :lock: Authentication 71 | 72 | You can **optionally** protect your reading list with a password by setting the `PASSWORD` and `AUTH_SECRET` environment variables in your docker compose config. 73 | 74 | This will protect all routes excluding feed routes (`/rss`, `/atom`, etc). 75 | 76 | I'm still looking into how rss aggregators generally handle auth for feeds and only want to add auth when it doesn't prevent aggregators from accessing reading lists. 77 | 78 | ## Stucture of an article 79 | 80 | ```ts 81 | { 82 | id: number; 83 | url: string; 84 | publish_date: string; // date article was published (added_date if this can't be found) 85 | added_date: string; // date the article was added to readl8r 86 | title: string | null; 87 | description: string | null; 88 | content: string | null; 89 | author: string | null; 90 | favicon: string | null; 91 | ttr: number | null; // estimated time to read article in seconds 92 | } 93 | ``` 94 | 95 | > For the most up to date definition, see [the actual typescript type](./src/lib/types.ts#L16). 96 | 97 | ## :heavy_plus_sign: Add an article 98 | 99 | :lock: **Requires Authentication** 100 | 101 | You can add an article by providing the article's url in the body of a `POST` request: 102 | 103 | `POST (http|https)://HOST:PORT/articles/add` 104 | 105 | ### :weight_lifting: body 106 | 107 | ```jsonc 108 | { 109 | // required 110 | "url": "https://dev.to/jacobshuman/wtf-is-a-github-profile-readmemd-1p8c" 111 | } 112 | ``` 113 | 114 | 127 | 128 | ### Responses 129 | 130 | | Status | Body | Content-Type | 131 | | ------ | ------------------------------------- | ------------ | 132 | | 200 | `article added successfully` | `text/plain` | 133 | | 400 | `url is required` | `text/plain` | 134 | | 400 | `unable to extract metadata at {url}` | `text/plain` | 135 | | 401 | `not authorized` | `text/plain` | 136 | 137 | ## :page_facing_up: Get a single `JSON` article 138 | 139 | :lock: **Requires Authentication** 140 | 141 | You can get a single `JSON` object representing an article by making a `GET` request to the `/articles/:id` route: 142 | 143 | `GET (http|https)://HOST:PORT/articles/:id` 144 | 145 | ### Responses 146 | 147 | | Status | Body | Content-Type | 148 | | ------ | ----------------------------------------- | ------------------ | 149 | | 200 | [Article](./src/lib/types.ts#L16) | `application/json` | 150 | | 401 | `not authorized` | `text/plain` | 151 | | 404 | `there is no article with an id of ":id"` | `text/plain` | 152 | 153 | ## :clipboard: Get a `JSON` array of all articles 154 | 155 | :lock: **Requires Authentication** 156 | 157 | You can get a `JSON` array of articles by making a `GET` request to the `/articles` route: 158 | 159 | `GET (http|https)://HOST:PORT/articles` 160 | 161 | ### Responses 162 | 163 | | Status | Body | Content-Type | 164 | | ------ | ----------------------------------- | ------------------ | 165 | | 200 | [Article[]](./src/lib/types.ts#L16) | `application/json` | 166 | | 401 | `not authorized` | `text/plain` | 167 | 168 | ## :memo: Update an article 169 | 170 | :lock: **Requires Authentication** 171 | 172 | You can update an article based on it's id by making a `PATCH` request to the `/articles/:id/update` route: 173 | 174 | `PATCH (http|https)://HOST:PORT/articles/:id/update` 175 | 176 | ### :weight_lifting: body 177 | 178 | ```jsonc 179 | { 180 | "article": { 181 | "url": "", // optional 182 | "publish_date": "", // optional 183 | "added_date": "", // optional 184 | "title": "", // optional 185 | "description": "", // optional 186 | "content": "", // optional 187 | "author": "", // optional 188 | "favicon": "", // optional 189 | "ttr": "" // optional 190 | } 191 | } 192 | ``` 193 | 194 | ### Responses 195 | 196 | | Status | Body | Content-Type | 197 | | ------ | ------------------------------------ | ------------ | 198 | | 200 | `article :id deleted successfully` | `text/plain` | 199 | | 401 | `not authorized` | `text/plain` | 200 | | 404 | `there is no article with id of :id` | `text/plain` | 201 | 202 | ## :wastebasket: Delete an article 203 | 204 | :lock: **Requires Authentication** 205 | 206 | You can delete an article based on it's id by making a `DELETE` request to the `/articles/:id/delete` route: 207 | 208 | `DELETE (http|https)://HOST:PORT/articles/:id/delete` 209 | 210 | ### Responses 211 | 212 | | Status | Body | Content-Type | 213 | | ------ | ------------------------------------ | ------------ | 214 | | 200 | `article :id deleted successfully` | `text/plain` | 215 | | 401 | `not authorized` | `text/plain` | 216 | | 404 | `there is no article with id of :id` | `text/plain` | 217 | 218 | ## :wastebasket: Remove all articles 219 | 220 | :lock: **Requires Authentication** 221 | 222 | `DELETE (http|https)://HOST:PORT/articles/clear` 223 | 224 | ### Responses 225 | 226 | | Status | Body | Content-Type | 227 | | ------ | --------------------------------- | ------------ | 228 | | 200 | `x articles cleared successfully` | `text/plain` | 229 | | 401 | `not authorized` | `text/plain` | 230 | 231 | ## :wastebasket: Purge old articles 232 | 233 | :lock: **Requires Authentication** 234 | 235 | You can manually purge articles older than a certain threshhold using the `/articles/purge` route. Simply pass an `older_than` query parameter in the url with the following format: 236 | 237 | ``` 238 | h = hours 239 | d = days 240 | m = months 241 | y = years 242 | 243 | h|d|m|y 244 | 245 | Examples 246 | 247 | 30d = 30 days 248 | 4m = 4 months 249 | 2y = 2 years 250 | ``` 251 | 252 | > Please note the `older_than` parameter does **not** accept numbers with decimals. 253 | 254 | `DELETE (http|https)://HOST:PORT/articles/purge?older_than=(h|d|m|y)` 255 | 256 | ### Responses 257 | 258 | | Status | Body | Content-Type | 259 | | ------ | -------------------------------------------------------------- | ------------ | 260 | | 200 | `x articles purged successfully` | `text/plain` | 261 | | 400 | `invalid format, use the formula ""` | `text/plain` | 262 | | 401 | `not authorized` | `text/plain` | 263 | 264 | ## :inbox_tray: Generate RSS2 feed from articles 265 | 266 | `GET (http|https)://HOST:PORT/rss` 267 | 268 | `GET (http|https)://HOST:PORT/rss.xml` 269 | 270 | `GET (http|https)://HOST:PORT/feed` 271 | 272 | `GET (http|https)://HOST:PORT/feed.xml` 273 | 274 | ### Responses 275 | 276 | | Status | Body | Content-Type | 277 | | ------ | ------------------------------------------------------- | --------------------- | 278 | | 200 | [RSS2 Feed](https://www.rssboard.org/rss-specification) | `application/rss+xml` | 279 | 280 | ## :inbox_tray: Generate Atom feed from articles 281 | 282 | `GET (http|https)://HOST:PORT/atom` 283 | 284 | ### Responses 285 | 286 | | Status | Body | Content-Type | 287 | | ------ | --------------------------------------------------------- | ---------------------- | 288 | | 200 | [Atom Feed](https://validator.w3.org/feed/docs/atom.html) | `application/atom+xml` | 289 | 290 | ## :inbox_tray: Generate JSON feed from articles 291 | 292 | `GET (http|https)://HOST:PORT/json` 293 | 294 | ### Responses 295 | 296 | | Status | Body | Content-Type | 297 | | ------ | -------------------------------------- | ------------------ | 298 | | 200 | [JSON Feed](https://www.jsonfeed.org/) | `application/json` | 299 | 300 | ## :heart: Health 301 | 302 | A simple `GET` route to see if the server is up and ready to handle incoming requests. 303 | 304 | `GET (http|https)://HOST:PORT/health` 305 | 306 | ### Responses 307 | 308 | | Status | Body | Content-Type | 309 | | ------ | ---- | ------------ | 310 | | 200 | `OK` | `text/plain` | 311 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | readl8r: 3 | platform: linux/amd64 4 | image: jacobshuman/readl8r:latest 5 | container_name: readl8r 6 | restart: unless-stopped 7 | volumes: 8 | - ./data:/app/data 9 | # environment: 10 | # AUTH_SECRET: # required if PASSWORD is set 11 | # HOST: # optional 12 | # PORT: # optional 13 | # SECURE: # optional 14 | # PASSWORD: # optional 15 | # FEED_TITLE: # optional (this will also appear as the title of the web app) 16 | # FEED_DESCRIPTION: # optional 17 | # FEED_IMAGE: # optional 18 | # FEED_FAVICON: # optional 19 | # FEED_COPYRIGHT: # optional 20 | # AUTHOR_NAME: # optional 21 | # AUTHOR_EMAIL: # optional 22 | # AUTHOR_LINK: # optional 23 | ports: 24 | - 8080:80 25 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import ts from 'typescript-eslint'; 3 | import svelte from 'eslint-plugin-svelte'; 4 | import prettier from 'eslint-config-prettier'; 5 | import globals from 'globals'; 6 | 7 | /** @type {import('eslint').Linter.Config[]} */ 8 | export default [ 9 | js.configs.recommended, 10 | ...ts.configs.recommended, 11 | ...svelte.configs['flat/recommended'], 12 | prettier, 13 | ...svelte.configs['flat/prettier'], 14 | { 15 | languageOptions: { 16 | globals: { 17 | ...globals.browser, 18 | ...globals.node 19 | } 20 | } 21 | }, 22 | { 23 | files: ['**/*.svelte'], 24 | languageOptions: { 25 | parserOptions: { 26 | parser: ts.parser 27 | } 28 | } 29 | }, 30 | { 31 | ignores: ['build/', '.svelte-kit/', 'dist/'] 32 | } 33 | ]; 34 | -------------------------------------------------------------------------------- /migrations/20240908T033145_rename_date_add_added_date.ts: -------------------------------------------------------------------------------- 1 | import { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.alterTable('articles').renameColumn('date', 'publish_date').execute(); 5 | await db.schema.alterTable('articles').addColumn('added_date', 'text').execute(); 6 | await db 7 | .updateTable('articles') 8 | .set({ added_date: new Date().toDateString() }) 9 | .where('added_date', 'is', null) 10 | .execute(); 11 | } 12 | 13 | export async function down(db: Kysely): Promise { 14 | await db.schema.alterTable('articles').dropColumn('added_date').execute(); 15 | await db.schema.alterTable('articles').renameColumn('publish_date', 'date').execute(); 16 | } 17 | -------------------------------------------------------------------------------- /migrations/20240908T040000_update_date_fields_to_iso_format.ts: -------------------------------------------------------------------------------- 1 | import { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely) { 4 | const articles = await db 5 | .selectFrom('articles') 6 | .select(['id', 'added_date', 'publish_date']) 7 | .execute(); 8 | 9 | const updatedArticles = articles.map((article) => ({ 10 | id: article.id, 11 | added_date: new Date(article.added_date).toISOString(), 12 | publish_date: new Date(article.publish_date).toISOString() 13 | })); 14 | 15 | await db.transaction().execute(async (trx) => { 16 | for (const { id, added_date, publish_date } of updatedArticles) { 17 | await trx 18 | .updateTable('articles') 19 | .set({ 20 | added_date, 21 | publish_date 22 | }) 23 | .where('id', '=', id) 24 | .execute(); 25 | } 26 | }); 27 | } 28 | 29 | export async function down(db: Kysely) { 30 | const articles = await db 31 | .selectFrom('articles') 32 | .select(['id', 'added_date', 'publish_date']) 33 | .execute(); 34 | 35 | const updatedArticles = articles.map((article) => ({ 36 | id: article.id, 37 | added_date: new Date(article.added_date).toDateString(), 38 | publish_date: new Date(article.publish_date).toDateString() 39 | })); 40 | 41 | await db.transaction().execute(async (t) => { 42 | for (const { id, added_date, publish_date } of updatedArticles) { 43 | await t 44 | .updateTable('articles') 45 | .set({ 46 | added_date, 47 | publish_date 48 | }) 49 | .where('id', '=', id) 50 | .execute(); 51 | } 52 | }); 53 | } 54 | -------------------------------------------------------------------------------- /migrations/20240908T050000_add_ttr.ts: -------------------------------------------------------------------------------- 1 | import { Kysely } from 'kysely'; 2 | 3 | export async function up(db: Kysely): Promise { 4 | await db.schema.alterTable('articles').addColumn('ttr', 'integer').execute(); 5 | } 6 | 7 | export async function down(db: Kysely): Promise { 8 | await db.schema.alterTable('articles').dropColumn('ttr').execute(); 9 | } 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "readl8r", 3 | "description": "A no-nonsense read later service", 4 | "version": "0.3.0", 5 | "private": true, 6 | "type": "module", 7 | "packageManager": "pnpm@9.6.0", 8 | "scripts": { 9 | "dev": "vite dev", 10 | "test": "vitest", 11 | "build": "vite build", 12 | "preview": "vite preview", 13 | "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", 14 | "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", 15 | "lint": "prettier --check . && eslint .", 16 | "format": "prettier --write ." 17 | }, 18 | "devDependencies": { 19 | "@extractus/article-extractor": "^8.0.10", 20 | "@fontsource/unifrakturcook": "^5.0.20", 21 | "@fontsource/unifrakturmaguntia": "^5.0.20", 22 | "@iconify/svelte": "^4.0.2", 23 | "@sveltejs/adapter-node": "^5.2.2", 24 | "@sveltejs/kit": "^2.0.0", 25 | "@sveltejs/vite-plugin-svelte": "4.0.0-next.7", 26 | "@types/better-sqlite3": "^7.6.11", 27 | "@types/eslint": "^9.6.0", 28 | "@types/rss": "^0.0.32", 29 | "autoprefixer": "^10.4.20", 30 | "bits-ui": "^0.21.13", 31 | "chalk": "^5.3.0", 32 | "clsx": "^2.1.1", 33 | "eslint": "^9.0.0", 34 | "eslint-config-prettier": "^9.1.0", 35 | "eslint-plugin-svelte": "^2.36.0", 36 | "feed": "^4.2.2", 37 | "globals": "^15.0.0", 38 | "jose": "^5.8.0", 39 | "kysely": "^0.27.4", 40 | "kysely-ctl": "^0.9.0", 41 | "prettier": "^3.3.3", 42 | "prettier-plugin-svelte": "^3.2.6", 43 | "prettier-plugin-tailwindcss": "^0.6.5", 44 | "radash": "^12.1.0", 45 | "svelte": "5.0.3", 46 | "svelte-check": "^3.6.0", 47 | "tailwind-merge": "^2.5.2", 48 | "tailwindcss": "^3.4.9", 49 | "typescript": "^5.0.0", 50 | "typescript-eslint": "^8.0.0", 51 | "vite": "^5.0.3", 52 | "vitest": "^2.1.0" 53 | }, 54 | "dependencies": { 55 | "better-sqlite3": "^11.2.1" 56 | } 57 | } -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /screenshots/home-dark.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-shuman/readl8r/955f78bf89f6507ae9a7ccb4d9b58ac987538dcd/screenshots/home-dark.jpeg -------------------------------------------------------------------------------- /screenshots/home-light.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacob-shuman/readl8r/955f78bf89f6507ae9a7ccb4d9b58ac987538dcd/screenshots/home-light.jpeg -------------------------------------------------------------------------------- /src/app.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss/base'; 2 | @import 'tailwindcss/components'; 3 | @import 'tailwindcss/utilities'; 4 | -------------------------------------------------------------------------------- /src/app.d.ts: -------------------------------------------------------------------------------- 1 | // See https://kit.svelte.dev/docs/types#app 2 | // for information about these interfaces 3 | declare global { 4 | namespace App { 5 | // interface Error {} 6 | // interface Locals {} 7 | // interface PageData {} 8 | // interface PageState {} 9 | // interface Platform {} 10 | } 11 | } 12 | 13 | export {}; 14 | -------------------------------------------------------------------------------- /src/app.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 12 | 13 | readl8r 14 | %sveltekit.head% 15 | 16 | 17 | 21 |
%sveltekit.body%
22 | 23 | 24 | -------------------------------------------------------------------------------- /src/hooks.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { logger } from '$lib/utils'; 3 | import { text, type Handle } from '@sveltejs/kit'; 4 | 5 | export const handle: Handle = async ({ event, resolve }) => { 6 | if (env.PASSWORD && !env.AUTH_SECRET) { 7 | logger.error( 8 | 'You must set $AUTH_SECRET since you set $PASSWORD ($AUTH_SECRET is used to sign JWTs!)' 9 | ); 10 | 11 | return text( 12 | 'You must set $AUTH_SECRET since you set $PASSWORD ($AUTH_SECRET is used to sign JWTs!)', 13 | { status: 500, headers: { 'Content-Type': 'text/plain' } } 14 | ); 15 | } 16 | 17 | return await resolve(event); 18 | }; 19 | -------------------------------------------------------------------------------- /src/lib/auth.test.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import type { Cookies } from '@sveltejs/kit'; 3 | import { SignJWT } from 'jose'; 4 | import { beforeEach, describe, expect, it } from 'vitest'; 5 | import { isAuthorized, verifyJwt } from './auth'; 6 | 7 | const password = 'password123'; 8 | const authSecret = 'super-duper-secret'; 9 | 10 | beforeEach(() => { 11 | env.PASSWORD = ''; 12 | env.AUTH_SECRET = ''; 13 | }); 14 | 15 | describe('isAuthorized()', () => { 16 | it('$PASSWORD and $AUTH_SECRET are NOT set', async () => { 17 | expect(await isAuthorized()).toEqual(true); 18 | }); 19 | 20 | it('bearer token matches $PASSWORD', async () => { 21 | const request = new Request('https://example.org', { 22 | headers: { 23 | Authorization: `Bearer ${password}` 24 | } 25 | }); 26 | 27 | env.PASSWORD = password; 28 | 29 | expect(await isAuthorized({ request })).toEqual(true); 30 | }); 31 | 32 | it('bearer token does not match $PASSWORD', async () => { 33 | const request = new Request('https://example.org', { 34 | headers: { 35 | Authorization: `Bearer something-else` 36 | } 37 | }); 38 | 39 | env.PASSWORD = password; 40 | 41 | expect(await isAuthorized({ request })).toEqual(false); 42 | }); 43 | 44 | it('$PASSWORD is set but no cookie or bearer token is passed', async () => { 45 | env.PASSWORD = password; 46 | 47 | expect(await isAuthorized()).toEqual(false); 48 | }); 49 | 50 | it('valid auth cookie', async () => { 51 | const jwt = await new SignJWT({}) 52 | .setProtectedHeader({ alg: 'HS256' }) 53 | .setIssuedAt() 54 | .setExpirationTime('30d') 55 | .sign(new TextEncoder().encode(authSecret)); 56 | 57 | const cookies = { 58 | get: () => jwt 59 | } as unknown as Cookies; 60 | 61 | env.PASSWORD = password; 62 | env.AUTH_SECRET = authSecret; 63 | 64 | expect(await verifyJwt(cookies.get('auth'))).toEqual(true); 65 | expect(await isAuthorized({ cookies })).toEqual(true); 66 | }); 67 | 68 | it('auth cookie with invalid password', async () => { 69 | const jwt = await new SignJWT({}) 70 | .setProtectedHeader({ alg: 'HS256' }) 71 | .setExpirationTime('30d') 72 | .sign(new TextEncoder().encode('something-else')); 73 | 74 | const cookies = { 75 | get: () => jwt 76 | } as unknown as Cookies; 77 | 78 | env.PASSWORD = password; 79 | env.AUTH_SECRET = authSecret; 80 | 81 | expect(await verifyJwt(cookies.get('auth'))).toEqual(false); 82 | expect(await isAuthorized({ cookies })).toEqual(false); 83 | }); 84 | 85 | it('expired auth cookie', async () => { 86 | const jwt = await new SignJWT({}) 87 | .setProtectedHeader({ alg: 'HS256' }) 88 | .setExpirationTime(Math.floor(Date.now() / 1000) - 60 * 24 * 60 * 60) 89 | .sign(new TextEncoder().encode(authSecret)); 90 | 91 | const cookies = { 92 | get: () => jwt 93 | } as unknown as Cookies; 94 | 95 | env.PASSWORD = password; 96 | env.AUTH_SECRET = authSecret; 97 | 98 | expect(await verifyJwt(cookies.get('auth'))).toEqual(false); 99 | expect(await isAuthorized({ cookies })).toEqual(false); 100 | }); 101 | }); 102 | -------------------------------------------------------------------------------- /src/lib/auth.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import type { Cookies } from '@sveltejs/kit'; 3 | import { jwtVerify } from 'jose'; 4 | 5 | export async function isAuthorized({ 6 | request, 7 | cookies 8 | }: { 9 | request?: Request; 10 | cookies?: Cookies; 11 | } = {}): Promise { 12 | return ( 13 | await Promise.all([ 14 | // No password is set 15 | (async () => !env.PASSWORD)(), 16 | 17 | // Check bearer token 18 | (async () => 19 | request && request.headers.get('authorization')?.split('Bearer ')[1] === env.PASSWORD)(), 20 | 21 | // User has `auth` cookie 22 | (async () => cookies && (await verifyJwt(cookies.get('auth'))))() 23 | ]) 24 | ).some((r) => r); 25 | } 26 | 27 | export async function verifyJwt(token?: string): Promise { 28 | if (!token) { 29 | return false; 30 | } 31 | 32 | const secretKey = new TextEncoder().encode(env.AUTH_SECRET); 33 | 34 | try { 35 | await jwtVerify(token, secretKey); 36 | return true; 37 | } catch { 38 | return false; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/lib/components/Button.svelte: -------------------------------------------------------------------------------- 1 | 26 | 27 | {#snippet loadingChildren()} 28 | {#if loading} 29 | 30 | {:else} 31 | {@render children()} 32 | {/if} 33 | {/snippet} 34 | 35 | {#if href} 36 | 37 | {@render loadingChildren()} 38 | 39 | {:else} 40 | 43 | {/if} 44 | -------------------------------------------------------------------------------- /src/lib/components/IconButton.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 | {#snippet _icon()} 25 | 26 | {/snippet} 27 | 28 | {#snippet _interactiveElement(builder?: any)} 29 | {#if href} 30 | 31 | {@render _icon()} 32 | 33 | {:else} 34 | 37 | {/if} 38 | {/snippet} 39 | 40 | 41 | {@render _interactiveElement(builder)} 42 | 43 | -------------------------------------------------------------------------------- /src/lib/components/TextField.svelte: -------------------------------------------------------------------------------- 1 | 23 | 24 |
25 | {#if label || error} 26 | 33 | {/if} 34 | 35 | 48 |
49 | -------------------------------------------------------------------------------- /src/lib/components/Tooltip.svelte: -------------------------------------------------------------------------------- 1 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | {#if isMounted && message} 21 | 22 |
28 | {message} 29 |
30 |
31 | {/if} 32 |
33 | -------------------------------------------------------------------------------- /src/lib/db.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from 'vitest'; 2 | 3 | describe.todo('getDb()', () => {}); 4 | describe.todo('addArticle()', () => {}); 5 | describe.todo('getArticle()', () => {}); 6 | describe.todo('getArticles()', () => {}); 7 | describe.todo('updateArticle()', () => {}); 8 | describe.todo('deleteArticle()', () => {}); 9 | describe.todo('deleteAllArticles()', () => {}); 10 | describe.todo('getPurgeDate()', () => {}); 11 | describe.todo('purgeArticles()', () => {}); 12 | -------------------------------------------------------------------------------- /src/lib/db.ts: -------------------------------------------------------------------------------- 1 | import BetterSqlite3 from 'better-sqlite3'; 2 | import { 3 | DeleteResult, 4 | InsertResult, 5 | Kysely, 6 | Migrator, 7 | SqliteDialect, 8 | type Migration 9 | } from 'kysely'; 10 | import { mkdirSync } from 'node:fs'; 11 | import type { Article, ArticleUpdate, Database, NewArticle } from './types'; 12 | import { logger } from './utils'; 13 | 14 | export async function getDb(): Promise> { 15 | mkdirSync('./data', { recursive: true }); 16 | 17 | const db = new Kysely({ 18 | dialect: new SqliteDialect({ 19 | database: new BetterSqlite3('data/local.sqlite') 20 | }) 21 | }); 22 | const migrator = new Migrator({ 23 | db, 24 | provider: { 25 | getMigrations: async () => 26 | import.meta.glob('../../migrations/**.ts', { 27 | eager: true 28 | }) as Record 29 | } 30 | }); 31 | 32 | db.schema 33 | .createTable('articles') 34 | .ifNotExists() 35 | .addColumn('id', 'integer', (col) => col.primaryKey().autoIncrement()) 36 | .addColumn('url', 'text', (col) => col.notNull()) 37 | .addColumn('title', 'text') 38 | .addColumn('description', 'text') 39 | .addColumn('content', 'text') 40 | .addColumn('author', 'text') 41 | .addColumn('date', 'text') 42 | .addColumn('favicon', 'text') 43 | .execute(); 44 | 45 | const { error, results } = await migrator.migrateToLatest(); 46 | 47 | for (const res of results ?? []) { 48 | if (res.status === 'Success') { 49 | logger.success(`migration "${res.migrationName}" was executed successfully`); 50 | } else if (res.status === 'Error') { 51 | logger.error(`failed to execute migration "${res.migrationName}"`); 52 | } 53 | } 54 | 55 | if (error) { 56 | logger.error('failed to migrate'); 57 | logger.error(error); 58 | process.exit(1); 59 | } 60 | 61 | return db; 62 | } 63 | 64 | export async function addArticle(article: NewArticle): Promise { 65 | const db = await getDb(); 66 | 67 | return await db.insertInto('articles').values(article).execute(); 68 | } 69 | 70 | export async function getArticle(id: number): Promise
{ 71 | const db = await getDb(); 72 | 73 | return await db.selectFrom('articles').where('id', '=', id).selectAll().executeTakeFirst(); 74 | } 75 | 76 | export async function getArticles(): Promise { 77 | const db = await getDb(); 78 | 79 | return await db.selectFrom('articles').selectAll().execute(); 80 | } 81 | 82 | export async function updateArticle(id: number, article: ArticleUpdate): Promise { 83 | const db = await getDb(); 84 | 85 | return await db 86 | .updateTable('articles') 87 | .set(article) 88 | .where('id', '=', id) 89 | .returningAll() 90 | .execute(); 91 | } 92 | 93 | export async function deleteArticle(id: number): Promise { 94 | const db = await getDb(); 95 | 96 | return await db.deleteFrom('articles').where('id', '=', id).execute(); 97 | } 98 | 99 | export async function deleteAllArticles(): Promise { 100 | const db = await getDb(); 101 | 102 | return await db.deleteFrom('articles').execute(); 103 | } 104 | 105 | export function getPurgeDate(period: 'h' | 'd' | 'm' | 'y', amount: number): Date { 106 | const date = new Date(); 107 | 108 | switch (period) { 109 | case 'h': 110 | date.setHours(date.getHours() - amount); 111 | break; 112 | case 'd': 113 | date.setDate(date.getDate() - amount); 114 | break; 115 | case 'm': 116 | date.setMonth(date.getMonth() - amount); 117 | break; 118 | case 'y': 119 | date.setFullYear(date.getFullYear() - amount); 120 | break; 121 | } 122 | 123 | return date; 124 | } 125 | export async function purgeArticles( 126 | period: 'h' | 'd' | 'm' | 'y', 127 | amount: number 128 | ): Promise { 129 | const db = await getDb(); 130 | 131 | return await db 132 | .deleteFrom('articles') 133 | .where('added_date', '<', getPurgeDate(period, amount).toISOString()) 134 | .execute(); 135 | } 136 | -------------------------------------------------------------------------------- /src/lib/feed.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { Feed } from 'feed'; 3 | import type { Article } from './types'; 4 | 5 | const baseUrl = `${env.SECURE ? 'https' : 'http'}://${env.HOST}:${env.PORT}`; 6 | 7 | export function generateFeed(items: Article[]): Feed { 8 | const feed = new Feed({ 9 | title: env.FEED_TITLE ?? 'readl8r', 10 | description: env.FEED_DESCRIPTION, 11 | id: baseUrl, 12 | link: baseUrl, 13 | language: 'en', 14 | image: env.FEED_IMAGE ? `${baseUrl}/${env.FEED_IMAGE}` : undefined, 15 | favicon: env.FEED_FAVICON ? `${baseUrl}/${env.FEED_FAVICON}` : undefined, 16 | copyright: env.FEED_COPYRIGHT ?? 'No copyright notice', 17 | updated: new Date(), 18 | generator: 'readl8r', 19 | feedLinks: { 20 | json: `${baseUrl}/json`, 21 | atom: `${baseUrl}/atom`, 22 | rss: `${baseUrl}/rss` 23 | }, 24 | author: { 25 | name: env.AUTHOR_NAME, 26 | email: env.AUTHOR_EMAIL, 27 | link: env.AUTHOR_LINK 28 | }, 29 | hub: `${baseUrl}/hub` 30 | }); 31 | 32 | for (let item of items) { 33 | feed.addItem({ 34 | title: item.title ?? 'No title', 35 | description: item.description ?? 'No description', 36 | link: item.url, 37 | date: new Date(item.publish_date), // TODO: whats the difference between date and published? 38 | content: item.content ?? undefined, 39 | author: item.author ? [{ name: item.author }] : undefined 40 | }); 41 | } 42 | 43 | return feed; 44 | } 45 | 46 | const adjectives: string[] = [ 47 | // goofy 48 | 'Witty', 49 | 'Quirky', 50 | 'Silly', 51 | 'Goofy', 52 | 'Zany', 53 | 'Whimsical', 54 | 'Laughable', 55 | 'Chuckling', 56 | 'Nonsense', 57 | 'Absurd' 58 | ]; 59 | const nouns: string[] = [ 60 | // formal 61 | 'Daily', 62 | 'Weekly', 63 | 'Monthly', 64 | 'York', 65 | 'City', 66 | 'Urban', 67 | 'Coastal', 68 | 'National', 69 | 'Global', 70 | 'Central', 71 | 72 | // goofy 73 | 'Pickle', 74 | 'Banana', 75 | 'Squirrel', 76 | 'Tater Tot', 77 | 'Llama', 78 | 'Chicken', 79 | 'Potato', 80 | 'Unicorn', 81 | 'Jellybean' 82 | ]; 83 | const publications: string[] = [ 84 | 'Press', 85 | 'Bulletin', 86 | 'Gazette', 87 | 'Times', 88 | 'Ledger', 89 | 'Chirp', 90 | 'Report', 91 | 'Update', 92 | 'Journal', 93 | 'Herald', 94 | 'Tribune', 95 | 'Chronicles', 96 | 'Observer', 97 | 'Review', 98 | 'Express', 99 | 'Post' 100 | ]; 101 | 102 | export function generateFeedTitle(): string { 103 | const adjective = 104 | Math.random() < 0.5 ? `${adjectives[Math.floor(Math.random() * adjectives.length)]} ` : ''; 105 | const noun = nouns[Math.floor(Math.random() * nouns.length)]; 106 | const publication = publications[Math.floor(Math.random() * publications.length)]; 107 | 108 | return `The ${adjective}${noun} ${publication}`; 109 | } 110 | 111 | export function generateFeedDescription(): string { 112 | const descriptions = [ 113 | 'Making Headlines Less Boring', 114 | 'Headlines, Now with Flavor.', 115 | 'Bringing Headlines to Life.', 116 | 'Now 50% Less Boring!', 117 | 'Your Daily Briefing, Lightly Toasted.', 118 | "The World's Events, Served with a Wink.", 119 | 'The Daily Scoop with Extra Sass.' 120 | ]; 121 | 122 | return descriptions[Math.floor(Math.random() * descriptions.length)]; 123 | } 124 | -------------------------------------------------------------------------------- /src/lib/types.ts: -------------------------------------------------------------------------------- 1 | import { type Generated, type Insertable, type Selectable, type Updateable } from 'kysely'; 2 | 3 | export interface ArticleTable { 4 | id: Generated; 5 | url: string; 6 | publish_date: string; // date article was published (added_date if this can't be found) 7 | added_date: string; // date the article was added to readl8r 8 | title: string | null; 9 | description: string | null; 10 | content: string | null; 11 | author: string | null; 12 | favicon: string | null; 13 | ttr: number | null; // estimated time to read article in seconds 14 | } 15 | 16 | export type Article = Selectable; 17 | export type NewArticle = Insertable; 18 | export type ArticleUpdate = Updateable; 19 | 20 | export interface Database { 21 | articles: ArticleTable; 22 | } 23 | -------------------------------------------------------------------------------- /src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | export const logger = { 4 | info: (...args: any[]) => console.info(chalk.bgBlue.black(' INFO '), ...args), 5 | warn: (...args: any[]) => console.info(chalk.bgYellow.black(' WARN '), ...args), 6 | error: (...args: any[]) => console.info(chalk.bgRed.black(' ERROR '), ...args), 7 | success: (...args: any[]) => console.info(chalk.bgGreen.black(' SUCCESS '), ...args) 8 | }; 9 | -------------------------------------------------------------------------------- /src/routes/+layout.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { getArticles } from '$lib/db'; 3 | import { generateFeedDescription, generateFeedTitle } from '$lib/feed'; 4 | import type { LayoutServerLoad } from './$types'; 5 | 6 | export const load: LayoutServerLoad = async () => ({ 7 | title: env.FEED_TITLE ?? generateFeedTitle(), 8 | description: env.FEED_DESCRIPTION ?? generateFeedDescription(), 9 | articles: await getArticles() 10 | }); 11 | -------------------------------------------------------------------------------- /src/routes/+layout.svelte: -------------------------------------------------------------------------------- 1 | 8 | 9 | 10 | {data.title} 11 | 12 | 13 | 14 | {@render children()} 15 | -------------------------------------------------------------------------------- /src/routes/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { isAuthorized } from '$lib/auth'; 3 | import { redirect } from '@sveltejs/kit'; 4 | import type { PageServerLoad } from './$types'; 5 | 6 | export const load: PageServerLoad = async ({ request, cookies }) => { 7 | if (!(await isAuthorized({ request, cookies }))) { 8 | return redirect(302, '/login'); 9 | } 10 | 11 | return { usesAuth: Boolean(env.PASSWORD) }; 12 | }; 13 | -------------------------------------------------------------------------------- /src/routes/+page.svelte: -------------------------------------------------------------------------------- 1 | 32 | 33 | {#if isMounted} 34 |
38 |
39 |

{data.title}

40 |

{data.description}

41 |
42 | 43 |
46 |

47 | {new Date().toDateString()} 48 | 49 | 50 | {data.articles.length} Article{data.articles.length !== 1 ? 's' : ''} 51 | 52 |

53 | 54 |
55 | 56 | 57 | 58 | {#if data.usesAuth} 59 | 60 | 61 | {/if} 62 |
63 |
64 |
65 | 66 |
67 | {#if data.articles.length > 0} 68 | {#each data.articles as article, index} 69 |
70 | {/each} 71 | {:else} 72 | 73 |

74 | {generateNoArticlesTitle()} 75 |

76 |

There are no articles in your list...

77 |
78 | 79 | 80 |

Developers Wanted!

81 | 82 |

83 | Make a POST request to /articles/add to add a new article. 84 |

85 | 86 | 89 |
90 | 91 | 92 |

Not a developer? No Problem!

93 | 94 |
95 | 103 | 104 | 124 |
125 | 126 | 127 |
128 | {/if} 129 |
130 | {/if} 131 | 132 | 148 | -------------------------------------------------------------------------------- /src/routes/Article.svelte: -------------------------------------------------------------------------------- 1 | 34 | 35 | 36 |
37 |

38 | {#if favicon} 39 | {`${title} 40 | {/if} 41 | 42 | {title} 43 |

44 | 45 |

46 | {author || 'By some author'} 47 | 48 | {publish_date ? new Date(publish_date).toDateString() : 'At some point in time'} 49 |

50 |
51 | 52 |

{description}

53 | 54 | {#if !fake} 55 |
56 | 57 | Read more 58 | {#if ttr != null} 59 | ({Math.round(ttr / 60)} minutes) 60 | {/if} 61 | 62 | 63 | { 67 | const { status } = await fetch(`/articles/${id}/delete`, { 68 | method: 'DELETE', 69 | credentials: 'same-origin' 70 | }); 71 | 72 | if (status === 200) { 73 | await invalidateAll(); 74 | } 75 | }} 76 | /> 77 |
78 | {/if} 79 |
80 | -------------------------------------------------------------------------------- /src/routes/ArticleCard.svelte: -------------------------------------------------------------------------------- 1 | 6 | 7 |
12 | {@render children()} 13 |
14 | 15 | 29 | -------------------------------------------------------------------------------- /src/routes/[...path]/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@sveltejs/kit'; 2 | 3 | export const load = async () => redirect(303, '/'); 4 | -------------------------------------------------------------------------------- /src/routes/articles/+server.ts: -------------------------------------------------------------------------------- 1 | import { isAuthorized } from '$lib/auth'; 2 | import { getArticles } from '$lib/db'; 3 | import { json, text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const GET: RequestHandler = async ({ request, cookies }) => { 6 | if (!(await isAuthorized({ request, cookies }))) { 7 | return text('not authorized', { status: 401, headers: { 'Content-Type': 'text/plain' } }); 8 | } 9 | 10 | return json(await getArticles(), { status: 200 }); 11 | }; 12 | -------------------------------------------------------------------------------- /src/routes/articles/[articleId]/+server.ts: -------------------------------------------------------------------------------- 1 | import { isAuthorized } from '$lib/auth'; 2 | import { getArticle } from '$lib/db'; 3 | import { json, text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const GET: RequestHandler = async ({ params, request, cookies }) => { 6 | const { articleId } = params; 7 | 8 | if (!(await isAuthorized({ request, cookies }))) { 9 | return text('not authorized', { status: 401, headers: { 'Content-Type': 'text/plain' } }); 10 | } 11 | 12 | const article = await getArticle(Number(articleId)); 13 | 14 | if (!article) { 15 | return text(`there is no article with an id of "${articleId}"`, { 16 | status: 404, 17 | headers: { 'Content-Type': 'text/plain' } 18 | }); 19 | } 20 | 21 | return json(article, { status: 200 }); 22 | }; 23 | -------------------------------------------------------------------------------- /src/routes/articles/[articleId]/delete/+server.ts: -------------------------------------------------------------------------------- 1 | import { isAuthorized } from '$lib/auth'; 2 | import { deleteArticle } from '$lib/db'; 3 | import { text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const DELETE: RequestHandler = async ({ request, params, cookies }) => { 6 | const { articleId } = params; 7 | 8 | if (!(await isAuthorized({ request, cookies }))) { 9 | return text('not authorized', { status: 401, headers: { 'Content-Type': 'text/plain' } }); 10 | } 11 | 12 | const [{ numDeletedRows }] = await deleteArticle(Number(articleId)); 13 | 14 | if (numDeletedRows < 1) { 15 | return text(`there is no article with id of ${articleId}`, { 16 | status: 404, 17 | headers: { 'Content-Type': 'text/plain' } 18 | }); 19 | } 20 | 21 | return text(`article ${articleId} deleted successfully`, { 22 | status: 200, 23 | headers: { 'Content-Type': 'text/plain' } 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /src/routes/articles/[articleId]/update/+server.ts: -------------------------------------------------------------------------------- 1 | import { isAuthorized } from '$lib/auth'; 2 | import { updateArticle } from '$lib/db'; 3 | import { json, text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const PATCH: RequestHandler = async ({ request, params, cookies }) => { 6 | const { articleId } = params; 7 | const { article } = await request.json(); 8 | 9 | if (!(await isAuthorized({ request, cookies }))) { 10 | return text('not authorized', { status: 401, headers: { 'Content-Type': 'text/plain' } }); 11 | } 12 | 13 | const updatedArticle = await updateArticle(Number(articleId), article); 14 | 15 | if (!updatedArticle) { 16 | return text(`there is no article with id of ${articleId}`, { 17 | status: 404, 18 | headers: { 'Content-Type': 'text/plain' } 19 | }); 20 | } 21 | 22 | return json(updatedArticle, { status: 200 }); 23 | }; 24 | -------------------------------------------------------------------------------- /src/routes/articles/add/+server.ts: -------------------------------------------------------------------------------- 1 | import { isAuthorized } from '$lib/auth'; 2 | import { addArticle } from '$lib/db'; 3 | import { extract } from '@extractus/article-extractor'; 4 | import { text, type RequestHandler } from '@sveltejs/kit'; 5 | 6 | export const POST: RequestHandler = async ({ request, cookies }) => { 7 | if (!request.body) { 8 | return text('url is required', { status: 400, headers: { 'Content-Type': 'text/plain' } }); 9 | } 10 | 11 | const { url } = await request.json(); 12 | 13 | if (!(await isAuthorized({ request, cookies }))) { 14 | return text('not authorized', { status: 401, headers: { 'Content-Type': 'text/plain' } }); 15 | } else if (!request.body || !url) { 16 | return text('url is required', { status: 400, headers: { 'Content-Type': 'text/plain' } }); 17 | } 18 | 19 | const article = await extract(url); 20 | 21 | if (article) { 22 | await addArticle({ 23 | url, 24 | title: article.title, 25 | description: article.description?.slice(0, 200) ?? article.content?.slice(0, 200) + '...', 26 | content: article.content, 27 | author: article.author, 28 | publish_date: article.published || new Date().toISOString(), 29 | added_date: new Date().toISOString(), 30 | favicon: article.favicon, 31 | ttr: article.ttr 32 | }); 33 | 34 | return text('article added successfully', { 35 | status: 200, 36 | headers: { 'Content-Type': 'text/plain' } 37 | }); 38 | } 39 | 40 | return text(`unable to extract metadata at "${url}"`, { 41 | status: 400, 42 | headers: { 'Content-Type': 'text/plain' } 43 | }); 44 | }; 45 | -------------------------------------------------------------------------------- /src/routes/articles/clear/+server.ts: -------------------------------------------------------------------------------- 1 | import { isAuthorized } from '$lib/auth'; 2 | import { deleteAllArticles } from '$lib/db'; 3 | import { text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const DELETE: RequestHandler = async ({ request, cookies }) => { 6 | if (!(await isAuthorized({ request, cookies }))) { 7 | return text('not authorized', { status: 401, headers: { 'Content-Type': 'text/plain' } }); 8 | } 9 | 10 | const [{ numDeletedRows }] = await deleteAllArticles(); 11 | 12 | return text(`${numDeletedRows} articles cleared successfully`, { 13 | status: 200, 14 | headers: { 'Content-Type': 'text/plain' } 15 | }); 16 | }; 17 | -------------------------------------------------------------------------------- /src/routes/articles/purge/+server.ts: -------------------------------------------------------------------------------- 1 | import { isAuthorized } from '$lib/auth'; 2 | import { purgeArticles } from '$lib/db'; 3 | import { text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const DELETE: RequestHandler = async ({ request, cookies, url }) => { 6 | const older_than = url.searchParams.get('older_than'); 7 | 8 | if (!older_than) { 9 | return text('missing required "older_than" url query parameter', { 10 | status: 400, 11 | headers: { 'Content-Type': 'text/plain' } 12 | }); 13 | } 14 | 15 | const [matchesFormat, amount, period] = older_than.match(/^([0-9]+)([hdmy])$/) ?? []; 16 | 17 | if (!(await isAuthorized({ request, cookies }))) { 18 | return text('not authorized', { status: 401, headers: { 'Content-Type': 'text/plain' } }); 19 | } else if (!matchesFormat) { 20 | return text('invalid format, use the formula ""', { 21 | status: 400, 22 | headers: { 'Content-Type': 'text/plain' } 23 | }); 24 | } 25 | 26 | const [{ numDeletedRows }] = await purgeArticles(period as 'h' | 'd' | 'm' | 'y', Number(amount)); 27 | 28 | return text(`${numDeletedRows} articles purged successfully`, { 29 | status: 200, 30 | headers: { 'Content-Type': 'text/plain' } 31 | }); 32 | }; 33 | -------------------------------------------------------------------------------- /src/routes/atom/+server.ts: -------------------------------------------------------------------------------- 1 | import { getArticles } from '$lib/db'; 2 | import { generateFeed } from '$lib/feed'; 3 | import { text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const GET: RequestHandler = async () => 6 | text(generateFeed(await getArticles()).atom1(), { 7 | status: 200, 8 | headers: { 'Content-Type': 'application/atom+xml' } 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes/feed.xml/+server.ts: -------------------------------------------------------------------------------- 1 | import { getArticles } from '$lib/db'; 2 | import { generateFeed } from '$lib/feed'; 3 | import { text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const GET: RequestHandler = async () => 6 | text(generateFeed(await getArticles()).rss2(), { 7 | status: 200, 8 | headers: { 'Content-Type': 'application/rss+xml' } 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes/feed/+server.ts: -------------------------------------------------------------------------------- 1 | import { getArticles } from '$lib/db'; 2 | import { generateFeed } from '$lib/feed'; 3 | import { text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const GET: RequestHandler = async () => 6 | text(generateFeed(await getArticles()).rss2(), { 7 | status: 200, 8 | headers: { 'Content-Type': 'application/rss+xml' } 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes/health/+server.ts: -------------------------------------------------------------------------------- 1 | import { text, type RequestHandler } from '@sveltejs/kit'; 2 | 3 | export const GET: RequestHandler = async () => 4 | text('OK', { status: 200, headers: { 'Content-Type': 'text/plain' } }); 5 | -------------------------------------------------------------------------------- /src/routes/json/+server.ts: -------------------------------------------------------------------------------- 1 | import { getArticles } from '$lib/db'; 2 | import { generateFeed } from '$lib/feed'; 3 | import { json, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const GET: RequestHandler = async () => { 6 | return json(generateFeed(await getArticles()).json1(), { 7 | status: 200 8 | }); 9 | }; 10 | -------------------------------------------------------------------------------- /src/routes/login/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { isAuthorized } from '$lib/auth'; 3 | import { fail, redirect } from '@sveltejs/kit'; 4 | import { SignJWT } from 'jose'; 5 | import type { PageServerLoad } from './$types'; 6 | 7 | export const load: PageServerLoad = async ({ request, cookies }) => { 8 | if (!env.PASSWORD) { 9 | return redirect(303, '/'); 10 | } 11 | 12 | if (cookies.get('auth')) { 13 | if (await isAuthorized({ request, cookies })) { 14 | return redirect(303, '/'); 15 | } else { 16 | cookies.delete('auth', { path: '/' }); 17 | } 18 | } 19 | }; 20 | 21 | export const actions = { 22 | default: async ({ cookies, request }) => { 23 | const data = await request.formData(); 24 | const password = data.get('password'); 25 | 26 | if (!password || typeof password !== 'string' || password !== env.PASSWORD) { 27 | return fail(401, { passwordError: 'Invalid Password' }); 28 | } 29 | 30 | const secretKey = new TextEncoder().encode(env.AUTH_SECRET); 31 | const jwt = await new SignJWT({}) 32 | .setProtectedHeader({ alg: 'HS256' }) 33 | .setIssuedAt() 34 | .setExpirationTime('30d') 35 | .sign(secretKey); 36 | 37 | cookies.set('auth', jwt, { 38 | path: '/', 39 | httpOnly: true, 40 | secure: true, 41 | maxAge: 60 * 60 * 24 * 30 // 30 days 42 | }); 43 | } 44 | }; 45 | -------------------------------------------------------------------------------- /src/routes/login/+page.svelte: -------------------------------------------------------------------------------- 1 | 108 | 109 | {#if isMounted} 110 | 111 |
112 | 113 |
114 |

Login

115 |

{subtitle}

116 |
117 | 118 | 119 |
120 | 128 | 129 | 130 | 131 |
132 | 133 | {#each jokeArticles.slice(0, 2) as article, index} 134 |
135 | {/each} 136 |
137 | {/if} 138 | -------------------------------------------------------------------------------- /src/routes/logout/+page.server.ts: -------------------------------------------------------------------------------- 1 | import { env } from '$env/dynamic/private'; 2 | import { redirect } from '@sveltejs/kit'; 3 | 4 | export const load = ({ cookies }) => { 5 | if (!env.PASSWORD) { 6 | return redirect(303, '/'); 7 | } 8 | 9 | cookies.delete('auth', { path: '/' }); 10 | return redirect(303, '/login'); 11 | }; 12 | -------------------------------------------------------------------------------- /src/routes/rss.xml/+server.ts: -------------------------------------------------------------------------------- 1 | import { getArticles } from '$lib/db'; 2 | import { generateFeed } from '$lib/feed'; 3 | import { text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const GET: RequestHandler = async () => 6 | text(generateFeed(await getArticles()).rss2(), { 7 | status: 200, 8 | headers: { 'Content-Type': 'application/rss+xml' } 9 | }); 10 | -------------------------------------------------------------------------------- /src/routes/rss/+server.ts: -------------------------------------------------------------------------------- 1 | import { getArticles } from '$lib/db'; 2 | import { generateFeed } from '$lib/feed'; 3 | import { text, type RequestHandler } from '@sveltejs/kit'; 4 | 5 | export const GET: RequestHandler = async () => 6 | text(generateFeed(await getArticles()).rss2(), { 7 | status: 200, 8 | headers: { 'Content-Type': 'application/rss+xml' } 9 | }); 10 | -------------------------------------------------------------------------------- /static/favicon-dark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /static/favicon-light.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /svelte.config.js: -------------------------------------------------------------------------------- 1 | import adapter from '@sveltejs/adapter-node'; 2 | 3 | /** @type {import('@sveltejs/kit').Config} */ 4 | const config = { 5 | kit: { 6 | adapter: adapter() 7 | } 8 | }; 9 | 10 | export default config; 11 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from 'tailwindcss'; 2 | 3 | export default { 4 | content: ['./src/**/*.{html,js,svelte,ts}'], 5 | 6 | theme: { 7 | extend: { 8 | colors: { 9 | black: '#202020', 10 | blacker: '#151515', 11 | white: '#FAF9F6', 12 | gray: '#E6E5E2', 13 | 'gray-light': '#757575', 14 | highlight: '#FBF719', 15 | 'highlight-dark': '#FF793B', 16 | highlighted: '#646302', 17 | 'highlighted-dark': '#FFEEE5' 18 | }, 19 | fontFamily: { 20 | title: ['UnifrakturCook', 'serif'], 21 | subtitle: ['UnifrakturMaguntia', 'serif'], 22 | body: ['Times New Roman', 'serif'] 23 | }, 24 | spacing: { 25 | page: '1.5rem' 26 | } 27 | } 28 | }, 29 | 30 | plugins: [] 31 | } as Config; 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./.svelte-kit/tsconfig.json", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "checkJs": true, 6 | "esModuleInterop": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "resolveJsonModule": true, 9 | "skipLibCheck": true, 10 | "sourceMap": true, 11 | "strict": true, 12 | "moduleResolution": "bundler" 13 | } 14 | // Path aliases are handled by https://kit.svelte.dev/docs/configuration#alias 15 | // except $lib which is handled by https://kit.svelte.dev/docs/configuration#files 16 | // 17 | // If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes 18 | // from the referenced tsconfig.json - TypeScript does not merge them in 19 | } 20 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { sveltekit } from '@sveltejs/kit/vite'; 2 | import { defineConfig } from 'vite'; 3 | 4 | export default defineConfig({ 5 | plugins: [sveltekit()] 6 | }); 7 | --------------------------------------------------------------------------------