├── .dockerignore ├── .env.sample ├── .gitignore ├── .nvmrc ├── .prettierrc ├── .vscode └── settings.json ├── Dockerfile ├── LICENSE.md ├── README.md ├── docker ├── .env.sample.docker_compose ├── basic-integration-tests.sh ├── docker-compose.yml └── wait-for-it.sh ├── fly.toml ├── package-lock.json ├── package.json ├── pm2.sh ├── sql ├── rsstodolist.mysql └── rsstodolist.postgres ├── src ├── FeedModel.ts ├── dump.ts ├── env.ts ├── index.ts ├── static │ ├── favicon.png │ ├── manifest.webmanifest │ ├── rss-128.png │ ├── rss-256.png │ ├── rss-512.png │ ├── rss.css │ ├── rss.svg │ ├── rss.xslt │ └── style.css ├── strings.test.js ├── strings.ts ├── utils.test.js ├── utils.ts └── views │ ├── index.ejs │ ├── list.ejs │ └── rss.ejs ├── tsconfig.json └── vitest.config.js /.dockerignore: -------------------------------------------------------------------------------- 1 | # flyctl launch added from .gitignore 2 | **/node_modules 3 | 4 | **/.env 5 | **/.env.* 6 | !**/.env.sample.* 7 | 8 | **/.docker/.env 9 | **/.docker/.env.* 10 | !**/.docker/.env.sample.* 11 | -------------------------------------------------------------------------------- /.env.sample: -------------------------------------------------------------------------------- 1 | TZ="Etc/GMT+1" 2 | 3 | PORT=6070 4 | 5 | DATABASE_HOST=127.0.0.1 6 | DATABASE_USER=rsstodolistUser 7 | DATABASE_PASS=SomePassword 8 | DATABASE_NAME=rsstodolist 9 | 10 | # MariaDB container 11 | # DATABASE_DIALECT=mariadb 12 | # DATABASE_PORT=3306 13 | # MYSQL_DATABASE=rsstodolist 14 | # MYSQL_USER=rsstodolistUser 15 | # MYSQL_PASSWORD=SomePassword 16 | # MYSQL_RANDOM_ROOT_PASSWORD=yes 17 | 18 | # Postgres container 19 | DATABASE_DIALECT=postgres 20 | DATABASE_PORT=5432 21 | POSTGRES_USER=rsstodolistUser 22 | POSTGRES_PASSWORD=SomePassword 23 | POSTGRES_DB=rsstodolist 24 | 25 | # PUBLIC=true 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | parameters.json 3 | dist 4 | 5 | .env 6 | .env.* 7 | !.env.sample.* 8 | 9 | .docker/.env 10 | .docker/.env.* 11 | !.docker/.env.sample.* 12 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": false, 5 | "singleQuote": true, 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.wordWrapColumn": 120, 4 | "prettier.printWidth": 120 5 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:22-alpine 2 | 3 | # Add pkg to use wait-for-it script. 4 | RUN apk add --no-cache bash coreutils 5 | 6 | # The application will be mounted into this directory. 7 | WORKDIR /usr/app 8 | 9 | # Install dependencies. 10 | COPY ./package.json ./package-lock.json tsconfig.json /usr/app/ 11 | RUN npm install 12 | 13 | # Build 14 | COPY ./src /usr/app/src 15 | RUN npm run build 16 | 17 | # Clean 18 | RUN npm ci && npm cache clean --force 19 | 20 | # Bundle app source. 21 | COPY ./docker/wait-for-it.sh /usr/app/ 22 | 23 | EXPOSE 6070 24 | 25 | CMD [ "sh", "-c", "./wait-for-it.sh ${DATABASE_HOST:-127.0.0.1}:${DATABASE_PORT:-3306} -t 90 -- npm start" ] 26 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Grégory Paul 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 | # rsstodolist-node-server 2 | 3 | rsstodolist is an URL oriented to-read-list based on an RSS XML feed. Typical use case is to save web pages to read later on a RSS reader, or to send links to friends. 4 | 5 | That application is hosted on fly.io at https://rsstodolist.eu/. 6 | For more reliability and privacy, I _strongly_ suggest you to self-host that application. 7 | 8 | Thanks to [Loïc Fürhoff](https://github.com/imagoiq), it can be hosted in a convenient way via docker. 9 | 10 | 11 | ## Requirements 12 | 13 | - Node >= 20 14 | - MariaDB or Postgres 15 | 16 | or 17 | 18 | - docker 19 | 20 | 21 | ## Pre-requisites 22 | 23 | Copy `.env.sample` into `.env` (or `.env.docker_compose` if you are using docker-compose setup) and set the variables according your need. 24 | 25 | The app will try to determine it’s root url. If it isn’t correct, you can specify it via `ROOT_URL` env variable. 26 | 27 | The `PUBLIC` env variable should only be used for public instance (it disable /list and add some messages about self-hosting). 28 | 29 | 30 | ## 1. Setup with docker-compose 31 | 32 | ```shell 33 | docker-compose -f ./docker/docker-compose.yml build 34 | docker-compose -f ./docker/docker-compose.yml up 35 | ``` 36 | 37 | ### Development 38 | 39 | To allow fast code update, you can simply start database using docker-compose like 40 | `docker-compose -f ./docker/docker-compose.yml up db` and run `npm run dev` with nodemon in another shell. 41 | 42 | ## 2. Setup with the DockerFile 43 | 44 | ### Run the migration file 45 | 46 | You need to apply `sql/rsstodolist.mysql` or `sql/rsstodolist.postgres` manually on your database server. 47 | 48 | ### Build the image 49 | 50 | As there is no currently public image, build the image for example like this: 51 | 52 | ```shell 53 | npm run docker-build 54 | # or 55 | docker build -t rsstodolist -f ./Dockerfile . 56 | ``` 57 | 58 | ### Run the container 59 | 60 | Run the container for example by linking a file containing environment variables. 61 | 62 | ```shell 63 | docker run --env-file ./.env rsstodolist 64 | ``` 65 | 66 | or define needed environment variables within the command: 67 | 68 | ```shell 69 | docker run -p 8080:6070 \ 70 | -e DATABASE_HOST=127.0.0.1 \ 71 | -e DATABASE_PORT=3306 \ 72 | rsstodolist 73 | ``` 74 | 75 | ## 3. Setup via node & MariaDB 76 | 77 | ### Run the migration file 78 | 79 | Run the migration file to create the rsstodolist database. 80 | 81 | ### Install packages and start the application 82 | 83 | ``` shell 84 | npm install 85 | npm start 86 | ``` 87 | 88 | ## Commands 89 | 90 | You can use `npm run dump` to extract all databases rows into a CSV file format. -------------------------------------------------------------------------------- /docker/.env.sample.docker_compose: -------------------------------------------------------------------------------- 1 | TZ="Etc/GMT+1" 2 | 3 | PORT=6070 4 | 5 | DATABASE_HOST=db 6 | DATABASE_USER=rsstodolistUser 7 | DATABASE_PASS=SomePassword 8 | DATABASE_NAME=rsstodolist 9 | 10 | CONTAINER_EXT_PORT=8080 11 | 12 | # MariaDB container 13 | # DATABASE_DIALECT=mariadb 14 | # DATABASE_PORT=3306 15 | # MYSQL_DATABASE=rsstodolist 16 | # MYSQL_USER=rsstodolistUser 17 | # MYSQL_PASSWORD=SomePassword 18 | # MYSQL_RANDOM_ROOT_PASSWORD=yes 19 | 20 | # Postgres container 21 | DATABASE_DIALECT=postgres 22 | DATABASE_PORT=5432 23 | POSTGRES_USER=rsstodolistUser 24 | POSTGRES_PASSWORD=SomePassword 25 | POSTGRES_DB=rsstodolist 26 | 27 | PUBLIC=true 28 | -------------------------------------------------------------------------------- /docker/basic-integration-tests.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | echo "\nHomepage:" 3 | curl -XGET "http://localhost:8080/" 4 | echo "\nRSS feed:" 5 | curl -XGET "http://localhost:8080/?n=somename" 6 | echo "\nAdd:" 7 | curl -IXGET "http://localhost:8080/add?n=somename&u=https://www.qwant.com/" 8 | echo "\nRSS feed:" 9 | curl -XGET "http://localhost:8080/?n=somename" 10 | echo "\nDel:" 11 | curl -IXGET "http://localhost:8080/del?n=somename&u=https://www.qwant.com/" 12 | echo "\nRSS feed:" 13 | curl -XGET "http://localhost:8080/?n=somename" -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | volumes: 4 | rsstodolist_db_data: 5 | 6 | services: 7 | rsstodolist: 8 | build: 9 | context: ../ 10 | dockerfile: ./Dockerfile 11 | env_file: ../.env.docker_compose 12 | ports: 13 | - '${CONTAINER_EXT_PORT:-8080}:${PORT:-6070}' 14 | depends_on: 15 | - db 16 | 17 | db: 18 | # image: 'mariadb:latest' 19 | # env_file: ../.env.docker_compose 20 | # volumes: 21 | # - 'rsstodolist_db_data:/var/lib/mysql' 22 | # - ../sql/rsstodolist.mysql:/docker-entrypoint-initdb.d/rsstodolist.sql 23 | image: 'postgres:latest' 24 | ports: 25 | - 5432:5432 26 | env_file: ../.env.docker_compose 27 | volumes: 28 | - 'rsstodolist_db_data/:/var/lib/postgresql/data/' 29 | - ../sql/rsstodolist.postgres:/docker-entrypoint-initdb.d/rsstodolist.sql 30 | -------------------------------------------------------------------------------- /docker/wait-for-it.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # Source: https://github.com/vishnubob/wait-for-it 3 | # Use this script to test if a given TCP host/port are available 4 | 5 | WAITFORIT_cmdname=${0##*/} 6 | 7 | echoerr() { if [[ $WAITFORIT_QUIET -ne 1 ]]; then echo "$@" 1>&2; fi } 8 | 9 | usage() 10 | { 11 | cat << USAGE >&2 12 | Usage: 13 | $WAITFORIT_cmdname host:port [-s] [-t timeout] [-- command args] 14 | -h HOST | --host=HOST Host or IP under test 15 | -p PORT | --port=PORT TCP port under test 16 | Alternatively, you specify the host and port as host:port 17 | -s | --strict Only execute subcommand if the test succeeds 18 | -q | --quiet Don't output any status messages 19 | -t TIMEOUT | --timeout=TIMEOUT 20 | Timeout in seconds, zero for no timeout 21 | -- COMMAND ARGS Execute command with args after the test finishes 22 | USAGE 23 | exit 1 24 | } 25 | 26 | wait_for() 27 | { 28 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 29 | echoerr "$WAITFORIT_cmdname: waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 30 | else 31 | echoerr "$WAITFORIT_cmdname: waiting for $WAITFORIT_HOST:$WAITFORIT_PORT without a timeout" 32 | fi 33 | WAITFORIT_start_ts=$(date +%s) 34 | while : 35 | do 36 | if [[ $WAITFORIT_ISBUSY -eq 1 ]]; then 37 | nc -z $WAITFORIT_HOST $WAITFORIT_PORT 38 | WAITFORIT_result=$? 39 | else 40 | (echo -n > /dev/tcp/$WAITFORIT_HOST/$WAITFORIT_PORT) >/dev/null 2>&1 41 | WAITFORIT_result=$? 42 | fi 43 | if [[ $WAITFORIT_result -eq 0 ]]; then 44 | WAITFORIT_end_ts=$(date +%s) 45 | echoerr "$WAITFORIT_cmdname: $WAITFORIT_HOST:$WAITFORIT_PORT is available after $((WAITFORIT_end_ts - WAITFORIT_start_ts)) seconds" 46 | break 47 | fi 48 | sleep 1 49 | done 50 | return $WAITFORIT_result 51 | } 52 | 53 | wait_for_wrapper() 54 | { 55 | # In order to support SIGINT during timeout: http://unix.stackexchange.com/a/57692 56 | if [[ $WAITFORIT_QUIET -eq 1 ]]; then 57 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --quiet --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 58 | else 59 | timeout $WAITFORIT_BUSYTIMEFLAG $WAITFORIT_TIMEOUT $0 --child --host=$WAITFORIT_HOST --port=$WAITFORIT_PORT --timeout=$WAITFORIT_TIMEOUT & 60 | fi 61 | WAITFORIT_PID=$! 62 | trap "kill -INT -$WAITFORIT_PID" INT 63 | wait $WAITFORIT_PID 64 | WAITFORIT_RESULT=$? 65 | if [[ $WAITFORIT_RESULT -ne 0 ]]; then 66 | echoerr "$WAITFORIT_cmdname: timeout occurred after waiting $WAITFORIT_TIMEOUT seconds for $WAITFORIT_HOST:$WAITFORIT_PORT" 67 | fi 68 | return $WAITFORIT_RESULT 69 | } 70 | 71 | # process arguments 72 | while [[ $# -gt 0 ]] 73 | do 74 | case "$1" in 75 | *:* ) 76 | WAITFORIT_hostport=(${1//:/ }) 77 | WAITFORIT_HOST=${WAITFORIT_hostport[0]} 78 | WAITFORIT_PORT=${WAITFORIT_hostport[1]} 79 | shift 1 80 | ;; 81 | --child) 82 | WAITFORIT_CHILD=1 83 | shift 1 84 | ;; 85 | -q | --quiet) 86 | WAITFORIT_QUIET=1 87 | shift 1 88 | ;; 89 | -s | --strict) 90 | WAITFORIT_STRICT=1 91 | shift 1 92 | ;; 93 | -h) 94 | WAITFORIT_HOST="$2" 95 | if [[ $WAITFORIT_HOST == "" ]]; then break; fi 96 | shift 2 97 | ;; 98 | --host=*) 99 | WAITFORIT_HOST="${1#*=}" 100 | shift 1 101 | ;; 102 | -p) 103 | WAITFORIT_PORT="$2" 104 | if [[ $WAITFORIT_PORT == "" ]]; then break; fi 105 | shift 2 106 | ;; 107 | --port=*) 108 | WAITFORIT_PORT="${1#*=}" 109 | shift 1 110 | ;; 111 | -t) 112 | WAITFORIT_TIMEOUT="$2" 113 | if [[ $WAITFORIT_TIMEOUT == "" ]]; then break; fi 114 | shift 2 115 | ;; 116 | --timeout=*) 117 | WAITFORIT_TIMEOUT="${1#*=}" 118 | shift 1 119 | ;; 120 | --) 121 | shift 122 | WAITFORIT_CLI=("$@") 123 | break 124 | ;; 125 | --help) 126 | usage 127 | ;; 128 | *) 129 | echoerr "Unknown argument: $1" 130 | usage 131 | ;; 132 | esac 133 | done 134 | 135 | if [[ "$WAITFORIT_HOST" == "" || "$WAITFORIT_PORT" == "" ]]; then 136 | echoerr "Error: you need to provide a host and port to test." 137 | usage 138 | fi 139 | 140 | WAITFORIT_TIMEOUT=${WAITFORIT_TIMEOUT:-15} 141 | WAITFORIT_STRICT=${WAITFORIT_STRICT:-0} 142 | WAITFORIT_CHILD=${WAITFORIT_CHILD:-0} 143 | WAITFORIT_QUIET=${WAITFORIT_QUIET:-0} 144 | 145 | # Check to see if timeout is from busybox? 146 | WAITFORIT_TIMEOUT_PATH=$(type -p timeout) 147 | WAITFORIT_TIMEOUT_PATH=$(realpath $WAITFORIT_TIMEOUT_PATH 2>/dev/null || readlink -f $WAITFORIT_TIMEOUT_PATH) 148 | 149 | WAITFORIT_BUSYTIMEFLAG="" 150 | if [[ $WAITFORIT_TIMEOUT_PATH =~ "busybox" ]]; then 151 | WAITFORIT_ISBUSY=1 152 | # Check if busybox timeout uses -t flag 153 | # (recent Alpine versions don't support -t anymore) 154 | if timeout &>/dev/stdout | grep -q -e '-t '; then 155 | WAITFORIT_BUSYTIMEFLAG="-t" 156 | fi 157 | else 158 | WAITFORIT_ISBUSY=0 159 | fi 160 | 161 | if [[ $WAITFORIT_CHILD -gt 0 ]]; then 162 | wait_for 163 | WAITFORIT_RESULT=$? 164 | exit $WAITFORIT_RESULT 165 | else 166 | if [[ $WAITFORIT_TIMEOUT -gt 0 ]]; then 167 | wait_for_wrapper 168 | WAITFORIT_RESULT=$? 169 | else 170 | wait_for 171 | WAITFORIT_RESULT=$? 172 | fi 173 | fi 174 | 175 | if [[ $WAITFORIT_CLI != "" ]]; then 176 | if [[ $WAITFORIT_RESULT -ne 0 && $WAITFORIT_STRICT -eq 1 ]]; then 177 | echoerr "$WAITFORIT_cmdname: strict mode, refusing to execute subprocess" 178 | exit $WAITFORIT_RESULT 179 | fi 180 | exec "${WAITFORIT_CLI[@]}" 181 | else 182 | exit $WAITFORIT_RESULT 183 | fi 184 | -------------------------------------------------------------------------------- /fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml app configuration file generated for rsstodolist on 2023-08-17T13:36:03+02:00 2 | # 3 | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. 4 | # 5 | 6 | app = "rsstodolist" 7 | primary_region = "cdg" 8 | kill_signal = "SIGINT" 9 | kill_timeout = "5s" 10 | 11 | [experimental] 12 | auto_rollback = true 13 | 14 | [build] 15 | 16 | [env] 17 | PUBLIC = "true" 18 | ROOT_URL = "https://rsstodolist.eu" 19 | NODE_OPTIONS='--max-old-space-size=128' 20 | 21 | [[services]] 22 | protocol = "tcp" 23 | internal_port = 6070 24 | processes = ["app"] 25 | 26 | [[services.ports]] 27 | port = 80 28 | handlers = ["http"] 29 | force_https = true 30 | 31 | [[services.ports]] 32 | port = 443 33 | handlers = ["tls", "http"] 34 | [services.concurrency] 35 | type = "connections" 36 | hard_limit = 25 37 | soft_limit = 20 38 | 39 | [[services.tcp_checks]] 40 | interval = "15s" 41 | timeout = "2s" 42 | grace_period = "1s" 43 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "rsstodolist-node-server", 3 | "version": "1.1.0", 4 | "description": "rsstodolist server in node", 5 | "main": "dist/index.js", 6 | "type": "module", 7 | "scripts": { 8 | "build": "tsc && cp -r src/static dist/", 9 | "dev": "tsx src/index.ts", 10 | "dump": "NODE_ENV=production node dist/dump.js", 11 | "start": "NODE_ENV=production node dist/index.js", 12 | "tdd": "vitest", 13 | "test": "vitest --run", 14 | "docker-build": "docker build -t rsstodolist -f ./Dockerfile ." 15 | }, 16 | "engines": { 17 | "node": ">=20.0.0" 18 | }, 19 | "repository": { 20 | "type": "git", 21 | "url": "git+https://github.com/paulgreg/rsstodolist-node-server.git" 22 | }, 23 | "keywords": [ 24 | "rsstodolist", 25 | "rss" 26 | ], 27 | "author": "Grégory PAUL", 28 | "license": "GPL-2.0", 29 | "bugs": { 30 | "url": "https://github.com/paulgreg/rsstodolist-node-server/issues" 31 | }, 32 | "homepage": "https://github.com/paulgreg/rsstodolist-node-server#readme", 33 | "dependencies": { 34 | "axios": "^1.8.4", 35 | "charset": "^1.0.1", 36 | "cheerio": "^1.0.0", 37 | "cors": "^2.8.5", 38 | "debug": "^4.4.0", 39 | "dotenv": "^16.5.0", 40 | "ejs": "^3.1.10", 41 | "express": "^5.1.0", 42 | "iconv-lite": "^0.6.3", 43 | "jschardet": "^3.1.4", 44 | "mariadb": "^3.4.1", 45 | "morgan": "^1.10.0", 46 | "sequelize": "^6.37.7" 47 | }, 48 | "devDependencies": { 49 | "@types/charset": "^1.0.5", 50 | "@types/cors": "^2.8.17", 51 | "@types/express": "^5.0.1", 52 | "@types/morgan": "^1.9.9", 53 | "@types/node": "^22.14.1", 54 | "@types/sequelize": "^4.28.20", 55 | "tsx": "^4.19.3", 56 | "typescript": "^5.8.3", 57 | "vitest": "^3.1.2" 58 | }, 59 | "optionalDependencies": { 60 | "mysql2": "^3.14.0", 61 | "pg": "^8.14.1" 62 | } 63 | } -------------------------------------------------------------------------------- /pm2.sh: -------------------------------------------------------------------------------- 1 | NODE_ENV=production pm2 start dist/index.js --name 'rsstodolist-server' --max-memory-restart 192M 2 | -------------------------------------------------------------------------------- /sql/rsstodolist.mysql: -------------------------------------------------------------------------------- 1 | -- Host: localhost Database: rsstodolist 2 | -- ------------------------------------------------------ 3 | -- Server version 10.3.22-MariaDB-0+deb10u1 4 | 5 | /*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; 6 | /*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; 7 | /*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; 8 | /*!40101 SET NAMES utf8mb4 */; 9 | /*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; 10 | /*!40103 SET TIME_ZONE='+00:00' */; 11 | /*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; 12 | /*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; 13 | /*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; 14 | /*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; 15 | 16 | CREATE DATABASE IF NOT EXISTS rsstodolist; 17 | use rsstodolist; 18 | 19 | DROP TABLE IF EXISTS `feeds_feedentry`; 20 | /*!40101 SET @saved_cs_client = @@character_set_client */; 21 | /*!40101 SET character_set_client = utf8 */; 22 | CREATE TABLE `feeds_feedentry` ( 23 | `id` int(11) NOT NULL AUTO_INCREMENT, 24 | `name` varchar(20) NOT NULL, 25 | `url` varchar(512) NOT NULL, 26 | `title` varchar(255) NOT NULL, 27 | `description` longtext DEFAULT NULL, 28 | `createdAt` datetime NOT NULL, 29 | `updatedAt` datetime NULL, 30 | PRIMARY KEY (`id`) 31 | ) ENGINE=InnoDB AUTO_INCREMENT=11333 DEFAULT CHARSET=utf8; 32 | /*!40101 SET character_set_client = @saved_cs_client */; -------------------------------------------------------------------------------- /sql/rsstodolist.postgres: -------------------------------------------------------------------------------- 1 | CREATE TABLE feeds_feedentry ( 2 | "id" SERIAL PRIMARY KEY, 3 | "name" VARCHAR(20) NOT NULL, 4 | "url" VARCHAR(512) NOT NULL, 5 | "title" VARCHAR(255) NOT NULL, 6 | "description" TEXT NULL, 7 | "createdAt" TIMESTAMP NOT NULL, 8 | "updatedAt" TIMESTAMP NULL 9 | ) -------------------------------------------------------------------------------- /src/FeedModel.ts: -------------------------------------------------------------------------------- 1 | import type { Optional } from 'sequelize' 2 | import { Sequelize, Model, DataTypes, Op } from 'sequelize' 3 | 4 | export const id = 'id' 5 | export const name = 'name' 6 | export const url = 'url' 7 | export const title = 'title' 8 | export const description = 'description' 9 | 10 | export const lengths = { 11 | [name]: 20, 12 | [url]: 512, 13 | [title]: 255, 14 | [description]: 1024, 15 | } 16 | 17 | interface FeedAttributes { 18 | [id]: number 19 | [name]: string 20 | [url]: string 21 | [title]: string 22 | [description]?: string 23 | createdAt?: Date 24 | updatedAt?: Date 25 | } 26 | 27 | interface FeedCreationAttributes extends Optional {} 28 | 29 | class FeedModel extends Model implements FeedAttributes { 30 | [id]!: number; 31 | [name]!: string; 32 | [url]!: string; 33 | [title]!: string; 34 | [description]?: string 35 | 36 | readonly createdAt!: Date 37 | readonly updatedAt!: Date 38 | } 39 | 40 | const FeedModelBuilder = (sequelize: Sequelize) => { 41 | const isPostgres = sequelize.getDialect() === 'postgres' 42 | 43 | const FeedModel = sequelize.define( 44 | 'feeds_feedentry', 45 | { 46 | [id]: { 47 | type: DataTypes.INTEGER, 48 | autoIncrement: true, 49 | primaryKey: true, 50 | }, 51 | [name]: { 52 | type: DataTypes.STRING(lengths[name]), 53 | allowNull: false, 54 | }, 55 | [url]: { 56 | type: DataTypes.STRING(lengths[url]), 57 | allowNull: false, 58 | }, 59 | [title]: { 60 | type: DataTypes.STRING(lengths[title]), 61 | allowNull: false, 62 | }, 63 | [description]: { 64 | type: DataTypes.TEXT, 65 | allowNull: true, 66 | }, 67 | }, 68 | { 69 | freezeTableName: true, 70 | } 71 | ) 72 | 73 | const findByName = async ({ name, limit }: { name: string; limit?: number }) => 74 | FeedModel.findAll({ 75 | limit: Math.min(limit ?? 25, 500), 76 | where: { 77 | name, 78 | }, 79 | order: [ 80 | ['updatedAt', isPostgres ? 'DESC NULLS LAST' : 'DESC'], 81 | ['createdAt', 'DESC'], 82 | ], 83 | }) 84 | 85 | const insert = async ({ name, url, title, description }: FeedCreationAttributes) => 86 | FeedModel.findOne({ 87 | where: { name, url }, 88 | }).then((result) => 89 | FeedModel.upsert({ 90 | id: result?.id || undefined, 91 | name, 92 | url, 93 | title, 94 | description, 95 | }) 96 | ) 97 | 98 | const remove = async ({ name, url }: { name: string; url: string }) => 99 | FeedModel.findOne({ 100 | where: { name, url }, 101 | }).then((m) => m?.destroy()) 102 | 103 | const list = async () => 104 | FeedModel.findAll({ 105 | group: ['name'], 106 | attributes: ['name', [sequelize.fn('COUNT', sequelize.col('name')), 'count']], 107 | order: [['name', 'ASC']], 108 | }) 109 | 110 | const count = async ({ name }: { name: string }) => 111 | FeedModel.findAll({ 112 | where: { name }, 113 | attributes: [[sequelize.fn('COUNT', sequelize.col('name')), 'count']], 114 | }) 115 | 116 | const search = async ({ query, limit }: { query: string; limit?: number }) => 117 | FeedModel.findAll({ 118 | limit: Math.min(limit || 100, 500), 119 | where: { 120 | [Op.or]: [ 121 | { 122 | url: { 123 | [Op.like]: `%${query}%`, 124 | }, 125 | }, 126 | { 127 | title: { 128 | [Op.like]: `%${query}%`, 129 | }, 130 | }, 131 | { 132 | description: { 133 | [Op.like]: `%${query}%`, 134 | }, 135 | }, 136 | ], 137 | }, 138 | order: [ 139 | ['updatedAt', 'DESC'], 140 | ['createdAt', 'DESC'], 141 | ], 142 | }) 143 | 144 | const suggest = async ({ query }: { query: string }) => 145 | FeedModel.findAll({ 146 | limit: 10, 147 | group: ['name'], 148 | attributes: ['name', [sequelize.fn('COUNT', sequelize.col('name')), 'count']], 149 | where: { 150 | name: { 151 | [Op.like]: `%${query}%`, 152 | }, 153 | }, 154 | order: [['name', 'ASC']], 155 | }) 156 | 157 | const dump = async () => 158 | FeedModel.findAll({ 159 | attributes: ['name', 'url', 'title', 'description', 'createdAt', 'updatedAt'], 160 | order: [ 161 | ['name', 'ASC'], 162 | ['updatedAt', 'DESC'], 163 | ['createdAt', 'DESC'], 164 | ], 165 | }) 166 | 167 | return { FeedModel, findByName, insert, remove, list, count, search, suggest, dump } 168 | } 169 | 170 | export default FeedModelBuilder 171 | -------------------------------------------------------------------------------- /src/dump.ts: -------------------------------------------------------------------------------- 1 | import { Sequelize } from 'sequelize' 2 | import * as env from './env.js' 3 | import FeedModelBuilder from './FeedModel.js' 4 | 5 | const sequelize = new Sequelize(env.DATABASE_URL, { 6 | timezone: env.TZ, 7 | dialectOptions: { 8 | timezone: env.TZ, // Duplicate because of a bug: https://github.com/sequelize/sequelize/issues/10921 9 | }, 10 | logging: false, 11 | }) 12 | 13 | const cleanStr = (str?: string) => { 14 | if (!str) return 'null' 15 | return str.replace(/;/g, '-').replace(/[\r\n]/g, '') 16 | } 17 | 18 | sequelize 19 | .authenticate() 20 | .then(() => FeedModelBuilder(sequelize).dump()) 21 | .then((results) => { 22 | console.log(`"name";"url";"title";"description";"createdAt";"updatedAt"`) 23 | results 24 | .map(({ dataValues }) => dataValues) 25 | .forEach(({ name, url, title, description, createdAt, updatedAt }) => { 26 | console.log( 27 | `${cleanStr(name)};${cleanStr(url)};${cleanStr(title)};${cleanStr( 28 | description 29 | )};${createdAt};${updatedAt}` 30 | ) 31 | }) 32 | process.exit(0) 33 | }) 34 | .catch((err) => { 35 | console.error(err) 36 | throw err 37 | }) 38 | -------------------------------------------------------------------------------- /src/env.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv' 2 | dotenv.config() 3 | 4 | export const TZ = process.env.TZ ?? 'Etc/GMT0' 5 | export const DATABASE_PORT = process.env.DATABASE_PORT ?? 5432 // ?? 3306 6 | export const DATABASE_DIALECT = process.env.DATABASE_DIALECT ?? 'postgres' //'mariadb' 7 | export const DATABASE_HOST = process.env.DATABASE_HOST ?? '127.0.0.1' 8 | export const DATABASE_NAME = process.env.DATABASE_NAME ?? 'rsstodolist' 9 | export const DATABASE_USER = process.env.DATABASE_USER 10 | export const DATABASE_PASS = process.env.DATABASE_PASS 11 | export const PORT = process.env.PORT ?? 6070 12 | export const CONTAINER_EXT_PORT = process.env.CONTAINER_EXT_PORT 13 | export const PUBLIC = process.env.PUBLIC === 'true' 14 | export const ROOT_URL = process.env.ROOT_URL 15 | 16 | export const DATABASE_URL = 17 | process.env.DATABASE_URL ?? 18 | `${DATABASE_DIALECT}://${DATABASE_USER}:${DATABASE_PASS}@${DATABASE_HOST}:${DATABASE_PORT}/${DATABASE_NAME}` 19 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import { Sequelize } from 'sequelize' 3 | import FeedModelBuilder, { lengths } from './FeedModel.js' 4 | import axios from 'axios' 5 | import * as cheerio from 'cheerio' 6 | import morgan from 'morgan' 7 | import { trim, truncate, slugify, cleanify, sanitize, isValidUrl } from './strings.js' 8 | import { dirname } from 'path' 9 | import { fileURLToPath } from 'url' 10 | import cors from 'cors' 11 | import jschardet from 'jschardet' 12 | import charset from 'charset' 13 | import iconv from 'iconv-lite' 14 | import * as env from './env.js' 15 | import type { IncomingHttpHeaders } from 'http' 16 | import { getIntParam, getStrParam } from './utils.js' 17 | 18 | const PORT = env.CONTAINER_EXT_PORT ?? env.PORT 19 | 20 | const MINUTE = 60 21 | const HOUR = MINUTE * 60 22 | const DAY = HOUR * 24 23 | 24 | const cleanNameStr = (n?: string) => slugify(truncate(cleanify(sanitize(trim(n))), lengths.name)) 25 | const cleanUrlStr = (u?: string) => truncate(trim(u), lengths.url) 26 | const cleanTitleStr = (t?: string) => truncate(cleanify(trim(t)), lengths.title) 27 | const cleanDescriptionStr = (d?: string) => truncate(cleanify(trim(d)), lengths.description) 28 | const cleanSearchStr = (d?: string) => cleanify(sanitize(trim(d))) 29 | 30 | const sequelize = new Sequelize(env.DATABASE_URL, { 31 | timezone: env.TZ, 32 | dialectOptions: { 33 | timezone: env.TZ, // Duplicate because of a bug: https://github.com/sequelize/sequelize/issues/10921 34 | }, 35 | logging: false, 36 | }) 37 | 38 | axios.interceptors.response.use((response) => { 39 | const chardetResult = jschardet.detect(response.data) 40 | const headers = response.headers as IncomingHttpHeaders 41 | const encoding = chardetResult?.encoding || charset(headers, response.data) 42 | 43 | if (encoding) { 44 | response.data = iconv.decode(response.data, encoding) 45 | } 46 | 47 | return response 48 | }) 49 | 50 | sequelize 51 | .authenticate() 52 | .then(() => { 53 | console.log(`Connection to database « ${sequelize.getDatabaseName()} » has been established successfully`) 54 | }) 55 | .catch((err) => { 56 | console.error(`Unable to connect to database`, err) 57 | throw err 58 | }) 59 | .then(() => { 60 | const { findByName, list, insert, remove, count, search, suggest } = FeedModelBuilder(sequelize) 61 | 62 | const app = express() 63 | app.set('view engine', 'ejs') 64 | app.set('views', 'src/views') 65 | 66 | app.use(morgan(':method :url :status :res[content-length] - :response-time ms')) 67 | app.use(cors()) 68 | 69 | const __dirname = dirname(fileURLToPath(import.meta.url)) 70 | app.use( 71 | '/static', 72 | express.static(__dirname + '/static', { 73 | index: false, 74 | maxAge: DAY * 90, 75 | }) 76 | ) 77 | app.use('/manifest.json', express.static(__dirname + '/static/manifest.json')) 78 | 79 | app.get('/', (req, res) => { 80 | const name = cleanNameStr(getStrParam(req, 'name', 'n')) 81 | const title = cleanTitleStr(getStrParam(req, 'title', 't')) 82 | const description = cleanDescriptionStr(getStrParam(req, 'description', 'd')) 83 | const url = cleanUrlStr(getStrParam(req, 'url', 'u')) 84 | const limit = getIntParam(req, 'limit', 'l') ?? 25 85 | 86 | const rootUrl = env.ROOT_URL ?? req.protocol + '://' + req.get('host') 87 | const n = req.query.name || req.query.n 88 | 89 | if (name && !url) { 90 | if (n !== name) { 91 | res.redirect(302, `./?n=${name}`) 92 | return 93 | } 94 | 95 | return findByName({ name, limit }).then((entries) => { 96 | res.type('text/xml') 97 | res.render('rss', { 98 | rootUrl, 99 | public: env.PUBLIC, 100 | title: name, 101 | titleWithFeedName: false, 102 | url: `/?n=${name}`, 103 | entries, 104 | }) 105 | }) 106 | } 107 | res.set('Cache-control', `public, max-age=${DAY}`) 108 | // Using share target API in Chrome sends URL in description :/ so use description field in that case and empty it 109 | const descriptionIsUrl = !url && description.startsWith('http') 110 | const hackUrl = descriptionIsUrl ? description : url 111 | const hackDescription = descriptionIsUrl ? '' : description 112 | res.render('index', { 113 | rootUrl, 114 | public: env.PUBLIC, 115 | lengths, 116 | name, 117 | url: hackUrl, 118 | description: hackDescription, 119 | title, 120 | }) 121 | }) 122 | 123 | if (!env.PUBLIC) { 124 | console.log('enable /list') 125 | app.get('/list', (req, res) => 126 | list().then((feeds) => { 127 | res.set('Cache-control', `public, max-age=${MINUTE}`) 128 | res.render('list', { feeds }) 129 | }) 130 | ) 131 | 132 | console.log('enable /search') 133 | app.get('/search', (req, res) => { 134 | const rootUrl = env.ROOT_URL || req.protocol + '://' + req.get('host') 135 | const query = cleanSearchStr(getStrParam(req, 'query', 'q')) 136 | if (!query) { 137 | res.status(404).end('404 : Missing query parameter') 138 | return 139 | } 140 | if (query.length < 2) { 141 | res.status(400).end('400 : query parameter should be at least 2 characters') 142 | return 143 | } 144 | const limit = getIntParam(req, 'limit', 'l') ?? 100 145 | return search({ query, limit }).then((entries) => { 146 | res.type('text/xml') 147 | res.render('rss', { 148 | rootUrl, 149 | public: env.PUBLIC, 150 | title: `${entries.length} result${entries.length > 1 ? 's' : ''} for search « ${query} »`, 151 | titleWithFeedName: true, 152 | url: `/search?q=${query}`, 153 | entries, 154 | }) 155 | }) 156 | }) 157 | 158 | console.log('enable /suggest') 159 | app.get('/suggest', (req, res) => { 160 | const query = cleanSearchStr(getStrParam(req, 'query', 'q')) 161 | if (!query) { 162 | res.status(404).end('404 : Missing query parameter') 163 | return 164 | } 165 | if (query.length < 2) { 166 | res.status(400).end('400 : query parameter should be at least 2 characters') 167 | return 168 | } 169 | suggest({ query }).then((results) => res.json(results)) 170 | }) 171 | } 172 | 173 | app.get('/add', (req, res) => { 174 | const name = cleanNameStr(getStrParam(req, 'name', 'n')) 175 | const title = cleanTitleStr(getStrParam(req, 'title', 't')) 176 | const description = cleanDescriptionStr(getStrParam(req, 'description', 'd')) 177 | const url = cleanUrlStr(getStrParam(req, 'url', 'u')) 178 | 179 | if (!name || !url) { 180 | res.status(404).end('404 : Missing name or url parameter') 181 | return 182 | } 183 | 184 | const shouldLimitToWikipedia = env.PUBLIC && name === 'somename' 185 | if (!isValidUrl(url, shouldLimitToWikipedia)) { 186 | res.status(400).end('403 : Forbidden') 187 | return 188 | } 189 | return ( 190 | title 191 | ? Promise.resolve({ title, description }) 192 | : Promise.resolve().then(() => 193 | axios 194 | .get(encodeURI(url), { 195 | responseType: 'arraybuffer', 196 | headers: { 197 | 'User-Agent': 198 | 'Mozilla/5.0 (X11; Linux x86_64; rv:78.0) Gecko/20100101 Firefox/78.0', 199 | }, 200 | timeout: 5_000, 201 | }) 202 | .then((response) => { 203 | const { status, data } = response ?? {} 204 | if (status === 200) { 205 | const $ = cheerio.load(data) 206 | 207 | const titleFromPage = $('head title').text() || $('body title').text() 208 | return { 209 | title: truncate(cleanify(titleFromPage), lengths.title), 210 | description: truncate( 211 | cleanify($('head meta[name=description]').attr('content')), 212 | lengths.description 213 | ), 214 | } 215 | } 216 | }) 217 | .catch((error) => { 218 | console.log(error) 219 | }) 220 | ) 221 | ) 222 | .then((metas) => { 223 | const { title, description } = metas ?? {} 224 | return insert({ name, url, title: title ?? url, description }) 225 | }) 226 | .then(() => { 227 | res.redirect(302, `./?n=${name}`) 228 | }) 229 | .catch((err) => { 230 | const msg = `Error while inserting '${url}' in '${name}'` 231 | console.error(msg, err) 232 | res.sendStatus(500).end(msg) 233 | }) 234 | }) 235 | 236 | app.get('/del', (req, res) => { 237 | const name = cleanNameStr(getStrParam(req, 'name', 'n')) 238 | const url = cleanUrlStr(getStrParam(req, 'url', 'u')) 239 | 240 | if (!name || !url) { 241 | res.status(404).end('404 : Missing name or url parameter') 242 | return 243 | } 244 | if (!isValidUrl(url)) res.status(400).end('400 : not an URL') 245 | return remove({ name, url }) 246 | .then(() => res.redirect(302, `./?n=${name}`)) 247 | .catch((err) => { 248 | const msg = `Error while removing '${url}' in '${name}'` 249 | console.error(msg, err) 250 | res.sendStatus(500).end(msg) 251 | }) 252 | }) 253 | 254 | app.get('/count', (req, res) => { 255 | const name = cleanNameStr(getStrParam(req, 'name', 'n')) 256 | if (!name) { 257 | res.status(404).end('404 : Missing name parameter') 258 | return 259 | } 260 | return count({ name }).then(([count]) => { 261 | res.json(count) 262 | }) 263 | }) 264 | 265 | app.listen(PORT, () => { 266 | console.log(`rsstodolist-node-server listening at http://127.0.0.1:${PORT}`) 267 | }) 268 | }) 269 | -------------------------------------------------------------------------------- /src/static/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgreg/rsstodolist-node-server/1282441791e4d479a02d17f48f841b30424174d7/src/static/favicon.png -------------------------------------------------------------------------------- /src/static/manifest.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "RSS", 3 | "name": "rsstodolist", 4 | "icons": [ 5 | { 6 | "src": "./rss-128.png", 7 | "sizes": "128x128", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./rss-256.png", 12 | "sizes": "256x256", 13 | "type": "image/png" 14 | }, 15 | { 16 | "src": "./rss-512.png", 17 | "sizes": "512x512", 18 | "type": "image/png" 19 | } 20 | ], 21 | "start_url": "../", 22 | "display": "minimal-ui", 23 | "theme_color": "#3b7cc0", 24 | "background_color": "#3b7cc0", 25 | "share_target": { 26 | "action": "../", 27 | "enctype": "application/x-www-form-urlencoded", 28 | "method": "GET", 29 | "params": { 30 | "title": "title", 31 | "text": "description", 32 | "url": "url" 33 | } 34 | } 35 | } -------------------------------------------------------------------------------- /src/static/rss-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgreg/rsstodolist-node-server/1282441791e4d479a02d17f48f841b30424174d7/src/static/rss-128.png -------------------------------------------------------------------------------- /src/static/rss-256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgreg/rsstodolist-node-server/1282441791e4d479a02d17f48f841b30424174d7/src/static/rss-256.png -------------------------------------------------------------------------------- /src/static/rss-512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/paulgreg/rsstodolist-node-server/1282441791e4d479a02d17f48f841b30424174d7/src/static/rss-512.png -------------------------------------------------------------------------------- /src/static/rss.css: -------------------------------------------------------------------------------- 1 | body { font-family: sans-serif; } 2 | h1 { width: 100%; padding: .3em; background: darkblue; color: white; } 3 | h2 { margin: 0; color: darkblue; } 4 | article { margin: 1em 0 0 0; } 5 | p { margin: .2em 0 0 0; } 6 | -------------------------------------------------------------------------------- /src/static/rss.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/static/rss.xslt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <xsl:value-of select="title" /> 8 | 9 | 10 | 11 |

