forked from reese/reticule
prettify, convert line endings to unix format
This commit is contained in:
parent
cf8f670f11
commit
0c4d884754
19 changed files with 3493 additions and 3493 deletions
|
@ -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
|
||||
|
|
60
Dockerfile
60
Dockerfile
|
@ -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
|
||||
|
|
48
LICENSE.txt
48
LICENSE.txt
|
@ -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/>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
14
nodemon.json
14
nodemon.json
|
@ -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
5946
package-lock.json
generated
File diff suppressed because it is too large
Load diff
90
package.json
90
package.json
|
@ -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
192
readme.md
|
@ -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/
|
||||
|
|
60
roadmap.md
60
roadmap.md
|
@ -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
|
||||
|
|
14
sample.env
14
sample.env
|
@ -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"
|
||||
|
|
32
src/app.ts
32
src/app.ts
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
218
src/routes.ts
218
src/routes.ts
|
@ -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
|
||||
|
|
|
@ -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`
|
||||
)
|
||||
})
|
||||
|
|
20
src/types.ts
20
src/types.ts
|
@ -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[]
|
||||
}
|
||||
|
|
|
@ -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/**/*"]
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue