1
0
Fork 0
forked from reese/reticule

prettify, convert line endings to unix format

This commit is contained in:
reese ovine 2023-05-21 15:07:33 -05:00
parent cf8f670f11
commit 0c4d884754
19 changed files with 3493 additions and 3493 deletions

View file

@ -1,17 +1,17 @@
root = true
[*]
indent_style = tab
end_of_line = crlf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space
indent_size = 2
[*.md]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false
root = true
[*]
indent_style = tab
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.yml]
indent_style = space
indent_size = 2
[*.md]
indent_style = space
indent_size = 2
trim_trailing_whitespace = false

View file

@ -1,30 +1,30 @@
FROM node:current-alpine AS builder
ENV NODE_ENV development
WORKDIR /app
COPY . .
# Install dependencies and transpile typescript
RUN apk add --no-cache git jq && \
npm ci && \
npm run build
FROM node:current-alpine AS runner
ENV NODE_ENV production
# Copy source code
WORKDIR /app
# When using COPY with more than one source file, the destination must be a directory and end with a /
COPY package* ./
COPY --from=builder /app/dist ./dist
# Install dependencies
RUN npm ci --omit=dev
# Create volume to persist database even if you forget to map a volume
VOLUME /data
# Start!
CMD npm start
FROM node:current-alpine AS builder
ENV NODE_ENV development
WORKDIR /app
COPY . .
# Install dependencies and transpile typescript
RUN apk add --no-cache git jq && \
npm ci && \
npm run build
FROM node:current-alpine AS runner
ENV NODE_ENV production
# Copy source code
WORKDIR /app
# When using COPY with more than one source file, the destination must be a directory and end with a /
COPY package* ./
COPY --from=builder /app/dist ./dist
# Install dependencies
RUN npm ci --omit=dev
# Create volume to persist database even if you forget to map a volume
VOLUME /data
# Start!
CMD npm start

View file

@ -1,24 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

View file

@ -1,43 +1,43 @@
{
"categories": [
{
"id": "ebb3548e-4679-4a76-a0f1-eb0e272ddcf9",
"name": "Shortcuts",
"shortcuts": [
{
"description": "Save an article to read later",
"iconName": "flat_color_pin",
"id": "39fb3ee7-cb24-4c7e-beec-4e12f260c453",
"launcherShortcut": true,
"name": "Read Later",
"quickSettingsTileShortcut": true,
"responseHandling": {
"uiType": "toast"
},
"url": "{{46f4aa6c-36f2-4355-ae47-02d7aba12ef7}}/add?key\u003d{{21e44c15-ba2d-4be1-832f-c0ff0106325d}}\u0026url\u003d{{fd0331b6-7636-4795-a079-c7ed5ae66aca}}"
}
]
}
],
"variables": [
{
"id": "46f4aa6c-36f2-4355-ae47-02d7aba12ef7",
"key": "reticule_instance",
"value": "https://your.instance.url.without.trailing.slash"
},
{
"id": "21e44c15-ba2d-4be1-832f-c0ff0106325d",
"key": "reticule_api_key",
"value": "your_key_that_you_used_when_setting_up_your_instance"
},
{
"flags": 1,
"id": "fd0331b6-7636-4795-a079-c7ed5ae66aca",
"key": "reticule_url",
"title": "Article URL",
"type": "text",
"urlEncode": true
}
],
"version": 51
}
{
"categories": [
{
"id": "ebb3548e-4679-4a76-a0f1-eb0e272ddcf9",
"name": "Shortcuts",
"shortcuts": [
{
"description": "Save an article to read later",
"iconName": "flat_color_pin",
"id": "39fb3ee7-cb24-4c7e-beec-4e12f260c453",
"launcherShortcut": true,
"name": "Read Later",
"quickSettingsTileShortcut": true,
"responseHandling": {
"uiType": "toast"
},
"url": "{{46f4aa6c-36f2-4355-ae47-02d7aba12ef7}}/add?key\u003d{{21e44c15-ba2d-4be1-832f-c0ff0106325d}}\u0026url\u003d{{fd0331b6-7636-4795-a079-c7ed5ae66aca}}"
}
]
}
],
"variables": [
{
"id": "46f4aa6c-36f2-4355-ae47-02d7aba12ef7",
"key": "reticule_instance",
"value": "https://your.instance.url.without.trailing.slash"
},
{
"id": "21e44c15-ba2d-4be1-832f-c0ff0106325d",
"key": "reticule_api_key",
"value": "your_key_that_you_used_when_setting_up_your_instance"
},
{
"flags": 1,
"id": "fd0331b6-7636-4795-a079-c7ed5ae66aca",
"key": "reticule_url",
"title": "Article URL",
"type": "text",
"urlEncode": true
}
],
"version": 51
}