12 | 13 | 14 | 15 |
16 | 17 | 18 |
19 |

20 | 21 | 22 | 23 |
24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |

32 |
33 | 34 | 35 | 36 | 37 | 38 |
39 | -------------------------------------------------------------------------------- /src/static/style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | background-color: #3b7cc0; 4 | font-family: sans-serif; 5 | } 6 | 7 | main { 8 | display: flex; 9 | flex-direction: column; 10 | background-color: white; 11 | border-right: 3px solid #159efe; 12 | border-left: 3px solid #159efe; 13 | min-height: 100vh; 14 | } 15 | 16 | a { 17 | display: inline-flex; 18 | } 19 | 20 | h1 { 21 | display: flex; 22 | justify-content: center; 23 | color: darkblue; 24 | } 25 | 26 | h2 { 27 | margin: 0.25em 0.25em 0.5em 0.25em; 28 | } 29 | 30 | ul { 31 | list-style-type: square; 32 | margin: 0.5em 0 0.5em 0em; 33 | } 34 | 35 | form { 36 | display: flex; 37 | flex-direction: column; 38 | justify-content: center; 39 | margin: 0.5em; 40 | } 41 | 42 | form div { 43 | display: flex; 44 | align-items: center; 45 | justify-content: center; 46 | } 47 | 48 | .label { 49 | width: 3.25em; 50 | display: inline-block; 51 | } 52 | 53 | input { 54 | flex-grow: 1; 55 | margin: 0.25em; 56 | } 57 | input[type='submit'] { 58 | max-width: 200px; 59 | } 60 | 61 | p { 62 | margin: 1em 0.5em; 63 | } 64 | 65 | code.block { 66 | display: block; 67 | margin: 0.5em 0; 68 | word-break: break-all; 69 | } 70 | 71 | details { 72 | margin: 0.5em 0; 73 | border: 1px solid #159efe; 74 | border-left: 0; 75 | border-right: 0; 76 | background: #fafdff; 77 | } 78 | summary { 79 | padding: 0.5em; 80 | cursor: pointer; 81 | color: darkblue; 82 | font-weight: bold; 83 | } 84 | 85 | .intro { 86 | border: 2px solid #159efe; 87 | border-left: 0; 88 | border-right: 0; 89 | background: #fafdff; 90 | } 91 | 92 | .feeds { 93 | margin: 0.25em 0 2em 0; 94 | } 95 | 96 | @media (min-width: 1024px) { 97 | body { 98 | margin: 0 5%; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/strings.test.js: -------------------------------------------------------------------------------- 1 | import { trim, truncate, slugify, cleanify, sanitize, isValidUrl } from './strings.ts' 2 | 3 | describe('strings', () => { 4 | describe('trim', () => { 5 | test('should trim string', () => expect(trim(' abcdef ')).toBe('abcdef')) 6 | test('should handle empty string', () => expect(trim(undefined)).toBe('')) 7 | }) 8 | describe('truncate', () => { 9 | test('should truncate', () => expect(truncate('abcdef', 3)).toBe('abc')) 10 | test('should not truncate if shorter', () => expect(truncate('abc', 10)).toBe('abc')) 11 | test('should handle other type than string', () => expect(truncate(5, 10)).toBe('5')) 12 | test('should handle undefined', () => expect(truncate(undefined, 5)).toBe('')) 13 | }) 14 | describe('slugify', () => { 15 | test('should do nothing if name is well formated', () => expect(slugify('abcdef')).toBe('abcdef')) 16 | test('should accept numbers', () => expect(slugify('abc123')).toBe('abc123')) 17 | test('should lower case', () => expect(slugify('AbC')).toBe('abc')) 18 | test('should remove space', () => expect(slugify(' a b c ')).toBe('abc')) 19 | test('should remove accents', () => expect(slugify('-áéó-')).toBe('--')) 20 | test('should remove unicode char', () => expect(slugify('- -™-')).toBe('---')) 21 | test('should handle other type than string', () => expect(slugify(5)).toBe('5')) 22 | test('should handle undefined', () => expect(slugify(undefined)).toBe('')) 23 | }) 24 | describe('cleanify', () => { 25 | test('should remove emoji', () => 26 | expect(cleanify('Firefox OS 🦊🚀 - LinuxFr.org')).toBe('Firefox OS - LinuxFr.org')) 27 | 28 | test('should remove more emoji', () => 29 | expect(cleanify('Git files hidden in plain sight 🫥 - Tyler Cipriani')).toBe( 30 | 'Git files hidden in plain sight - Tyler Cipriani' 31 | )) 32 | 33 | test('should remove unicode char', () => expect(cleanify('- -™-')).toBe('---')) 34 | 35 | test('should remove « lead surrogate »', () => expect(cleanify('|𝚟𝚎𝚛𝚖𝚊𝚍𝚎𝚗|')).toBe('||')) 36 | }) 37 | 38 | describe('sanitize', () => { 39 | test('should remove %', () => expect(sanitize('test%')).toBe('test')) 40 | test('should remove multiple %', () => expect(sanitize('%test%')).toBe('test')) 41 | test('should remove \\%', () => expect(sanitize('\\%test')).toBe('test')) 42 | test('should remove "\' or 1=1', () => expect(sanitize('"\'or 1=1')).toBe('or 1=1')) 43 | test('should remove "\'', () => expect(sanitize('"')).toBe('')) 44 | }) 45 | describe('isValidUrl', () => { 46 | describe('all URL', () => { 47 | test('should return true for valid http url', () => expect(isValidUrl('http://something.com')).toBe(true)) 48 | test('should return true for valid https url', () => expect(isValidUrl('https://something.com')).toBe(true)) 49 | test('should return false for not an url', () => expect(isValidUrl('something.com')).toBe(false)) 50 | }) 51 | describe('only wikipedia', () => { 52 | test('should return true for wikipedia FR url', () => 53 | expect(isValidUrl('https://fr.wikipedia.org/wiki/Wikip%C3%A9dia:Accueil_principal', true)).toBe(true)) 54 | test('should return false for standard url', () => 55 | expect(isValidUrl('http://something.com', true)).toBe(false)) 56 | }) 57 | }) 58 | }) 59 | -------------------------------------------------------------------------------- /src/strings.ts: -------------------------------------------------------------------------------- 1 | export const trim = (s = '') => String(s).trim() 2 | 3 | export const truncate = (s = '', length: number) => String(s).substring(0, length) 4 | 5 | export const slugify = (s = '') => 6 | String(s) 7 | .toLowerCase() 8 | .replace(/[^A-Za-z0-9-]/g, '') 9 | 10 | const UNICODE_RANGE = '[\uDC00-\uDFFF]' 11 | 12 | const UNICODE_FILTER_REGEX = 13 | `(` + 14 | `[\u2700-\u27BF]` + 15 | `|[\uE000-\uF8FF]` + 16 | `|\uD83C${UNICODE_RANGE}` + 17 | `|\uD83D${UNICODE_RANGE}` + 18 | `|\uD83E${UNICODE_RANGE}` + 19 | `|\uD835${UNICODE_RANGE}` + 20 | `|[\u2011-\u26FF]` + 21 | `|\uD83E[\uDD10-\uDDFF]` + 22 | `|\u000b` + 23 | `)` 24 | 25 | export const cleanify = (s = '') => String(s).replace(new RegExp(UNICODE_FILTER_REGEX, 'g'), '') 26 | 27 | export const sanitize = (s = '') => 28 | cleanify(String(s)) 29 | .replace(/%/g, '') 30 | .replace(/['"\/\\]/g, '') 31 | 32 | export const RE_WIKIPEDIA_VALID_URL = new RegExp('^https?://[a-z]{2}.wikipedia.org/') 33 | const RE_VALID_URL = /^https?:\/\// 34 | 35 | export const isValidUrl = (s = '', wikipediaOnly = false) => 36 | (wikipediaOnly ? RE_WIKIPEDIA_VALID_URL : RE_VALID_URL).test(s) 37 | -------------------------------------------------------------------------------- /src/utils.test.js: -------------------------------------------------------------------------------- 1 | import { getStrParam, getIntParam } from './utils.ts' 2 | 3 | describe('utils', () => { 4 | describe('getStrParam', () => { 5 | test('should return value by name', () => 6 | expect(getStrParam({ query: { name: 'test' } }, 'name', 'n')).toEqual('test')) 7 | test('should return value by short name', () => 8 | expect(getStrParam({ query: { n: 'test' } }, 'name', 'n')).toEqual('test')) 9 | }) 10 | describe('getIntParam', () => { 11 | test('should return value by name', () => 12 | expect(getIntParam({ query: { limit: '12' } }, 'limit', 'l')).toEqual(12)) 13 | test('should return value by short name', () => 14 | expect(getIntParam({ query: { l: '12' } }, 'limit', 'l')).toEqual(12)) 15 | 16 | test('should return positive value', () => 17 | expect(getIntParam({ query: { limit: '-12' } }, 'limit', 'l')).toEqual(12)) 18 | }) 19 | }) 20 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Request } from 'express' 2 | 3 | export const getStrParam = (req: Request, paramName: string, shortParamName: string): string | undefined => { 4 | const paramValue = req.query[paramName] 5 | const shortParamValue = req.query[shortParamName] 6 | if (typeof paramValue === 'string') return paramValue 7 | if (typeof shortParamValue === 'string') return shortParamValue 8 | } 9 | 10 | export const getIntParam = (req: Request, paramName: string, shortParamName: string): number | undefined => { 11 | const strParam = getStrParam(req, paramName, shortParamName) 12 | if (typeof strParam === 'string') return Math.abs(parseInt(strParam, 10)) 13 | } 14 | -------------------------------------------------------------------------------- /src/views/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | rsstodolist: an RSS based todo-list 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 |
20 |

rsstodolist

21 | 22 |
23 | 24 |

rsstodolist is a service that provides a simple way to create 25 | RSS feeds by sending URL to a feed.

26 | 27 | <% if (public) { %> 28 |

That service is public and « open », meaning anyone knowing your feed name may see, add or remove 29 | your items.

30 |

Also, that service is hosted via hobby plan on fly.io with some limitations.

31 |

So, for more reliability and privacy, I strongly suggest you to self-host that 32 | application (see related projects below).

33 | <% } %> 34 |
35 | 36 |
37 |
> 38 | Access a feed 39 |
40 |
41 | (required) 43 |
44 |
45 | 46 |
47 |
48 |

49 | 50 | <%= rootUrl %>/?name=somename 51 | 52 |

You can add limit (or l) parameter to fetch from 1 to 500 items (default is 53 | 25 items). 54 |

55 | <% if (!public) { %> 56 |

You can see all existing feeds on 57 | <%= rootUrl %>/list 58 |

59 | <% } %> 60 |
61 |
> 62 | add or delete an item by form 63 |
64 |
65 | action 67 | 68 |
69 |
70 | (required) 72 |
73 |
74 | 76 | (required) 77 |
78 |
79 | 81 |
82 |
83 | 85 |
86 |
87 | 88 |
89 | <% if (public) { %> 90 |

⚠️ On public instance, « somename » feed is limited to 91 | wikipedia pages to limit spam. 92 |

93 | <% } %> 94 |
95 |
96 |
97 | Use Firefox addon and Chrome extension 98 |