View file

@ -1,7 +1,7 @@
{
"watch": [
"src"
],
"ext": "ts,js,json",
"exec": "ts-node --esm ./src/server.ts"
}
{
"watch": [
"src"
],
"ext": "ts,js,json",
"exec": "ts-node --esm ./src/server.ts"
}

5946
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -1,45 +1,45 @@
{
"type": "module",
"node": "^12.20.0 || ^14.13.1 || >=16.0.0",
"name": "reticule",
"version": "1.2.0",
"description": "Save articles to read later in your feed aggregator of choice.",
"scripts": {
"build": "rimraf -fr ./dist && tsc",
"dev": "nodemon --verbose",
"format": "prettier -w ./src/",
"prebuild": "./get_version.sh > src/version.ts",
"start": "node ./dist/server.js"
},
"keywords": [
"rss",
"pocket",
"bookmarklet"
],
"author": "Reese Sapphire <reese@ovine.xyz>",
"license": "Unlicense",
"devDependencies": {
"@types/express": "^4.17.13",
"@types/node": "^18.16.1",
"@types/sanitize-html": "^2.6.2",
"nodemon": "^2.0.16",
"prettier": "^2.6.2",
"rimraf": "^3.0.2",
"ts-node": "^10.7.0",
"typescript": "^4.6.4"
},
"dependencies": {
"@extractus/article-extractor": "^7.2.15",
"dotenv": "^16.0.1",
"env-var": "^7.3.1",
"express": "^4.18.1",
"express-async-errors": "^3.1.1",
"lowdb": "^6.0.1"
},
"prettier": {
"printWidth": 100,
"semi": false,
"singleQuote": true,
"quoteProps": "consistent"
}
}
{
"type": "module",
"node": "^12.20.0 || ^14.13.1 || >=16.0.0",
"name": "reticule",
"version": "1.2.0",
"description": "Save articles to read later in your feed aggregator of choice.",
"scripts": {
"build": "rimraf -fr ./dist && tsc",
"dev": "nodemon --verbose",
"format": "prettier -w ./src/",
"prebuild": "./get_version.sh > src/version.ts",
"start": "node ./dist/server.js"
},
"keywords": [
"rss",
"pocket",
"bookmarklet"
],
"author": "Reese Sapphire <reese@ovine.xyz>",
"license": "Unlicense",
"devDependencies": {
"@types/express": "^4.17.13",
"@types/node": "^18.16.1",
"@types/sanitize-html": "^2.6.2",
"nodemon": "^2.0.16",
"prettier": "^2.6.2",
"rimraf": "^3.0.2",
"ts-node": "^10.7.0",
"typescript": "^4.6.4"
},
"dependencies": {
"@extractus/article-extractor": "^7.2.15",
"dotenv": "^16.0.1",
"env-var": "^7.3.1",
"express": "^4.18.1",
"express-async-errors": "^3.1.1",
"lowdb": "^6.0.1"
},
"prettier": {
"printWidth": 100,
"semi": false,
"singleQuote": true,
"quoteProps": "consistent"
}
}

192
readme.md
View file

@ -1,96 +1,96 @@
# reticule
basic express server for saving online articles to read later.
## Installation
### Docker
```sh
docker run -d \
-p 3000:80 \
-e API_KEY=e44dd04a559c71f0 \
-v ./reticule:/data \
git.cyberia.club/reese/reticule:latest
```
### Docker Compose
```yaml
version: '3'
services:
reticule:
image: git.cyberia.club/reese/reticule:latest
restart: unless-stopped
ports:
- 3000:80
volumes:
- ./reticule:/data
environment:
- API_KEY=5184424c7804a089 # generate a strong secret with `openssl rand -hex 32`
# these variables are optional, and the defaults are provided for reference.
- PORT=80
- DB_FILE=/data/db.json
- FEED_TITLE=Reading list
- FEED_DESCRIPTION=Articles saved to be read later
- PUBLIC=false
```
### Environment variables
| variable | description |
|:-----------------|:------------------------------------------------------------------|
| API_KEY | The password needed to be able to use the API (required). |
| PORT | The port within the container that the server runs on. |
| DB_FILE | The path and filename of the database file within the container. |
| FEED_TITLE | The name of your reading list feed that shows up in feed readers. |
| FEED_DESCRIPTION | A short description to accompany the above. |
| PUBLIC | Allow reading your article feed without an API key |
## Usage
### Web browser
1. Copy the bookmarklet code from the logs and replace `<SERVER_ADDRESS>` with the IP address or domain name.
2. Create a new bookmark in your browser (in the bookmarks bar for easy access, perhaps) and set the URL to the bookmarklet code.
3. Go to an article that you want to save, click the bookmark, et voila!
### Android
1. Install HTTP Shortcuts from [F-Droid](https://f-droid.org/en/packages/ch.rmy.android.http_shortcuts/) or the [Play Store](https://play.google.com/store/apps/details?id=ch.rmy.android.http_shortcuts).
2. Download [`reticule_http_shortcut.json`](extra/reticule_http_shortcut.json) from this repo to your device.
3. Open HTTP Shortcuts, tap the vertical 3 dots in the top right corner, tap "Import/Export", then tap "Import from file". Select the json file you downloaded earlier.
4. Go back to the main page, tap the 3 dot button again, then tap "Variables", and edit the values of `reticule_instance` and `reticule_api_key` to match those of your instance.
5. Try using the share button in an app. A new item "Send to..." with the HTTP Shortcuts icon should appear on the share sheet. You can also save an article by tapping the shortcut inside the HTTP Shortcuts app itself and pasting the URL.
### RSS feed reader
The RSS feed URL is `<SERVER_ADDRESS>/feed?key=<API_KEY>`. Replace the placeholders with your proper values and add it to your feed reader of choice. If it doesn't work in your reader, open an issue and I will try my best to resolve it!
## Fine print
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to http://unlicense.org/
# reticule
basic express server for saving online articles to read later.
## Installation
### Docker
```sh
docker run -d \
-p 3000:80 \
-e API_KEY=e44dd04a559c71f0 \
-v ./reticule:/data \
git.cyberia.club/reese/reticule:latest
```
### Docker Compose
```yaml
version: '3'
services:
reticule:
image: git.cyberia.club/reese/reticule:latest
restart: unless-stopped
ports:
- 3000:80
volumes:
- ./reticule:/data
environment:
- API_KEY=5184424c7804a089 # generate a strong secret with `openssl rand -hex 32`
# these variables are optional, and the defaults are provided for reference.
- PORT=80
- DB_FILE=/data/db.json
- FEED_TITLE=Reading list
- FEED_DESCRIPTION=Articles saved to be read later
- PUBLIC=false
```
### Environment variables
| variable | description |
|:-----------------|:------------------------------------------------------------------|
| API_KEY | The password needed to be able to use the API (required). |
| PORT | The port within the container that the server runs on. |
| DB_FILE | The path and filename of the database file within the container. |
| FEED_TITLE | The name of your reading list feed that shows up in feed readers. |
| FEED_DESCRIPTION | A short description to accompany the above. |
| PUBLIC | Allow reading your article feed without an API key |
## Usage
### Web browser
1. Copy the bookmarklet code from the logs and replace `<SERVER_ADDRESS>` with the IP address or domain name.
2. Create a new bookmark in your browser (in the bookmarks bar for easy access, perhaps) and set the URL to the bookmarklet code.
3. Go to an article that you want to save, click the bookmark, et voila!
### Android
1. Install HTTP Shortcuts from [F-Droid](https://f-droid.org/en/packages/ch.rmy.android.http_shortcuts/) or the [Play Store](https://play.google.com/store/apps/details?id=ch.rmy.android.http_shortcuts).
2. Download [`reticule_http_shortcut.json`](extra/reticule_http_shortcut.json) from this repo to your device.
3. Open HTTP Shortcuts, tap the vertical 3 dots in the top right corner, tap "Import/Export", then tap "Import from file". Select the json file you downloaded earlier.
4. Go back to the main page, tap the 3 dot button again, then tap "Variables", and edit the values of `reticule_instance` and `reticule_api_key` to match those of your instance.
5. Try using the share button in an app. A new item "Send to..." with the HTTP Shortcuts icon should appear on the share sheet. You can also save an article by tapping the shortcut inside the HTTP Shortcuts app itself and pasting the URL.
### RSS feed reader
The RSS feed URL is `<SERVER_ADDRESS>/feed?key=<API_KEY>`. Replace the placeholders with your proper values and add it to your feed reader of choice. If it doesn't work in your reader, open an issue and I will try my best to resolve it!
## Fine print
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to http://unlicense.org/

View file

@ -1,30 +1,30 @@
## to do
- [ ] add atom XML endpoint
- [ ] clean up the articles before saving them (remove unnecessary elements, params, etc.)
- [ ] improve the bookmarklet (stay on the page, show a notification/modal with the server response)
- [ ] store API key hashed in the DB (which makes it only possible to use API_KEY=abc123 on first run)
- [ ] generate an API key and log it on first start (if one isn't provided or already in the DB)
- [ ] switch to using JWT?
- [ ] add a simple landing page on `/` (viewable only with authentication)
- [ ] setup instructions
- [ ] bookmarklet drag-and-drop
- [ ] API key reroll
- [ ] get a logo (https://en.wikipedia.org/wiki/Reticule_(handbag)#/media/File:Reticule.tif ?)
## v1.1
- [x] tag container image
- [x] actually validate the config
- [x] include the version number (+ any changes) in the API
## v1.0
- [x] change name to [**reticule**](https://en.wikipedia.org/wiki/Reticule_(handbag))
- [x] change license
- [x] move away from github
- [x] create woodpecker build pipeline
- [x] host container image on forgejo
- [x] host container image on docker hub
- [x] point old docker hub page to `reeseovine/reticule`
## alpha
- [x] use lowdb
- [x] deduplicate entries by URL
## to do
- [ ] add atom XML endpoint
- [ ] clean up the articles before saving them (remove unnecessary elements, params, etc.)
- [ ] improve the bookmarklet (stay on the page, show a notification/modal with the server response)
- [ ] store API key hashed in the DB (which makes it only possible to use API_KEY=abc123 on first run)
- [ ] generate an API key and log it on first start (if one isn't provided or already in the DB)
- [ ] switch to using JWT?
- [ ] add a simple landing page on `/` (viewable only with authentication)
- [ ] setup instructions
- [ ] bookmarklet drag-and-drop
- [ ] API key reroll
- [ ] get a logo (https://en.wikipedia.org/wiki/Reticule_(handbag)#/media/File:Reticule.tif ?)
## v1.1
- [x] tag container image
- [x] actually validate the config
- [x] include the version number (+ any changes) in the API
## v1.0
- [x] change name to [**reticule**](https://en.wikipedia.org/wiki/Reticule_(handbag))
- [x] change license
- [x] move away from github
- [x] create woodpecker build pipeline
- [x] host container image on forgejo
- [x] host container image on docker hub
- [x] point old docker hub page to `reeseovine/reticule`
## alpha
- [x] use lowdb
- [x] deduplicate entries by URL

View file

@ -1,7 +1,7 @@
PORT=3000
API_KEY=change me!!
PUBLIC=false
DB_FILE=./data/db.json
FEED_TITLE="My Reading List"
FEED_DESCRIPTION="Articles I've saved to read later"
PORT=3000
API_KEY=change me!!
PUBLIC=false
DB_FILE=./data/db.json
FEED_TITLE="My Reading List"
FEED_DESCRIPTION="Articles I've saved to read later"

View file

@ -1,16 +1,16 @@
import express, { NextFunction, Request, Response, Express } from 'express'
import 'express-async-errors'
import routes from './routes.js'
export const app = (app = express()): Express => {
app.use(routes)
app.use((_, response) => response.sendStatus(404))
app.use((error: Error, _: Request, __: Response, next: NextFunction) => {
console.error(error)
next(error)
})
return app
}
import express, { NextFunction, Request, Response, Express } from 'express'
import 'express-async-errors'
import routes from './routes.js'
export const app = (app = express()): Express => {
app.use(routes)
app.use((_, response) => response.sendStatus(404))
app.use((error: Error, _: Request, __: Response, next: NextFunction) => {
console.error(error)
next(error)
})
return app
}

View file

@ -1,17 +1,17 @@
import dotenv from 'dotenv'
dotenv.config()
import env from 'env-var'
interface Conf {
[key: string]: any
}
const envConf: Conf = {
api_key: env.get('API_KEY').required().asString(),
port: env.get('PORT').default('80').asPortNumber(),
public: env.get('PUBLIC').default('false').asBool(),
db_file: env.get('DB_FILE').default('/data/db.json').asString(),
feed_title: env.get('FEED_TITLE').default('My Reading List').asString(),
feed_desc: env.get('FEED_DESCRIPTION').default('Articles to read later').asString(),
}
export default envConf
import dotenv from 'dotenv'
dotenv.config()
import env from 'env-var'
interface Conf {
[key: string]: any
}
const envConf: Conf = {
api_key: env.get('API_KEY').required().asString(),
port: env.get('PORT').default('80').asPortNumber(),
public: env.get('PUBLIC').default('false').asBool(),
db_file: env.get('DB_FILE').default('/data/db.json').asString(),
feed_title: env.get('FEED_TITLE').default('My Reading List').asString(),
feed_desc: env.get('FEED_DESCRIPTION').default('Articles to read later').asString(),
}
export default envConf

View file

@ -1,17 +1,17 @@
import fs from 'fs'
import { Low } from 'lowdb'
import { JSONFile } from 'lowdb/node'
import config from './config.js'
import { Article, LowData } from './types.js'
let defaultData: LowData = { articles: [] }
let adapter = new JSONFile<LowData>(config.db_file as string)
let db = new Low<LowData>(adapter, defaultData)
await db.read()
if (db.data === null) {
db.data = defaultData
await db.write()
}
export default db
import fs from 'fs'
import { Low } from 'lowdb'
import { JSONFile } from 'lowdb/node'
import config from './config.js'
import { Article, LowData } from './types.js'
let defaultData: LowData = { articles: [] }
let adapter = new JSONFile<LowData>(config.db_file as string)
let db = new Low<LowData>(adapter, defaultData)
await db.read()
if (db.data === null) {
db.data = defaultData
await db.write()
}
export default db

View file

@ -1,8 +1,8 @@
import { Request, Response, NextFunction } from 'express'
export default function cacheForever(whatDoesForeverMean = 2592000) {
return (_: Request, response: Response, next: NextFunction): void => {
response.set('Cache-Control', `public, max-age=${whatDoesForeverMean}, immutable`) // 30 days
next()
}
}
import { Request, Response, NextFunction } from 'express'
export default function cacheForever(whatDoesForeverMean = 2592000) {
return (_: Request, response: Response, next: NextFunction): void => {
response.set('Cache-Control', `public, max-age=${whatDoesForeverMean}, immutable`) // 30 days
next()
}
}

View file

@ -1,12 +1,12 @@
import { Request, Response, NextFunction } from 'express'
export default function noCache() {
return (_: Request, response: Response, next: NextFunction): void => {
response.setHeader('Surrogate-Control', 'no-store')
response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
response.setHeader('Pragma', 'no-cache')
response.setHeader('Expires', '0')
next()
}
}
import { Request, Response, NextFunction } from 'express'
export default function noCache() {
return (_: Request, response: Response, next: NextFunction): void => {
response.setHeader('Surrogate-Control', 'no-store')
response.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate')
response.setHeader('Pragma', 'no-cache')
response.setHeader('Expires', '0')
next()
}
}

View file

@ -1,109 +1,109 @@
import { Express, Router, Request, Response } from 'express'
import { extract, ArticleData } from '@extractus/article-extractor'
import config from './config.js'
import db from './database.js'
import noCache from './middleware/no-cache.js'
import cacheForever from './middleware/cache-forever.js'
import { Article } from './types.js'
import pkgver from './version.js'
const router = Router()
router.get('/favicon.ico', cacheForever(), (_: Request, res: Response) => res.sendStatus(204))
router.get('/robots.txt', cacheForever(), (_: Request, res: Response) => {
res.type('text/plain')
res.send('User-agent: *\nDisallow: /')
})
router.get('/healthcheck', noCache(), (_: Request, res: Response) => {
res.json({ timestamp: Date.now(), version: pkgver })
})
router.get('/add', noCache(), (req: Request, res: Response) => {
if (!req.query.key || req.query.key != config.api_key) {
return res.sendStatus(401)
}
if (!req.query.url) {
console.error('No URL given. Skipping...')
return res.sendStatus(400)
}
let url = decodeURIComponent(req.query.url as string)
for (let entry of db.data.articles) {
if (entry.url == url) {
return res.status(200).send(`Skipping duplicate.`)
}
}
console.info(`Adding ${req.query.url}`)
extract(url)
.then(async (article) => {
article = article as Article
db.data.articles.push(
Object.assign(article, {
added: new Date(),
id: Date.now(),
})
)
db.data.articles.sort((a: Article, b: Article) => {
return new Date(b.added).getTime() - new Date(a.added).getTime()
})
await db.write()
return res.status(201).send(`Successfully saved "${article.title}"!`)
})
.catch((err) => {
console.trace(err)
return res.sendStatus(500)
})
})
router.get('/json', (req: Request, res: Response) => {
if (!config.public && (!req.query.key || req.query.key != config.api_key)) {
console.warn(`Unauthorized /json read attempt from ${req.ip} !`)
return res.sendStatus(401)
}
res.json(db.data.articles)
})
router.get('/feed', (req: Request, res: Response) => {
if (!config.public && (!req.query.key || req.query.key != config.api_key)) {
console.warn(`Unauthorized /feed read attempt from ${req.ip} !`)
return res.sendStatus(401)
}
let rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${config.feed_title}</title>
<description>${config.feed_desc}</description>
<generator>Reticule ${pkgver}</generator>
<language>en</language>
<pubDate>${
db.data.articles.length > 0 ? new Date(db.data.articles[1].added).toUTCString() : ''
}</pubDate>
`
for (var entry of db.data.articles) {
rss += `
<item>
<title>${entry.title}</title>
<link>${entry.url}</link>
<guid>${entry.id}</guid>
<pubDate>${entry.added}</pubDate>
<description><![CDATA[${entry.description}]]></description>
<content:encoded><![CDATA[${entry.content}]]></content:encoded>
</item>
`
}
rss += `
</channel>
</rss>
`
res.send(rss)
})
export default router
import { Express, Router, Request, Response } from 'express'
import { extract, ArticleData } from '@extractus/article-extractor'
import config from './config.js'
import db from './database.js'
import noCache from './middleware/no-cache.js'
import cacheForever from './middleware/cache-forever.js'
import { Article } from './types.js'
import pkgver from './version.js'
const router = Router()
router.get('/favicon.ico', cacheForever(), (_: Request, res: Response) => res.sendStatus(204))
router.get('/robots.txt', cacheForever(), (_: Request, res: Response) => {
res.type('text/plain')
res.send('User-agent: *\nDisallow: /')
})
router.get('/healthcheck', noCache(), (_: Request, res: Response) => {
res.json({ timestamp: Date.now(), version: pkgver })
})
router.get('/add', noCache(), (req: Request, res: Response) => {
if (!req.query.key || req.query.key != config.api_key) {
return res.sendStatus(401)
}
if (!req.query.url) {
console.error('No URL given. Skipping...')
return res.sendStatus(400)
}
let url = decodeURIComponent(req.query.url as string)
for (let entry of db.data.articles) {
if (entry.url == url) {
return res.status(200).send(`Skipping duplicate.`)
}
}
console.info(`Adding ${req.query.url}`)
extract(url)
.then(async (article) => {
article = article as Article
db.data.articles.push(
Object.assign(article, {
added: new Date(),
id: Date.now(),
})
)
db.data.articles.sort((a: Article, b: Article) => {
return new Date(b.added).getTime() - new Date(a.added).getTime()
})
await db.write()
return res.status(201).send(`Successfully saved "${article.title}"!`)
})
.catch((err) => {
console.trace(err)
return res.sendStatus(500)
})
})
router.get('/json', (req: Request, res: Response) => {
if (!config.public && (!req.query.key || req.query.key != config.api_key)) {
console.warn(`Unauthorized /json read attempt from ${req.ip} !`)
return res.sendStatus(401)
}
res.json(db.data.articles)
})
router.get('/feed', (req: Request, res: Response) => {
if (!config.public && (!req.query.key || req.query.key != config.api_key)) {
console.warn(`Unauthorized /feed read attempt from ${req.ip} !`)
return res.sendStatus(401)
}
let rss = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>${config.feed_title}</title>
<description>${config.feed_desc}</description>
<generator>Reticule ${pkgver}</generator>
<language>en</language>
<pubDate>${
db.data.articles.length > 0 ? new Date(db.data.articles[1].added).toUTCString() : ''
}</pubDate>
`
for (var entry of db.data.articles) {
rss += `
<item>
<title>${entry.title}</title>
<link>${entry.url}</link>
<guid>${entry.id}</guid>
<pubDate>${entry.added}</pubDate>
<description><![CDATA[${entry.description}]]></description>
<content:encoded><![CDATA[${entry.content}]]></content:encoded>
</item>
`
}
rss += `
</channel>
</rss>
`
res.send(rss)
})
export default router

View file

@ -1,16 +1,16 @@
import { app } from './app.js'
import config from './config.js'
app().listen(config.port, () => {
console.info(`Listening on port ${config.port}`)
console.info(
`Here is the bookmarklet code. Make sure you replace <SERVER_ADDRESS> with this server's ip or hostname (including the protocol and port) that your browser can access!`
)
let bookmarklet = `(()=>{window.open("<SERVER_ADDRESS>/add?key=${config.api_key}&url="+encodeURIComponent(location),"_blank","noreferrer,noopener")})()`
console.info(`\n javascript:${bookmarklet}\n`)
console.info(
`Then add <SERVER_ADDRESS>/feed?key=${config.api_key} to your RSS feed reader of choice.\n`
)
})
import { app } from './app.js'
import config from './config.js'
app().listen(config.port, () => {
console.info(`Listening on port ${config.port}`)
console.info(
`Here is the bookmarklet code. Make sure you replace <SERVER_ADDRESS> with this server's ip or hostname (including the protocol and port) that your browser can access!`
)
let bookmarklet = `(()=>{window.open("<SERVER_ADDRESS>/add?key=${config.api_key}&url="+encodeURIComponent(location),"_blank","noreferrer,noopener")})()`
console.info(`\n javascript:${bookmarklet}\n`)
console.info(
`Then add <SERVER_ADDRESS>/feed?key=${config.api_key} to your RSS feed reader of choice.\n`
)
})

View file

@ -1,10 +1,10 @@
import { ArticleData } from '@extractus/article-extractor'
export interface Article extends ArticleData {
added: Date
id: number
}
export type LowData = {
articles: Article[]
}
import { ArticleData } from '@extractus/article-extractor'
export interface Article extends ArticleData {
added: Date
id: number
}
export type LowData = {
articles: Article[]
}

View file

@ -1,16 +1,16 @@
{
"compilerOptions": {
"module": "ES2022",
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"sourceMap": false,
"outDir": "dist",
"baseUrl": ".",
"pretty": true,
"removeComments": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}
{
"compilerOptions": {
"module": "ES2022",
"target": "ES2022",
"strict": true,
"esModuleInterop": true,
"moduleResolution": "node",
"sourceMap": false,
"outDir": "dist",
"baseUrl": ".",
"pretty": true,
"removeComments": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}