A Firefox addon and Chrome 100 | extension are available to add or remove an item in a feed.

101 |
102 |
103 | Add or delete an item by URL 104 |

105 | 106 | <%= rootUrl %> 107 | /add?name=somename&url=https://fr.wikipedia.org/ 108 |
109 | 110 | <%= rootUrl %> 111 | /del?name=somename&url=https://fr.wikipedia.org/ 112 | 113 |

114 |

115 | title and description parameters are required. 116 | Title will be set from web page title but you can define one by 117 | using title parameter. 118 | You can also set a description with description 119 | parameter. 120 |

121 |

All parameters can be minified : n for name, u for url, 122 | t for title and d for description. 123 |

124 |
125 |
126 | Use a bookmarklet 127 |

128 | You can use a bookmarklet. 130 | Create a new bookmarkl and paste the following code in the URL field : 131 | javascript:var rss=prompt('RSS feed name ?');var r=new XMLHttpRequest();r.open('GET','<%= rootUrl %>/add?n='+rss+'&url='+encodeURIComponent(window.location),true);r.setRequestHeader('Content-Type','text/plain;charset=UTF-8');r.onreadystatechange=function(){if(r.readyState==4){alert("Request sent : "+(r.status===200)+" ("+r.status+")");}};r.send(null); 133 | Clicking on that bookmark will add current page to the RSS feed. 134 |

135 |
136 | 149 |
150 | Contact 151 |

Contact me via paulgreg.me.

152 |
153 |
154 |
155 | 164 | 165 | 166 | 167 | -------------------------------------------------------------------------------- /src/views/list.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | rsstodolist: feeds list 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |

rsstodolist

14 |

Available feeds

15 |
    16 | <% feeds.forEach(feed=> { %> 17 |
  • 18 | /?n=<%= feed.name %> 19 | (<%= feed.dataValues.count %>) 20 |
  • 21 | <% }) %> 22 |
23 |
24 | 25 | 26 | -------------------------------------------------------------------------------- /src/views/rss.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | RSS Todo List - <%= title %> 6 | <%= rootUrl %><%= url %> 7 | RSS 2.0 feed containing your todolist items 8 | en-us 9 | me@paulgreg.me (Grégory PAUL) 10 | <% if (public && entries.length > 5) { %> 11 | 12 | Please consider self-hosting rsstodolist service 13 | <%= rootUrl %>/ 14 | rsstodolist-notice@0000000002 15 | If you’re relying on that service, consider self-host the service for more reliability and privacy. 16 | 17 | <% } %> 18 | <% entries.forEach(entry => { %> 19 | 20 | <%= entry.title %><% if (titleWithFeedName) { %> from ?n=<%= entry.name %><% } %> 21 | <%= entry.url %> 22 | <%= entry.description || "" %> 23 | <%= (entry.updatedAt || entry.createdAt).toUTCString() %> 24 | rsstodolist@<%= entry.id %> 25 | 26 | <% }) %> 27 | <% if (entries.length === 0) { %> 28 | 29 | Default item in your feed 30 | <%= rootUrl %>/ 31 | rsstodolist-notice@0000000001 32 | Default sample item 33 | 34 | <% } %> 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Modern JavaScript & Browser Compatibility: */ 4 | "target": "ESNext", // Uses the latest ECMAScript features for modern JavaScript support 5 | 6 | /* Module System Settings: */ 7 | "module": "NodeNext", // Configures Node.js to use ESM module system 8 | "rootDir": "src", // Specifies the source directory for your code 9 | "outDir": "dist", // Specifies the output directory for compiled files 10 | "sourceMap": true, // Enables source maps for easier debugging 11 | 12 | /* Module Resolution Strategy: */ 13 | "moduleResolution": "NodeNext", // Resolves modules using Node’s ESM strategy 14 | "moduleDetection": "force", // Forces TypeScript to treat files as modules 15 | 16 | /* Interoperability and File Consistency: */ 17 | "esModuleInterop": true, // Ensures compatibility between CommonJS and ESM modules 18 | "forceConsistentCasingInFileNames": true, // Prevents case-sensitivity issues across platforms 19 | 20 | /* Strict Type-Checking: */ 21 | "strict": true, // Enables strict type-checking for fewer runtime errors 22 | "noUncheckedIndexedAccess": true, // Enforces type safety for array/object accesses 23 | "noImplicitOverride": true, // Enforces explicit use of `override` for methods overriding base class methods 24 | "noImplicitAny": true, // Prevents the use of `any` type unless explicitly defined 25 | "skipLibCheck": true, // Skips type-checking of declaration files for faster compilation 26 | "resolveJsonModule": true, // Allows importing JSON files as modules 27 | "declaration": true, // Generates `.d.ts` files for type definitions 28 | "allowSyntheticDefaultImports": true, // Allows default imports for CommonJS modules 29 | "verbatimModuleSyntax": true, // Keeps the `import`/`export` syntax as-is without transformation 30 | 31 | "lib": ["ES2022"] // Include ES2022 features 32 | }, 33 | "include": ["src"], // Includes the `src` directory in the project 34 | "exclude": ["node_modules", "dist"] // Excludes `node_modules` and `dist` directories from the project 35 | } -------------------------------------------------------------------------------- /vitest.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | include: ['**/*.test.js'], 6 | globals: true, 7 | }, 8 | }) 9 | --------------------------------------------------------------------------------